diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root.rs b/AgentCompany-Rust/crates/app_ui/src/app_root.rs index fb099448..a9ee9dce 100644 --- a/AgentCompany-Rust/crates/app_ui/src/app_root.rs +++ b/AgentCompany-Rust/crates/app_ui/src/app_root.rs @@ -136,6 +136,62 @@ impl AppRoot { } } + fn update_theme(&mut self, theme: &'static str, cx: &mut Context) { + let result = { + let mut store = self.store.lock(); + store.update_theme(theme) + }; + match result { + Ok(_) => self.reload(format!("Theme updated to {theme}."), cx), + Err(error) => self.set_status(error.to_string(), cx), + } + } + + fn update_locale(&mut self, locale: &'static str, cx: &mut Context) { + let result = { + let mut store = self.store.lock(); + store.update_locale(locale) + }; + match result { + Ok(_) => self.reload(format!("Locale updated to {locale}."), cx), + Err(error) => self.set_status(error.to_string(), cx), + } + } + + fn update_execution_mode(&mut self, execution_mode: &'static str, cx: &mut Context) { + let Some(company_id) = self.current_company_id().map(str::to_string) else { + self.set_status("Create a company first.", cx); + return; + }; + let result = { + let mut store = self.store.lock(); + store.update_execution_mode(&company_id, execution_mode) + }; + match result { + Ok(_) => self.reload(format!("Execution mode updated to {execution_mode}."), cx), + Err(error) => self.set_status(error.to_string(), cx), + } + } + + fn set_primary_selected_workspace(&mut self, cx: &mut Context) { + let Some(company_id) = self.current_company_id().map(str::to_string) else { + self.set_status("Create a company first.", cx); + return; + }; + let Some(workspace_id) = self.state.selected_workspace_id.clone() else { + self.set_status("Choose a workspace first.", cx); + return; + }; + let result = { + let mut store = self.store.lock(); + store.set_primary_workspace(&company_id, &workspace_id) + }; + match result { + Ok(_) => self.reload("Primary workspace updated.", cx), + Err(error) => self.set_status(error.to_string(), cx), + } + } + fn decide_selected_approval( &mut self, state: ApprovalState, @@ -314,7 +370,9 @@ impl Render for AppRoot { SectionId::Workspaces => { self.render_workspaces(cx).into_any_element() } - SectionId::Settings => self.render_settings().into_any_element(), + SectionId::Settings => { + self.render_settings(cx).into_any_element() + } }), ), ) diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root/settings.rs b/AgentCompany-Rust/crates/app_ui/src/app_root/settings.rs index 9af9aa93..9305aedc 100644 --- a/AgentCompany-Rust/crates/app_ui/src/app_root/settings.rs +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/settings.rs @@ -1,7 +1,7 @@ use super::*; impl AppRoot { - pub(super) fn render_settings(&self) -> impl IntoElement + '_ { + pub(super) fn render_settings(&self, cx: &mut Context) -> impl IntoElement + '_ { let current = self.snapshot.current_company(); let execution_mode = current .map(|item| item.execution_mode.clone()) @@ -30,30 +30,97 @@ impl AppRoot { div() .flex_1() .gap_6() - .child(settings_section( + .child(settings_panel( "General", vec![ - settings_row("Profile path", self.snapshot.profile_path.clone()), - settings_row("Theme", self.snapshot.theme.clone()), - settings_row("Locale", self.snapshot.locale.clone()), + settings_action_row( + "Theme", + self.snapshot.theme.clone(), + vec![ + settings_action_button( + "System", + self.snapshot.theme == "system", + cx, + |this, cx| this.update_theme("system", cx), + ) + .into_any_element(), + settings_action_button( + "Light", + self.snapshot.theme == "light", + cx, + |this, cx| this.update_theme("light", cx), + ) + .into_any_element(), + settings_action_button( + "Dark", + self.snapshot.theme == "dark", + cx, + |this, cx| this.update_theme("dark", cx), + ) + .into_any_element(), + ], + ), + settings_action_row( + "Locale", + self.snapshot.locale.clone(), + vec![ + settings_action_button( + "EN", + self.snapshot.locale == "en", + cx, + |this, cx| this.update_locale("en", cx), + ) + .into_any_element(), + settings_action_button( + "ZH", + self.snapshot.locale == "zh", + cx, + |this, cx| this.update_locale("zh", cx), + ) + .into_any_element(), + ], + ), + settings_readonly_row( + "Profile path", + self.snapshot.profile_path.clone(), + ), ], )) - .child(settings_section( + .child(settings_panel( "Execution mode", - vec![settings_row("Company mode", execution_mode)], + vec![settings_action_row( + "Company mode", + execution_mode.clone(), + vec![ + settings_action_button( + "AI CEO", + execution_mode == "ai_ceo", + cx, + |this, cx| this.update_execution_mode("ai_ceo", cx), + ) + .into_any_element(), + settings_action_button( + "AI Co-Founder", + execution_mode == "ai_cofounder", + cx, + |this, cx| this.update_execution_mode("ai_cofounder", cx), + ) + .into_any_element(), + ], + )], )) - .child(settings_section( + .child(settings_panel( "Providers", - vec![settings_row( + vec![settings_readonly_row( "Configured connectors", self.snapshot.connectors.len().to_string(), )], )) - .child(settings_section( + .child(settings_panel( "Data", vec![ - settings_row("Stored profile", "Local SQLite".to_string()), - settings_row("Current company", current_company_name(current)), + settings_readonly_row("Stored profile", "Local SQLite".to_string()), + settings_readonly_row("Current company", current_company_name(current)), ], )), ) @@ -78,7 +145,7 @@ fn settings_nav_row(label: &'static str, active: bool) -> impl IntoElement { .child(label) } -fn settings_section(label: &'static str, rows: Vec) -> impl IntoElement { +fn settings_panel(label: &'static str, rows: Vec) -> impl IntoElement { div() .p_5() .rounded_lg() @@ -94,7 +161,7 @@ fn settings_section(label: &'static str, rows: Vec) -> impl IntoEleme .child(div().mt(px(16.0)).children(rows)) } -fn settings_row(label: &'static str, value: String) -> gpui::Div { +fn settings_readonly_row(label: &'static str, value: String) -> gpui::AnyElement { div() .flex() .justify_between() @@ -109,6 +176,65 @@ fn settings_row(label: &'static str, value: String) -> gpui::Div { .text_color(theme::text_primary()) .child(value), ) + .into_any_element() +} + +fn settings_action_row( + label: &'static str, + value: String, + actions: Vec, +) -> gpui::AnyElement { + div() + .py_3() + .border_b_1() + .border_color(theme::panel_border()) + .child( + div() + .flex() + .justify_between() + .items_center() + .child(div().text_sm().text_color(theme::text_muted()).child(label)) + .child( + div() + .text_sm() + .text_color(theme::text_primary()) + .child(value), + ), + ) + .child(div().mt(px(10.0)).flex().gap_2().children(actions)) + .into_any_element() +} + +fn settings_action_button( + label: &'static 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!("settings-action-{label}"))) + .px_3() + .py_2() + .rounded_lg() + .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(theme::panel_hover())) + .on_click( + cx.listener(move |this, _: &gpui::ClickEvent, _: &mut Window, cx| on_click(this, cx)), + ) + .child(label) } fn current_company_name(company: Option<&domain::Company>) -> String { diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root/workspaces.rs b/AgentCompany-Rust/crates/app_ui/src/app_root/workspaces.rs index fcb78208..b55cd892 100644 --- a/AgentCompany-Rust/crates/app_ui/src/app_root/workspaces.rs +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/workspaces.rs @@ -2,6 +2,17 @@ use super::*; impl AppRoot { pub(super) fn render_workspaces(&self, cx: &mut Context) -> impl IntoElement + '_ { + let selected = self + .state + .selected_workspace_id + .as_ref() + .and_then(|workspace_id| { + self.snapshot + .workspaces + .iter() + .find(|workspace| &workspace.id == workspace_id) + }); + div() .flex() .flex_col() @@ -26,7 +37,16 @@ impl AppRoot { .text_color(theme::text_muted()) .child(format!("{} linked workspaces", self.snapshot.workspaces.len())), ), - ), + ) + .when_some(selected, |container, workspace| { + if workspace.is_primary { + container + } else { + container.child(primary_button("Set primary", cx, |this, cx| { + this.set_primary_selected_workspace(cx) + })) + } + }), ) .child(div().h(px(1.0)).bg(theme::panel_border())) .when(self.snapshot.workspaces.is_empty(), |container| { @@ -62,64 +82,63 @@ impl AppRoot { }) .when(!self.snapshot.workspaces.is_empty(), |container| { container.child( - div() - .children(self.snapshot.workspaces.iter().map(|workspace| { - let active = self.state.selected_workspace_id.as_deref() - == Some(workspace.id.as_str()); - let workspace_id = workspace.id.clone(); - div() - .id(SharedString::from(format!("workspace-{}", workspace.id))) - .flex() - .justify_between() - .items_center() - .px_6() - .py_5() - .border_b_1() - .border_color(theme::panel_border()) - .cursor_pointer() - .bg(if active { - theme::selection_fill() - } else { - theme::window_bg() - }) - .hover(|style| style.bg(theme::panel_hover())) - .on_click(cx.listener(move |this, _, _, cx| { - this.state.selected_workspace_id = Some(workspace_id.clone()); - cx.notify(); - })) - .child( - div() - .flex_1() - .child( - div() - .text_base() - .text_color(theme::text_primary()) - .child(workspace.name.clone()), - ) - .child( - div() - .mt(px(8.0)) - .text_sm() - .text_color(theme::text_muted()) - .child(workspace.local_path.clone()), - ) - .child( - div() - .mt(px(8.0)) - .text_xs() - .text_color(theme::text_subtle()) - .child(format!( - "{} · {}", - workspace.state, - workspace_kind(workspace.is_primary) - )), - ), - ) - .child(workspace_state_badge( - workspace.state.as_str(), - workspace.is_primary, - )) - })), + div().children(self.snapshot.workspaces.iter().map(|workspace| { + let active = + self.state.selected_workspace_id.as_deref() == Some(workspace.id.as_str()); + let workspace_id = workspace.id.clone(); + div() + .id(SharedString::from(format!("workspace-{}", workspace.id))) + .flex() + .justify_between() + .items_center() + .px_6() + .py_5() + .border_b_1() + .border_color(theme::panel_border()) + .cursor_pointer() + .bg(if active { + theme::selection_fill() + } else { + theme::window_bg() + }) + .hover(|style| style.bg(theme::panel_hover())) + .on_click(cx.listener(move |this, _, _, cx| { + this.state.selected_workspace_id = Some(workspace_id.clone()); + cx.notify(); + })) + .child( + div() + .flex_1() + .child( + div() + .text_base() + .text_color(theme::text_primary()) + .child(workspace.name.clone()), + ) + .child( + div() + .mt(px(8.0)) + .text_sm() + .text_color(theme::text_muted()) + .child(workspace.local_path.clone()), + ) + .child( + div() + .mt(px(8.0)) + .text_xs() + .text_color(theme::text_subtle()) + .child(format!( + "{} · {}", + workspace.state, + workspace_kind(workspace.is_primary) + )), + ), + ) + .child(workspace_state_badge( + workspace.state.as_str(), + workspace.is_primary, + )) + })), ) }) } diff --git a/AgentCompany-Rust/crates/storage/src/store.rs b/AgentCompany-Rust/crates/storage/src/store.rs index d4fa8551..133802d8 100644 --- a/AgentCompany-Rust/crates/storage/src/store.rs +++ b/AgentCompany-Rust/crates/storage/src/store.rs @@ -154,6 +154,85 @@ impl ProfileStore { Ok(()) } + pub fn update_theme(&mut self, theme: &str) -> Result<()> { + let normalized = match theme { + "system" | "light" | "dark" => theme, + _ => return Err(anyhow!("unsupported theme value: {theme}")), + }; + self.conn.execute( + "insert into settings (key, value) values ('theme', ?1) on conflict(key) do update set value = excluded.value", + params![normalized], + )?; + Ok(()) + } + + pub fn update_locale(&mut self, locale: &str) -> Result<()> { + let normalized = match locale { + "en" | "zh" => locale, + _ => return Err(anyhow!("unsupported locale value: {locale}")), + }; + self.conn.execute( + "insert into settings (key, value) values ('locale', ?1) on conflict(key) do update set value = excluded.value", + params![normalized], + )?; + Ok(()) + } + + pub fn update_execution_mode(&mut self, company_id: &str, execution_mode: &str) -> Result<()> { + let normalized = normalized_execution_mode(execution_mode); + let ts = now_iso()?; + self.conn.execute( + "update companies set execution_mode = ?1, auto_approve_hires = ?2, review_deliverables = ?3, updated_at = ?4 where id = ?5", + params![ + normalized, + if normalized == "ai_ceo" { 1 } else { 0 }, + if normalized == "ai_cofounder" { 1 } else { 0 }, + ts, + company_id + ], + )?; + self.add_activity( + company_id, + BOARD_READER_ID, + "updated_execution_mode", + "companies", + company_id, + normalized, + )?; + Ok(()) + } + + pub fn set_primary_workspace(&mut self, company_id: &str, workspace_id: &str) -> Result<()> { + let ts = now_iso()?; + let tx = self.conn.transaction()?; + let belongs = tx.query_row( + "select count(1) from workspaces where id = ?1 and company_id = ?2", + params![workspace_id, company_id], + |row| row.get::<_, i64>(0), + )?; + if belongs == 0 { + return Err(anyhow!("workspace {workspace_id} was not found")); + } + tx.execute( + "update workspaces set is_primary = 0, updated_at = ?1 where company_id = ?2", + params![ts, company_id], + )?; + tx.execute( + "update workspaces set is_primary = 1, updated_at = ?1 where id = ?2 and company_id = ?3", + params![ts, workspace_id, company_id], + )?; + tx.commit()?; + self.add_activity( + company_id, + BOARD_READER_ID, + "updated_primary_workspace", + "workspaces", + workspace_id, + "primary workspace changed", + )?; + Ok(()) + } + pub fn decide_approval( &mut self, company_id: &str, diff --git a/AgentCompany-Rust/crates/storage/tests/native_settings_workspace_e2e.rs b/AgentCompany-Rust/crates/storage/tests/native_settings_workspace_e2e.rs new file mode 100644 index 00000000..68891009 --- /dev/null +++ b/AgentCompany-Rust/crates/storage/tests/native_settings_workspace_e2e.rs @@ -0,0 +1,62 @@ +use storage::{NewCompanyInput, ProfileStore}; +use tempfile::tempdir; + +#[test] +fn settings_and_workspace_mutations_round_trip_through_snapshot() { + let sandbox = tempdir().expect("temp dir"); + let mut store = ProfileStore::open(sandbox.path()).expect("open store"); + + let company_id = store + .create_company(NewCompanyInput { + name: "Orbit Settings".to_string(), + description: "Native control plane".to_string(), + workspace_path: "/tmp/orbit-settings".to_string(), + execution_mode: "ai_ceo".to_string(), + }) + .expect("create company"); + + let ts = support::time::now_iso().expect("timestamp"); + let secondary_workspace_id = uuid::Uuid::new_v4().to_string(); + let conn = rusqlite::Connection::open(store.db_path()).expect("open raw db"); + conn.execute( + "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, 'Secondary Workspace', '/tmp/orbit-settings/secondary', '', '', null, 'blocked', 0, ?3, ?3)", + rusqlite::params![secondary_workspace_id, company_id, ts], + ) + .expect("insert secondary workspace"); + + store.update_theme("dark").expect("update theme"); + store.update_locale("zh").expect("update locale"); + store + .update_execution_mode(&company_id, "ai_cofounder") + .expect("update execution mode"); + store + .set_primary_workspace(&company_id, &secondary_workspace_id) + .expect("set primary workspace"); + + let snapshot = store.snapshot().expect("snapshot"); + assert_eq!(snapshot.theme, "dark"); + assert_eq!(snapshot.locale, "zh"); + let company = snapshot + .companies + .iter() + .find(|item| item.id == company_id) + .expect("company present"); + assert_eq!(company.execution_mode, "ai_cofounder"); + let primary_workspace = snapshot + .workspaces + .iter() + .find(|workspace| workspace.is_primary) + .expect("primary workspace present"); + assert_eq!(primary_workspace.id, secondary_workspace_id); + + let reopened = ProfileStore::open(sandbox.path()).expect("reopen store"); + let reopened_snapshot = reopened.snapshot().expect("reopened snapshot"); + assert_eq!(reopened_snapshot.theme, "dark"); + assert_eq!(reopened_snapshot.locale, "zh"); + assert!( + reopened_snapshot + .workspaces + .iter() + .any(|workspace| workspace.id == secondary_workspace_id && workspace.is_primary) + ); +} diff --git a/AgentCompany-Rust/prd.json b/AgentCompany-Rust/prd.json new file mode 100644 index 00000000..8519e678 --- /dev/null +++ b/AgentCompany-Rust/prd.json @@ -0,0 +1,133 @@ +{ + "project": "AgentCompany-Rust", + "branchName": "ralph/agentcompany-rust-native-refactor", + "description": "Complete the Electron-to-native Rust+GPUI rewrite with UI/UX parity, runtime contract completion, and native verification.", + "userStories": [ + { + "id": "US-001", + "title": "Rebuild native shell, onboarding, dashboard, sidebar, and messages surfaces", + "description": "As an operator, I want the core shell surfaces to match the original product grammar so that the native app feels familiar and production-ready.", + "acceptanceCriteria": [ + "Native shell, sidebar, onboarding, dashboard, and messages surfaces are implemented in GPUI", + "UI hierarchy follows the original Electron product surfaces", + "Typecheck passes" + ], + "priority": 1, + "passes": true, + "notes": "Completed in commits 301934a and earlier parity work." + }, + { + "id": "US-002", + "title": "Rebuild approvals and documents native surfaces", + "description": "As an operator, I want approvals and documents to match the original information architecture so that review and document workflows stay intuitive.", + "acceptanceCriteria": [ + "Approvals surface matches the original sectioned layout", + "Documents surface matches the original document center grammar", + "Typecheck passes", + "Verify in native app" + ], + "priority": 2, + "passes": true, + "notes": "Completed in commit 0f30772." + }, + { + "id": "US-003", + "title": "Rebuild runs and inbox native surfaces", + "description": "As an operator, I want run inspection and attention queues to match the original app so that active work remains legible at a glance.", + "acceptanceCriteria": [ + "Runs queue and detail layout are implemented in GPUI", + "Inbox attention groups are implemented in GPUI", + "Typecheck passes", + "Verify in native app" + ], + "priority": 3, + "passes": true, + "notes": "Completed in commit 7fe5d6f." + }, + { + "id": "US-004", + "title": "Rebuild settings and workspaces native surfaces", + "description": "As an operator, I want settings and workspace management to match the original desktop structure so that control-plane tasks remain consistent.", + "acceptanceCriteria": [ + "Settings shell is represented as a sectioned native surface", + "Workspaces are represented as a native list surface", + "Typecheck passes", + "Verify in native app" + ], + "priority": 4, + "passes": true, + "notes": "Completed in commit 5cb212c." + }, + { + "id": "US-005", + "title": "Extend native persistence verification for aligned surfaces", + "description": "As a maintainer, I want native storage-backed verification so that rebuilt surfaces rest on stable persisted state.", + "acceptanceCriteria": [ + "Storage-backed end-to-end test covers reviews, approvals, runs, and documents", + "cargo test --workspace passes", + "Typecheck passes" + ], + "priority": 5, + "passes": true, + "notes": "Completed with native_surface_e2e.rs in commit 0f30772." + }, + { + "id": "US-006", + "title": "Implement native settings mutations and workspace interactions", + "description": "As an operator, I want settings and workspace surfaces to perform real actions so that the native app controls production state end-to-end.", + "acceptanceCriteria": [ + "Settings theme/locale/execution mode/provider/data actions are wired to real Rust-side state transitions or bridge actions", + "Workspace selection and primary workspace interactions are backed by real state changes", + "No fallback behavior is introduced", + "Typecheck passes", + "Verify in native app" + ], + "priority": 6, + "passes": true, + "notes": "Completed with real settings/workspace mutations and snapshot-backed verification." + }, + { + "id": "US-007", + "title": "Add native animation and interaction-state parity", + "description": "As an operator, I want the native app to preserve the original motion, hover, selection, and attention behavior so that the product feels identical in use.", + "acceptanceCriteria": [ + "Core surfaces implement matching hover, selection, and emphasis states", + "Onboarding, inbox, messages, and dashboard include native motion/state treatment aligned with Electron references", + "No placeholder animation remains", + "Typecheck passes", + "Verify in native app" + ], + "priority": 7, + "passes": false, + "notes": "High-value remaining parity layer." + }, + { + "id": "US-008", + "title": "Complete runtime contract integration into native app flows", + "description": "As an operator, I want Pi Mono run, steer, follow-up, approval, and artifact events to drive the native UI so that the app is a complete replacement for Electron.", + "acceptanceCriteria": [ + "Run lifecycle events are surfaced in native UI from real runtime data", + "Approval gates participate in real runtime flows", + "Message and artifact updates reflect live runtime contract events", + "Typecheck passes", + "cargo test --workspace passes" + ], + "priority": 8, + "passes": false, + "notes": "Core completion story for full replacement." + }, + { + "id": "US-009", + "title": "Add final native end-to-end verification matrix", + "description": "As a maintainer, I want a broader native verification matrix so that the rewritten app can be trusted as the production desktop path.", + "acceptanceCriteria": [ + "Native tests cover onboarding bootstrap, run inspection, approval visibility, document visibility, settings/workspace state, and inbox attention derivation", + "cargo clippy --workspace --all-targets --all-features -- -D warnings passes", + "cargo test --workspace passes" + ], + "priority": 9, + "passes": false, + "notes": "Final confidence layer before calling the rewrite complete." + } + ] +} diff --git a/AgentCompany-Rust/progress.txt b/AgentCompany-Rust/progress.txt new file mode 100644 index 00000000..4a0b9e0e --- /dev/null +++ b/AgentCompany-Rust/progress.txt @@ -0,0 +1,11 @@ +# Ralph Progress Log +Started: 2026-04-19 +--- +- Established native GPUI shell parity direction by reading PRD.md, Electron source surfaces, and visual screenshots before editing. +- Completed native shell, onboarding, overview, sidebar, and messages parity work. +- Completed approvals, documents, runs, inbox, settings, and workspaces parity passes. +- Added native storage-backed verification covering reviews, approvals, runs, and documents. +- Remaining work is concentrated in interaction depth, animation/state parity, and full runtime contract completion. +- Standing constraint: all code is written directly in the current context; no subagents or agent teams are used. +- Completed real settings/workspace state mutations in native Rust path: theme, locale, execution mode, and primary workspace. +- Added native_settings_workspace_e2e.rs to verify mutation persistence across reopen.