diff --git a/AgentCompany-Rust/crates/app_models/src/lib.rs b/AgentCompany-Rust/crates/app_models/src/lib.rs index 8b4befbe..b5f9ceb0 100644 --- a/AgentCompany-Rust/crates/app_models/src/lib.rs +++ b/AgentCompany-Rust/crates/app_models/src/lib.rs @@ -2,4 +2,4 @@ pub mod actions; pub mod state; pub use actions::AppAction; -pub use state::AppState; +pub use state::{AppState, OnboardingStep}; diff --git a/AgentCompany-Rust/crates/app_models/src/state.rs b/AgentCompany-Rust/crates/app_models/src/state.rs index 3f994310..774e27d5 100644 --- a/AgentCompany-Rust/crates/app_models/src/state.rs +++ b/AgentCompany-Rust/crates/app_models/src/state.rs @@ -1,5 +1,13 @@ use domain::SectionId; +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum OnboardingStep { + #[default] + Company, + Mission, + Launch, +} + #[derive(Clone, Debug, Default)] pub struct AppState { pub section: SectionId, @@ -8,6 +16,9 @@ pub struct AppState { pub selected_message_id: Option, pub selected_document_id: Option, pub selected_workspace_id: Option, + pub onboarding_step: OnboardingStep, + pub onboarding_connector_id: Option, + pub onboarding_execution_mode: &'static str, pub status_line: Option, } diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root.rs b/AgentCompany-Rust/crates/app_ui/src/app_root.rs index 99e8f494..95ede351 100644 --- a/AgentCompany-Rust/crates/app_ui/src/app_root.rs +++ b/AgentCompany-Rust/crates/app_ui/src/app_root.rs @@ -5,10 +5,10 @@ use crate::{ text_input::TextInput, }, }; -use app_models::AppState; +use app_models::{AppState, OnboardingStep}; use domain::{ApprovalState, ProfileSnapshot, SectionId}; use gpui::{App, Context, Entity, SharedString, Window, div, prelude::*, px}; -use storage::NewCompanyInput; +use storage::OnboardingBootstrapInput; mod approvals; mod documents; @@ -30,6 +30,8 @@ pub struct AppRoot { company_name_input: Entity, mission_input: Entity, workspace_input: Entity, + onboarding_goal_input: Entity, + onboarding_goal_detail_input: Entity, message_input: Entity, document_title_input: Entity, document_body_input: Entity, @@ -47,6 +49,8 @@ impl AppRoot { company_name_input: TextInput::new(cx, "Company name"), mission_input: TextInput::new(cx, "Mission or thesis direction"), workspace_input: TextInput::new(cx, "Primary workspace path"), + onboarding_goal_input: TextInput::new(cx, "Primary goal"), + onboarding_goal_detail_input: TextInput::new(cx, "Goal acceptance criteria"), message_input: TextInput::new(cx, "Board message"), document_title_input: TextInput::new(cx, "Document title"), document_body_input: TextInput::new(cx, "Document body"), @@ -59,6 +63,15 @@ impl AppRoot { } fn sync_selection(&mut self) { + if self.state.onboarding_connector_id.is_none() { + self.state.onboarding_connector_id = self + .snapshot + .connectors + .iter() + .find(|connector| connector.id == "anthropic") + .or_else(|| self.snapshot.connectors.first()) + .map(|connector| connector.id.clone()); + } if self.state.selected_run_id.is_none() { self.state.selected_run_id = self.snapshot.runs.first().map(|item| item.id.clone()); } @@ -111,22 +124,42 @@ impl AppRoot { } fn create_company(&mut self, cx: &mut Context) { - let input = NewCompanyInput { - name: self.company_name_input.read(cx).value(), - description: self.mission_input.read(cx).value(), + let input = OnboardingBootstrapInput { + company_name: self.company_name_input.read(cx).value(), + company_description: self.mission_input.read(cx).value(), workspace_path: self.workspace_input.read(cx).value(), - execution_mode: "ai_ceo".to_string(), + execution_mode: self.state.onboarding_execution_mode.to_string(), + goal_title: self.onboarding_goal_input.read(cx).value(), + goal_description: self.onboarding_goal_detail_input.read(cx).value(), + connector_id: self + .state + .onboarding_connector_id + .clone() + .unwrap_or_else(|| "anthropic".to_string()), + connector_model: self + .snapshot + .connectors + .iter() + .find(|connector| { + Some(connector.id.as_str()) == self.state.onboarding_connector_id.as_deref() + }) + .and_then(|connector| connector.model.clone()), }; let result = { let mut store = self.store.lock(); - store.create_company(input) + store.bootstrap_onboarding(input) }; match result { Ok(_) => { self.company_name_input.update(cx, |input, _| input.clear()); self.mission_input.update(cx, |input, _| input.clear()); self.workspace_input.update(cx, |input, _| input.clear()); - self.reload("Company created in the native profile.", cx); + self.onboarding_goal_input + .update(cx, |input, _| input.clear()); + self.onboarding_goal_detail_input + .update(cx, |input, _| input.clear()); + self.state.onboarding_step = OnboardingStep::Company; + self.reload("Company bootstrapped from native onboarding.", cx); } Err(error) => self.set_status(error.to_string(), cx), } diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root/onboarding.rs b/AgentCompany-Rust/crates/app_ui/src/app_root/onboarding.rs index 42b68f85..440c6374 100644 --- a/AgentCompany-Rust/crates/app_ui/src/app_root/onboarding.rs +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/onboarding.rs @@ -1,167 +1,459 @@ use super::*; -use gpui::{Animation, AnimationExt, pulsating_between}; +use app_models::OnboardingStep; +use gpui::{Animation, AnimationExt, Hsla, SharedString, pulsating_between, rgb}; use std::time::Duration; impl AppRoot { pub(super) fn render_onboarding(&self, cx: &mut Context) -> impl IntoElement + '_ { + let step = self.state.onboarding_step; + let readiness = onboarding_readiness( + &self.snapshot, + self.state.onboarding_connector_id.as_deref(), + ); + + div().size_full().bg(theme::window_bg()).child( + div() + .size_full() + .flex() + .child(self.render_onboarding_story(step, &readiness)) + .child(div().w(px(12.0)).bg(theme::split_divider())) + .child(self.render_onboarding_panel(step, cx)), + ) + } + + fn render_onboarding_story( + &self, + step: OnboardingStep, + readiness: &[ConnectorReadiness<'_>], + ) -> impl IntoElement + '_ { div() - .size_full() - .bg(theme::window_bg()) + .flex_1() + .flex() + .items_center() + .justify_center() .child( div() - .size_full() - .flex() + .w(px(620.0)) + .gap_8() + .child(onboarding_eyebrow("AGENTCOMPANY DESKTOP")) .child( div() - .flex_1() - .p_8() - .gap_8() + .text_color(theme::text_primary()) + .child( + div() + .text_size(px(68.0)) + .line_height(px(68.0)) + .child("Start a real local-first"), + ) + .child( + div() + .text_size(px(68.0)) + .line_height(px(68.0)) + .child("agent company."), + ), + ) + .child( + div() + .max_w(px(560.0)) + .text_size(px(18.0)) + .line_height(px(30.0)) + .text_color(theme::text_muted()) + .child("Set up your company, connect a runtime, choose an execution mode, and launch the first native operating loop."), + ) + .child(onboarding_stepper(step)) + .child( + div() + .mt(px(44.0)) + .gap_5() + .child( + div() + .text_xs() + .text_color(theme::text_muted()) + .child("CONNECTOR READINESS"), + ) + .children(readiness.iter().map(|item| readiness_row(item))), + ) + .child(div().mt(px(56.0)).child(onboarding_eyebrow("BY ELYDORA"))), + ) + } + + fn render_onboarding_panel( + &self, + step: OnboardingStep, + cx: &mut Context, + ) -> impl IntoElement + '_ { + div() + .w(px(620.0)) + .flex() + .items_center() + .justify_center() + .child(match step { + OnboardingStep::Company => self.render_company_setup_panel(cx).into_any_element(), + OnboardingStep::Mission => self.render_mission_setup_panel(cx).into_any_element(), + OnboardingStep::Launch => self.render_launch_summary_panel(cx).into_any_element(), + }) + } + + fn render_company_setup_panel(&self, cx: &mut Context) -> impl IntoElement + '_ { + let connector_options = primary_connector_options(&self.snapshot); + let selected_connector_id = self + .state + .onboarding_connector_id + .as_deref() + .unwrap_or("anthropic"); + + onboarding_card() + .child(step_counter("COMPANY", 1, 3)) + .child(card_title("Set up the company shell")) + .child(card_divider()) + .child( + div() + .gap_4() + .child(self.company_name_input.clone()) + .child(self.mission_input.clone()) + .child(self.workspace_input.clone()) + .child( + div() + .mt(px(6.0)) + .text_xs() + .line_height(px(20.0)) + .text_color(theme::text_muted()) + .child("The workspace path becomes the default execution boundary for the first operating loop."), + ) + .child( + div() + .mt(px(12.0)) + .gap_3() + .child( + div() + .text_xs() + .text_color(theme::text_muted()) + .child("CEO RUNTIME"), + ) + .child(div().flex().flex_wrap().gap_2().children( + connector_options.into_iter().map(|connector| { + let active = connector.id == selected_connector_id; + let connector_id = connector.id.clone(); + selectable_chip( + connector.label.as_str(), + active, + cx, + move |this, cx| { + this.state.onboarding_connector_id = Some(connector_id.clone()); + cx.notify(); + }, + ) + .into_any_element() + }), + )) + .child( + div() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel_inset()) + .p_4() + .text_sm() + .line_height(px(22.0)) + .text_color(theme::text_secondary()) + .child(onboarding_connector_note(&self.snapshot, selected_connector_id)), + ), + ), + ) + .child( + div() + .mt(px(28.0)) + .flex() + .justify_end() + .child(primary_button("Continue to mission", cx, |this, cx| { + this.state.onboarding_step = OnboardingStep::Mission; + cx.notify(); + })), + ) + } + + fn render_mission_setup_panel(&self, cx: &mut Context) -> impl IntoElement + '_ { + onboarding_card() + .child(step_counter("MISSION", 2, 3)) + .child(card_title("Define the founding mission")) + .child(card_divider()) + .child( + div() + .gap_4() + .child(self.onboarding_goal_input.clone()) + .child(self.onboarding_goal_detail_input.clone()) + .child( + div() + .mt(px(12.0)) + .gap_3() + .child( + div() + .text_xs() + .text_color(theme::text_muted()) + .child("EXECUTION MODE"), + ) .child( div() .flex() .gap_2() - .child(traffic_light(theme::danger())) - .child(traffic_light(rgb_hex(0xfebc2e))) - .child(traffic_light(rgb_hex(0x28c840))), - ) - .child(div().h(px(80.0))) - .child(eyebrow("AGENTCOMPANY DESKTOP")) - .child( - div() - .text_3xl() - .text_color(theme::text_primary()) - .child("Start a real local-first") - .child(div().child("agent company.")), + .child(selectable_chip( + "AI CEO", + self.state.onboarding_execution_mode == "ai_ceo", + cx, + |this, cx| { + this.state.onboarding_execution_mode = "ai_ceo"; + cx.notify(); + }, + )) + .child(selectable_chip( + "AI Co-Founder", + self.state.onboarding_execution_mode == "ai_cofounder", + cx, + |this, cx| { + this.state.onboarding_execution_mode = "ai_cofounder"; + cx.notify(); + }, + )), ) .child( div() - .text_lg() - .text_color(theme::text_muted()) - .child("Set up your company, point it at a workspace, and launch") - .child(div().child("the first native Rust control center.")), - ) - .child(onboarding_steps()) - .child( - div() - .w(px(520.0)) - .p_5() - .gap_4() - .border_1() .rounded_lg() - .border_color(theme::panel_border()) - .bg(theme::panel()) - .child( - div() - .text_xs() - .text_color(theme::text_muted()) - .child("COMPANY SETUP"), - ) - .child(self.company_name_input.clone()) - .child(self.mission_input.clone()) - .child(self.workspace_input.clone()) - .child( - div() - .text_xs() - .text_color(theme::text_muted()) - .child("The native shell writes directly into SQLite with no cloud fallback."), - ), - ) - .child(readiness_panel()) - .child(div().flex_1()) - .child(eyebrow("BY ELYDORA")), - ) - .child( - div() - .w(px(14.0)) - .bg(theme::split_divider()), - ) - .child( - div() - .w(px(520.0)) - .p_8() - .child( - div() - .mt(px(220.0)) - .p_8() - .gap_5() .border_1() - .rounded_lg() .border_color(theme::panel_border()) - .bg(theme::panel()) - .child(success_badge()) - .child( - div() - .text_2xl() - .text_color(theme::text_primary()) - .child("Native shell ready."), - ) - .child( - div() - .text_lg() - .text_color(theme::text_muted()) - .child("Create the company record, register the workspace, and") - .child(div().child("open the GPUI control center.")), - ) - .child( - div() - .flex() - .gap_3() - .child(launch_tile("CONNECTOR", "SQLite local profile")) - .child(launch_tile("LEAD AGENT", "CEO shell created")), - ) - .child( - div() - .flex() - .gap_3() - .child(launch_tile("PRIMARY WORKSPACE", "Registered on create")) - .child(launch_tile("KICKOFF PROJECT", "Created from first task")), - ) - .child(launch_tile("INITIAL RUN", "Queued once company state lands")) - .child(primary_button("Create company", cx, |this, cx| { - this.create_company(cx) - })), + .bg(theme::panel_inset()) + .p_4() + .text_sm() + .line_height(px(22.0)) + .text_color(theme::text_secondary()) + .child(onboarding_execution_mode_note( + self.state.onboarding_execution_mode, + )), ), ), ) + .child( + div() + .mt(px(28.0)) + .flex() + .justify_between() + .child(secondary_button("Back", cx, |this, cx| { + this.state.onboarding_step = OnboardingStep::Company; + cx.notify(); + })) + .child(primary_button("Review launch", cx, |this, cx| { + this.state.onboarding_step = OnboardingStep::Launch; + cx.notify(); + })), + ) + } + + fn render_launch_summary_panel(&self, cx: &mut Context) -> impl IntoElement + '_ { + let connector_id = self + .state + .onboarding_connector_id + .as_deref() + .unwrap_or("anthropic"); + let connector_label = self + .snapshot + .connectors + .iter() + .find(|connector| connector.id == connector_id) + .map(|connector| connector.label.clone()) + .unwrap_or_else(|| connector_id.to_string()); + let company_name = self.company_name_input.read(cx).value(); + let workspace_path = self.workspace_input.read(cx).value(); + let workspace_value = if workspace_path.trim().is_empty() { + "No workspace selected".to_string() + } else { + "Registered".to_string() + }; + let goal_title = self.onboarding_goal_input.read(cx).value(); + + onboarding_card() + .child(success_badge()) + .child( + div() + .mt(px(28.0)) + .text_size(px(32.0)) + .line_height(px(38.0)) + .text_color(theme::text_primary()) + .child(if company_name.trim().is_empty() { + "Native control center is ready.".to_string() + } else { + format!("{} is ready to launch.", company_name.trim()) + }), + ) + .child( + div() + .mt(px(16.0)) + .text_size(px(16.0)) + .line_height(px(26.0)) + .text_color(theme::text_muted()) + .child("Company, workspace, goal, lead agent, kickoff project, task, and the initial queued run will be created in the local profile."), + ) + .child( + div() + .mt(px(28.0)) + .grid() + .grid_cols(2) + .gap_4() + .child(summary_tile("CONNECTOR", connector_label)) + .child(summary_tile("LEAD AGENT", "CEO agent created".to_string())) + .child(summary_tile("PRIMARY WORKSPACE", workspace_value)) + .child(summary_tile("KICKOFF PROJECT", "Created".to_string())) + .child(summary_tile( + "INITIAL RUN", + if goal_title.trim().is_empty() { + "Queued".to_string() + } else { + format!("Queued for {}", goal_title.trim()) + }, + )), + ) + .child( + div() + .mt(px(32.0)) + .flex() + .justify_between() + .child(secondary_button("Back", cx, |this, cx| { + this.state.onboarding_step = OnboardingStep::Mission; + cx.notify(); + })) + .child(primary_button("Open Control Center", cx, |this, cx| { + this.create_company(cx); + })), + ) } } -fn eyebrow(label: &'static str) -> impl IntoElement { +struct ConnectorReadiness<'a> { + label: &'a str, + state: &'a str, + help: Option<&'a str>, + tone: ReadinessTone, +} + +#[derive(Clone, Copy)] +enum ReadinessTone { + Ready, + Warning, + Neutral, +} + +fn onboarding_readiness<'a>( + snapshot: &'a domain::ProfileSnapshot, + selected_connector_id: Option<&'a str>, +) -> Vec> { + let mut rows = Vec::new(); + let priority = [ + selected_connector_id.unwrap_or("anthropic"), + "openai-codex", + "gemini-cli", + ]; + for connector_id in priority { + if let Some(connector) = snapshot + .connectors + .iter() + .find(|item| item.id == connector_id) + { + let tone = if connector.status == "ready" || connector.auth_state == "ready" { + ReadinessTone::Ready + } else if connector.last_error.is_some() || connector.auth_state == "auth_required" { + ReadinessTone::Warning + } else { + ReadinessTone::Neutral + }; + let state = if connector.status == "ready" { + "ready" + } else if connector.auth_state == "ready" { + "authenticated" + } else if connector.auth_state == "auth_required" { + "auth required" + } else if connector.status.is_empty() { + "unknown" + } else { + connector.status.as_str() + }; + let help = connector.last_error.as_deref().or_else(|| { + (!connector.notes.trim().is_empty()).then_some(connector.notes.as_str()) + }); + rows.push(ConnectorReadiness { + label: connector.label.as_str(), + state, + help, + tone, + }); + } + } + rows +} + +fn primary_connector_options(snapshot: &domain::ProfileSnapshot) -> Vec<&domain::Connector> { + let preferred = ["anthropic", "openai-codex", "claude-pro", "gemini-cli"]; + let mut options = Vec::new(); + for connector_id in preferred { + if let Some(connector) = snapshot + .connectors + .iter() + .find(|item| item.id == connector_id) + { + options.push(connector); + } + } + if options.is_empty() { + snapshot.connectors.iter().take(4).collect() + } else { + options + } +} + +fn onboarding_eyebrow(label: &'static str) -> impl IntoElement { div() .flex() .gap_3() .items_center() - .child(div().w(px(48.0)).h(px(1.0)).bg(theme::text_subtle())) - .child( - div() - .text_xs() - .text_color(theme::text_subtle()) - .child(label), - ) + .child(div().w(px(44.0)).h(px(1.0)).bg(theme::text_subtle())) + .child(div().text_xs().text_color(theme::text_muted()).child(label)) } -fn onboarding_steps() -> impl IntoElement { +fn onboarding_stepper(step: OnboardingStep) -> impl IntoElement { div() + .mt(px(12.0)) .flex() .gap_3() .items_center() - .child(step_chip("Company", true)) - .child(div().w(px(32.0)).h(px(1.0)).bg(theme::text_subtle())) - .child(step_chip("Mission", true)) - .child(div().w(px(32.0)).h(px(1.0)).bg(theme::text_subtle())) - .child(step_chip("Launch", false)) + .child(major_step_chip( + "Company", + matches!(step, OnboardingStep::Company), + true, + )) + .child(step_connector()) + .child(major_step_chip( + "Mission", + matches!(step, OnboardingStep::Mission), + !matches!(step, OnboardingStep::Company), + )) + .child(step_connector()) + .child(major_step_chip( + "Launch", + matches!(step, OnboardingStep::Launch), + false, + )) } -fn step_chip(label: &'static str, complete: bool) -> impl IntoElement { +fn major_step_chip(label: &'static str, current: bool, complete: bool) -> impl IntoElement { + let bg = if complete { + theme::accent_teal_soft() + } else if current { + theme::panel_muted() + } else { + rgb(0xf1f3f7).into() + }; let chip = div() .px_4() .py_2() .rounded_full() - .bg(if complete { - theme::success_bg() - } else { - theme::panel_muted() - }) + .bg(bg) .text_sm() .text_color(if complete { theme::accent_teal() @@ -169,12 +461,12 @@ fn step_chip(label: &'static str, complete: bool) -> impl IntoElement { theme::text_secondary() }) .child(label); - if complete { + if current { chip.with_animation( - ("step-chip", label.len()), - Animation::new(Duration::from_secs(3)) + ("onboarding-major-step", label.len()), + Animation::new(Duration::from_secs(2)) .repeat() - .with_easing(pulsating_between(0.78, 1.0)), + .with_easing(pulsating_between(0.84, 1.0)), |element, delta| element.opacity(delta), ) .into_any_element() @@ -183,90 +475,194 @@ fn step_chip(label: &'static str, complete: bool) -> impl IntoElement { } } -fn readiness_panel() -> impl IntoElement { - div() - .w(px(520.0)) - .gap_3() - .child( - div() - .text_xs() - .text_color(theme::text_muted()) - .child("CONNECTOR READINESS"), - ) - .child(readiness_row("Claude Code", "ready", true)) - .child(readiness_row("Codex", "ready", true)) - .child(readiness_row("Gemini CLI", "auth required", false)) - .child( - div() - .pl(px(28.0)) - .text_xs() - .text_color(theme::text_subtle()) - .child("Gemini CLI requires authentication before the runtime can use it."), - ) +fn step_connector() -> impl IntoElement { + div().w(px(42.0)).h(px(1.0)).bg(theme::panel_border()) } -fn readiness_row(label: &'static str, state: &'static str, ready: bool) -> impl IntoElement { +fn readiness_row(row: &ConnectorReadiness<'_>) -> impl IntoElement { div() - .flex() - .gap_3() - .items_center() - .child(div().w(px(16.0)).h(px(16.0)).rounded_full().bg(if ready { - theme::accent_teal() - } else { - theme::accent_orange() - })) + .gap_2() .child( div() - .text_sm() - .text_color(theme::text_primary()) - .child(label), + .flex() + .gap_3() + .items_center() + .child(readiness_dot(row.tone)) + .child( + div() + .text_base() + .text_color(theme::text_primary()) + .child(row.label.to_string()), + ) + .child( + div() + .text_base() + .text_color(theme::text_muted()) + .child(row.state.to_string()), + ), ) - .child(div().text_sm().text_color(theme::text_muted()).child(state)) + .when_some(row.help, |container, help| { + container.child( + div() + .pl(px(28.0)) + .text_sm() + .line_height(px(22.0)) + .text_color(theme::text_muted()) + .child(help.to_string()), + ) + }) +} + +fn readiness_dot(tone: ReadinessTone) -> impl IntoElement { + let color: Hsla = match tone { + ReadinessTone::Ready => theme::accent_teal(), + ReadinessTone::Warning => theme::accent_orange(), + ReadinessTone::Neutral => theme::badge_dark(), + }; + div().w(px(16.0)).h(px(16.0)).rounded_full().bg(color) +} + +fn onboarding_card() -> gpui::Div { + div() + .w(px(650.0)) + .p_10() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) +} + +fn step_counter(label: &'static str, index: usize, total: usize) -> impl IntoElement { + div() + .text_xs() + .text_color(theme::text_muted()) + .child(format!("{} {} / {}", label, index, total)) +} + +fn card_title(label: &'static str) -> impl IntoElement { + div() + .mt(px(8.0)) + .text_size(px(24.0)) + .line_height(px(30.0)) + .text_color(theme::text_primary()) + .child(label) +} + +fn card_divider() -> impl IntoElement { + div() + .mt(px(12.0)) + .mb(px(24.0)) + .h(px(1.0)) + .bg(theme::panel_border()) +} + +fn selectable_chip( + label: &str, + active: bool, + cx: &mut Context, + on_click: impl Fn(&mut V, &mut Context) + 'static, +) -> impl IntoElement +where + V: 'static, +{ + div() + .id(SharedString::from(format!("onboarding-chip-{label}"))) + .px_4() + .py_2() + .rounded_full() + .cursor_pointer() + .bg(if active { + theme::primary_action() + } else { + theme::panel_muted() + }) + .text_color(if active { + theme::primary_action_text() + } else { + theme::text_secondary() + }) + .hover(|style| { + style.bg(if active { + theme::primary_action_hover() + } else { + theme::panel_hover() + }) + }) + .on_click( + cx.listener(move |this, _: &gpui::ClickEvent, _: &mut Window, cx| on_click(this, cx)), + ) + .child(label.to_string()) +} + +fn onboarding_connector_note(snapshot: &domain::ProfileSnapshot, connector_id: &str) -> String { + snapshot + .connectors + .iter() + .find(|connector| connector.id == connector_id) + .map(|connector| { + if !connector.notes.trim().is_empty() { + connector.notes.clone() + } else if !connector.description.trim().is_empty() { + connector.description.clone() + } else { + format!( + "{} will be bound to the founding CEO agent.", + connector.label + ) + } + }) + .unwrap_or_else(|| "The selected connector becomes the default CEO runtime.".to_string()) +} + +fn onboarding_execution_mode_note(mode: &str) -> &'static str { + match mode { + "ai_cofounder" => { + "AI Co-Founder keeps human review tighter on deliverables while preserving a shared planning loop." + } + _ => { + "AI CEO starts the company autonomously, boots the lead agent, creates the first project, and queues the kickoff run." + } + } } fn success_badge() -> impl IntoElement { div() - .w(px(72.0)) - .h(px(72.0)) + .w(px(60.0)) + .h(px(60.0)) .rounded_full() .bg(theme::success_bg()) .child( div() - .m(px(20.0)) - .w(px(32.0)) - .h(px(32.0)) + .m(px(16.0)) + .w(px(28.0)) + .h(px(28.0)) .rounded_full() .bg(theme::accent_teal()) .with_animation( - "success-badge-inner", + "onboarding-success-badge", Animation::new(Duration::from_secs(2)) .repeat() - .with_easing(pulsating_between(0.72, 1.0)), + .with_easing(pulsating_between(0.76, 1.0)), |element, delta| element.opacity(delta), ), ) } -fn launch_tile(label: &'static str, value: &'static str) -> impl IntoElement { +fn summary_tile(label: &'static str, value: String) -> impl IntoElement { div() - .flex_1() - .p_4() - .gap_2() + .min_h(px(92.0)) + .p_5() .rounded_lg() - .bg(theme::panel_muted()) + .border_1() + .border_color(theme::panel_inset_border()) + .bg(theme::panel_inset()) .child(div().text_xs().text_color(theme::text_muted()).child(label)) .child( div() - .text_sm() + .mt(px(10.0)) + .text_base() .text_color(theme::text_primary()) + .line_height(px(22.0)) .child(value), ) } - -fn rgb_hex(hex: u32) -> gpui::Hsla { - gpui::rgb(hex).into() -} - -fn traffic_light(color: gpui::Hsla) -> impl IntoElement { - div().w(px(12.0)).h(px(12.0)).rounded_full().bg(color) -} diff --git a/AgentCompany-Rust/crates/storage/src/lib.rs b/AgentCompany-Rust/crates/storage/src/lib.rs index e86f5407..5c3a06f2 100644 --- a/AgentCompany-Rust/crates/storage/src/lib.rs +++ b/AgentCompany-Rust/crates/storage/src/lib.rs @@ -1,4 +1,6 @@ mod schema; mod store; -pub use store::{NewCompanyInput, ProfileStore}; +pub use store::{ + NewCompanyInput, OnboardingBootstrapInput, OnboardingBootstrapSummary, ProfileStore, +}; diff --git a/AgentCompany-Rust/crates/storage/src/store.rs b/AgentCompany-Rust/crates/storage/src/store.rs index 572d553b..7d8bff94 100644 --- a/AgentCompany-Rust/crates/storage/src/store.rs +++ b/AgentCompany-Rust/crates/storage/src/store.rs @@ -27,6 +27,29 @@ pub struct NewCompanyInput { pub execution_mode: String, } +#[derive(Clone, Debug, Default)] +pub struct OnboardingBootstrapInput { + pub company_name: String, + pub company_description: String, + pub workspace_path: String, + pub execution_mode: String, + pub goal_title: String, + pub goal_description: String, + pub connector_id: String, + pub connector_model: Option, +} + +#[derive(Clone, Debug, Default)] +pub struct OnboardingBootstrapSummary { + pub company_id: String, + pub company_name: String, + pub workspace_id: Option, + pub lead_agent_id: String, + pub project_id: String, + pub task_id: String, + pub run_id: String, +} + pub struct ProfileStore { profile_dir: PathBuf, db_path: PathBuf, @@ -149,6 +172,160 @@ impl ProfileStore { Ok(company_id) } + pub fn bootstrap_onboarding( + &mut self, + input: OnboardingBootstrapInput, + ) -> Result { + let company_name = input.company_name.trim(); + if company_name.len() < 2 { + return Err(anyhow!("company name must be at least 2 characters")); + } + let connector_id = self.resolve_onboarding_connector(input.connector_id.trim())?; + let goal_title = input.goal_title.trim(); + if goal_title.is_empty() { + return Err(anyhow!("goal title cannot be empty")); + } + + let company_id = Uuid::new_v4().to_string(); + let workspace_id = if input.workspace_path.trim().is_empty() { + None + } else { + Some(Uuid::new_v4().to_string()) + }; + let goal_id = Uuid::new_v4().to_string(); + let lead_agent_id = Uuid::new_v4().to_string(); + let project_id = Uuid::new_v4().to_string(); + let task_id = Uuid::new_v4().to_string(); + let run_id = Uuid::new_v4().to_string(); + let ts = now_iso()?; + let execution_mode = normalized_execution_mode(&input.execution_mode); + let tx = self.conn.transaction()?; + tx.execute( + r#" + insert into companies ( + id, name, description, status, auto_approve_hires, + review_deliverables, execution_mode, created_at, updated_at + ) values (?1, ?2, ?3, 'active', ?4, ?5, ?6, ?7, ?7) + "#, + params![ + company_id, + company_name, + input.company_description.trim(), + if execution_mode == "ai_ceo" { 1 } else { 0 }, + if execution_mode == "ai_cofounder" { + 1 + } else { + 0 + }, + execution_mode, + ts, + ], + )?; + tx.execute( + "insert into settings (key, value) values ('current_company_id', ?1) on conflict(key) do update set value = excluded.value", + params![company_id], + )?; + if let Some(workspace_id) = workspace_id.as_ref() { + tx.execute( + r#" + insert into workspaces ( + id, company_id, name, local_path, repo_url, repo_ref, + project_id, state, is_primary, created_at, updated_at + ) values (?1, ?2, 'Default Workspace', ?3, '', '', null, 'ready', 1, ?4, ?4) + "#, + params![workspace_id, company_id, input.workspace_path.trim(), ts], + )?; + } + tx.execute( + "insert into goals (id, company_id, title, description, parent_id, owner_agent_id, status, created_at, updated_at) values (?1, ?2, ?3, ?4, null, null, 'active', ?5, ?5)", + params![ + goal_id, + company_id, + goal_title, + if input.goal_description.trim().is_empty() { + format!("Achieve: {goal_title}") + } else { + input.goal_description.trim().to_string() + }, + ts, + ], + )?; + tx.execute( + "insert into agents (id, company_id, name, role, title, department, status, reports_to, connector_id, workspace_id, model, capabilities, budget_monthly_usd, spent_monthly_usd, created_at, updated_at) values (?1, ?2, 'CEO', 'CEO', 'Chief Executive Officer', null, 'idle', null, ?3, ?4, ?5, ?6, 250, 0, ?7, ?7)", + params![ + lead_agent_id, + company_id, + connector_id, + workspace_id, + input.connector_model.as_deref().map(str::trim).filter(|value| !value.is_empty()), + "Executive leadership, strategic planning, team building, goal decomposition, delegation, and organizational management.", + ts, + ], + )?; + tx.execute( + "insert into projects (id, company_id, goal_id, name, description, lead_agent_id, status, target_date, created_at, updated_at) values (?1, ?2, ?3, ?4, ?5, ?6, 'in_progress', null, ?7, ?7)", + params![ + project_id, + company_id, + goal_id, + format!("Execution plan: {goal_title}"), + format!("Turn the founding goal \"{goal_title}\" into the first real operating plan and delegation map."), + lead_agent_id, + ts, + ], + )?; + tx.execute( + "insert into tasks (id, company_id, project_id, goal_id, parent_id, title, description, assignee_agent_id, workspace_id, status, priority, active_run_id, cost_usd, task_type, requires_user_review, started_at, completed_at, cancelled_at, created_at, updated_at) values (?1, ?2, ?3, ?4, null, ?5, ?6, ?7, ?8, 'todo', 'high', ?9, 0, 'execution', 0, null, null, null, ?10, ?10)", + params![ + task_id, + company_id, + project_id, + goal_id, + format!("Operationalize goal: {goal_title}"), + kickoff_task_description(company_name, input.company_description.trim(), goal_title), + lead_agent_id, + workspace_id, + run_id, + ts, + ], + )?; + tx.execute( + "insert into runs (id, company_id, task_id, agent_id, workspace_id, connector_id, status, summary, error_message, model, cost_usd, started_at, finished_at, created_at, updated_at) values (?1, ?2, ?3, ?4, ?5, ?6, 'queued', ?7, null, ?8, null, null, null, ?9, ?9)", + params![ + run_id, + company_id, + task_id, + lead_agent_id, + workspace_id, + connector_id, + "Kickoff execution queued", + input.connector_model.as_deref().map(str::trim).filter(|value| !value.is_empty()), + ts, + ], + )?; + tx.execute( + "insert into activity (id, company_id, actor, action, entity_type, entity_id, detail, created_at) values (?1, ?2, ?3, 'company.bootstrapped', 'companies', ?4, ?5, ?6)", + params![ + Uuid::new_v4().to_string(), + company_id, + BOARD_READER_ID, + company_id, + format!("Bootstrapped from onboarding with connector {connector_id}."), + ts, + ], + )?; + tx.commit()?; + Ok(OnboardingBootstrapSummary { + company_id, + company_name: company_name.to_string(), + workspace_id, + lead_agent_id, + project_id, + task_id, + run_id, + }) + } + pub fn set_current_company(&mut self, company_id: &str) -> Result<()> { self.conn.execute( "insert into settings (key, value) values ('current_company_id', ?1) on conflict(key) do update set value = excluded.value", @@ -157,6 +334,18 @@ impl ProfileStore { Ok(()) } + fn resolve_onboarding_connector(&self, connector_id: &str) -> Result { + let resolved = resolve_onboarding_connector_fallback(connector_id); + self.conn + .query_row( + "select id from connectors where id = ?1", + params![resolved], + |row| row.get(0), + ) + .optional()? + .ok_or_else(|| anyhow!("connector {resolved} was not found")) + } + pub fn update_theme(&mut self, theme: &str) -> Result<()> { let normalized = match theme { "system" | "light" | "dark" => theme, @@ -1040,6 +1229,25 @@ fn subject_for(body: &str) -> String { subject } +fn kickoff_task_description( + company_name: &str, + company_description: &str, + goal_title: &str, +) -> String { + let description = if company_description.trim().is_empty() { + "No company description yet." + } else { + company_description.trim() + }; + [ + format!("Read the founding goal \"{goal_title}\" and convert it into the first real operating plan."), + "Explore the workspace and identify the project structure, key files, frameworks, and execution constraints.".to_string(), + format!("Use the company context — name \"{company_name}\" and description \"{description}\" — when deciding the first departments, delegation plan, and operating cadence."), + "Create the first tasks, approval checkpoints, and coordination messages needed to start execution.".to_string(), + ] + .join("\n\n") +} + fn normalized_execution_mode(value: &str) -> &str { match value { "ai_cofounder" => "ai_cofounder", @@ -1047,6 +1255,10 @@ fn normalized_execution_mode(value: &str) -> &str { } } +fn resolve_onboarding_connector_fallback(value: &str) -> &str { + if value.is_empty() { "anthropic" } else { value } +} + fn default_locale() -> String { match env::var("LANG") { Ok(lang) if lang.to_ascii_lowercase().contains("zh") => "zh".to_string(), diff --git a/AgentCompany-Rust/crates/storage/tests/onboarding_bootstrap_e2e.rs b/AgentCompany-Rust/crates/storage/tests/onboarding_bootstrap_e2e.rs new file mode 100644 index 00000000..b2258ec5 --- /dev/null +++ b/AgentCompany-Rust/crates/storage/tests/onboarding_bootstrap_e2e.rs @@ -0,0 +1,81 @@ +use storage::{OnboardingBootstrapInput, ProfileStore}; +use tempfile::tempdir; + +#[test] +fn onboarding_bootstrap_creates_company_goal_agent_project_task_and_run() { + let sandbox = tempdir().expect("temp dir"); + let mut store = ProfileStore::open(sandbox.path()).expect("open store"); + + let summary = store + .bootstrap_onboarding(OnboardingBootstrapInput { + company_name: "Orbit Ops".to_string(), + company_description: "Local-first operations company".to_string(), + workspace_path: "/tmp/orbit-ops".to_string(), + execution_mode: "ai_ceo".to_string(), + goal_title: "Ship the native shell".to_string(), + goal_description: + "Rebuild the product in Rust and GPUI without losing product grammar.".to_string(), + connector_id: "anthropic".to_string(), + connector_model: Some("claude-sonnet".to_string()), + }) + .expect("bootstrap onboarding"); + + let snapshot = store.snapshot().expect("snapshot"); + assert_eq!( + snapshot.current_company_id.as_deref(), + Some(summary.company_id.as_str()) + ); + assert!( + snapshot + .companies + .iter() + .any(|company| company.id == summary.company_id && company.name == "Orbit Ops") + ); + assert!( + snapshot + .workspaces + .iter() + .any(|workspace| Some(workspace.id.as_str()) == summary.workspace_id.as_deref()) + ); + assert!( + snapshot + .goals + .iter() + .any(|goal| goal.title == "Ship the native shell" && goal.status == "active") + ); + assert!(snapshot.agents.iter().any(|agent| { + agent.id == summary.lead_agent_id + && agent.name == "CEO" + && agent.connector_id == "anthropic" + && agent.model.as_deref() == Some("claude-sonnet") + })); + assert!( + snapshot + .projects + .iter() + .any(|project| project.id == summary.project_id && project.status == "in_progress") + ); + assert!(snapshot.tasks.iter().any(|task| { + task.id == summary.task_id + && task.active_run_id.as_deref() == Some(summary.run_id.as_str()) + && task.assignee_agent_id.as_deref() == Some(summary.lead_agent_id.as_str()) + })); + assert!(snapshot.runs.iter().any(|run| { + run.id == summary.run_id + && run.agent_id == summary.lead_agent_id + && run.status == "queued" + && run.task_id.as_deref() == Some(summary.task_id.as_str()) + })); + assert!(snapshot.activity.iter().any(|activity| { + activity.entity_id == summary.company_id && activity.action == "company.bootstrapped" + })); + + let reopened = ProfileStore::open(sandbox.path()).expect("reopen store"); + let reopened_snapshot = reopened.snapshot().expect("reopened snapshot"); + assert!( + reopened_snapshot + .runs + .iter() + .any(|run| run.id == summary.run_id && run.status == "queued") + ); +} diff --git a/AgentCompany-Rust/prd.json b/AgentCompany-Rust/prd.json index 881eae71..99ce4a2f 100644 --- a/AgentCompany-Rust/prd.json +++ b/AgentCompany-Rust/prd.json @@ -128,6 +128,76 @@ "priority": 9, "passes": true, "notes": "Completed with the final native verification matrix: profile_store_e2e.rs covers onboarding bootstrap, native_surface_e2e.rs covers run/approval/document visibility, native_settings_workspace_e2e.rs covers settings/workspaces, runtime_live_flow_e2e.rs covers live runtime approval/message/run flows, and native_inbox_attention_e2e.rs covers inbox attention derivation. cargo clippy/test both pass." + }, + { + "id": "US-010", + "title": "Restore onboarding parity against original wizard flow", + "description": "As a first-time operator, I want the native onboarding surface to preserve the original multi-step wizard grammar so that setup feels identical to the Electron app.", + "acceptanceCriteria": [ + "Onboarding restores the original staged wizard structure instead of the simplified dual-column placeholder shell", + "Native onboarding reflects original step semantics such as readiness, provider setup, mission, and launch summary", + "Typecheck passes", + "cargo test --workspace passes" + ], + "priority": 10, + "passes": false, + "notes": "In progress: native onboarding now uses a real multi-stage company/mission/launch flow with screenshot-aligned left narrative + right summary grammar, real connector readiness from snapshot data, and a real bootstrap transaction that creates company/workspace/goal/CEO/project/task/run. Remaining gap: restore the full original wizard tab semantics (nodecheck/providers/modelpool/executionmode) more precisely." + }, + { + "id": "US-011", + "title": "Restore dashboard and inbox information architecture parity", + "description": "As an operator, I want the dashboard and inbox to match the original attention hierarchy, banners, and filtering behavior so that operating cadence feels unchanged.", + "acceptanceCriteria": [ + "Dashboard restores original metrics, attention rows, active work, and recent activity grammar instead of simplified placeholder sections", + "Inbox restores original filterable attention list behavior instead of static grouped blocks with hardcoded active filter state", + "Typecheck passes", + "cargo test --workspace passes" + ], + "priority": 11, + "passes": false, + "notes": "Audit found missing dashboard banners/charts/attention structure and inbox filter interaction parity." + }, + { + "id": "US-012", + "title": "Restore runs and approvals page grammar to original operational surfaces", + "description": "As an operator, I want runs and approvals to keep the original queue/detail and review grouping semantics so that native review flows feel identical to Electron.", + "acceptanceCriteria": [ + "Runs restores original queue/detail grammar and removes non-source control slabs that distort layout hierarchy", + "Approvals restores board/user/manager review grouping and richer approval inspector semantics from the original source", + "Typecheck passes", + "cargo test --workspace passes" + ], + "priority": 12, + "passes": false, + "notes": "Audit found the live runtime controls now dominate the run detail layout and approvals still flatten original review semantics." + }, + { + "id": "US-013", + "title": "Restore document center parity including work product grammar", + "description": "As an operator, I want documents and work products to preserve the original center/editor grammar so that company operations remain familiar.", + "acceptanceCriteria": [ + "Document Center restores the original document plus work-product information architecture rather than a simplified draft form and flat list", + "Document actions and metadata hierarchy align with original Electron semantics", + "Typecheck passes", + "cargo test --workspace passes" + ], + "priority": 13, + "passes": false, + "notes": "Audit found current native documents surface lacks work-product grouping, modal/editor grammar, and action hierarchy from the original source." + }, + { + "id": "US-014", + "title": "Run final UI parity re-audit after remediation", + "description": "As a maintainer, I want a final source-to-native parity audit after remediation so that we can truthfully claim the native UI matches the Electron product grammar.", + "acceptanceCriteria": [ + "Key native surfaces are re-audited against original source components and screenshots", + "Remaining parity deviations are documented or eliminated", + "cargo clippy --workspace --all-targets --all-features -- -D warnings passes", + "cargo test --workspace passes" + ], + "priority": 14, + "passes": false, + "notes": "Created after post-completion audit found significant remaining UI grammar gaps." } ] } diff --git a/AgentCompany-Rust/progress.txt b/AgentCompany-Rust/progress.txt index 43f72d71..c6a48d68 100644 --- a/AgentCompany-Rust/progress.txt +++ b/AgentCompany-Rust/progress.txt @@ -13,4 +13,5 @@ Started: 2026-04-19 - Added runtime contract persistence entities for run events and artifacts, exposed them in ProfileSnapshot, and rendered them in the native runs inspector. - Added runtime_contract_snapshot_e2e.rs to verify runtime event and artifact persistence across reopen. - Runtime contract integration advanced: RuntimeEvent ingestion now persists message/tool/artifact/run-finished events into storage and exposes them in the runs inspector. -- Wired a live native runtime session into the GPUI Runs surface. Selected runs now launch Pi Mono through env-configured stdio sidecar settings, runtime tool calls create approval rows, approval decisions send ToolApproval back into the live session, runtime messages project into agent_messages, and run lifecycle still persists into run_events/artifacts/runs.\n- Closed the final native verification matrix. The workspace now has explicit storage-backed e2e coverage for onboarding bootstrap, approvals/documents/runs visibility, settings/workspace mutations, live runtime contract flows, and inbox attention derivation.\n \ No newline at end of file +- Wired a live native runtime session into the GPUI Runs surface. Selected runs now launch Pi Mono through env-configured stdio sidecar settings, runtime tool calls create approval rows, approval decisions send ToolApproval back into the live session, runtime messages project into agent_messages, and run lifecycle still persists into run_events/artifacts/runs.\n- Closed the final native verification matrix. The workspace now has explicit storage-backed e2e coverage for onboarding bootstrap, approvals/documents/runs visibility, settings/workspace mutations, live runtime contract flows, and inbox attention derivation.\n- Reopened Ralph parity work after a direct source audit. The current native app has real runtime/data flows, but several surfaces still diverge from Electron page grammar: onboarding is too collapsed, dashboard misses banner/attention/chart structure, inbox filters are not real, run detail is dominated by native control slabs not present in source, approvals flatten board/user/manager semantics, and documents still lack work-product/editor hierarchy. Added US-010 through US-014 to close these parity gaps honestly. +- Rebuilt native onboarding away from the old static split shell. It now has a real multi-stage company/mission/launch flow, screenshot-aligned narrative/summary composition, real connector readiness derived from snapshot data, and a storage bootstrap transaction that creates company, workspace, goal, CEO, kickoff project, kickoff task, and queued run. Remaining US-010 work is the finer original wizard-tab parity.\n \ No newline at end of file