From b3d5bcdde56e3fe3b001e187bf7dfadcc4f32691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Apr 2026 13:46:18 -0400 Subject: [PATCH] Align approvals and documents with native shell --- .../crates/app_ui/src/app_root/approvals.rs | 300 +++++++++++++----- .../crates/app_ui/src/app_root/documents.rs | 171 ++++++---- .../storage/tests/native_surface_e2e.rs | 82 +++++ 3 files changed, 419 insertions(+), 134 deletions(-) create mode 100644 AgentCompany-Rust/crates/storage/tests/native_surface_e2e.rs diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root/approvals.rs b/AgentCompany-Rust/crates/app_ui/src/app_root/approvals.rs index dfeb80a3..a9f411b2 100644 --- a/AgentCompany-Rust/crates/app_ui/src/app_root/approvals.rs +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/approvals.rs @@ -2,100 +2,238 @@ use super::*; impl AppRoot { pub(super) fn render_approvals(&self, cx: &mut Context) -> impl IntoElement + '_ { - let selected = self + let selected_pending_id = self .state .selected_approval_id - .as_ref() - .and_then(|id| self.snapshot.approvals.iter().find(|item| &item.id == id)); - let approval_type = selected - .map(|item| item.approval_type.clone()) - .unwrap_or_else(|| "Select an approval".to_string()); - let approval_summary = selected - .map(|item| item.impact_summary.clone()) - .unwrap_or_else(|| "No approval selected.".to_string()); + .clone() + .filter(|approval_id| { + pending_approvals(&self.snapshot).any(|item| &item.id == approval_id) + }); + let selected_pending = selected_pending_id.as_ref().and_then(|approval_id| { + self.snapshot + .approvals + .iter() + .find(|item| &item.id == approval_id) + }); + div() .flex() - .gap_4() + .flex_col() + .gap_10() .child( - div() - .id("approvals-list") - .w(px(340.0)) - .p_4() - .gap_2() - .border_1() - .rounded_md() - .border_color(theme::panel_border()) - .bg(theme::panel()) - .overflow_scroll() - .child(div().text_lg().child("Approval queue")) - .children(self.snapshot.approvals.iter().map(|item| { - let active = - self.state.selected_approval_id.as_deref() == Some(item.id.as_str()); - let approval_id = item.id.clone(); - div() - .id(SharedString::from(format!("approval-{}", item.id))) - .px_3() - .py_2() - .rounded_md() - .cursor_pointer() - .bg(if active { - theme::selection_fill() - } else { - theme::panel() - }) - .hover(|style| style.bg(theme::panel_hover())) - .on_click(cx.listener(move |this, _, _, cx| { - this.state.selected_approval_id = Some(approval_id.clone()); - cx.notify(); - })) - .child(format!("{} · {}", item.state, item.payload_summary)) - })), + approvals_section( + "Agent reviews in progress", + self.snapshot.tasks.iter().filter(|task| task.status == "in_review"), + ), ) .child( div() - .flex_1() - .p_4() - .gap_3() - .border_1() - .rounded_md() - .border_color(theme::panel_border()) - .bg(theme::panel()) - .child(div().text_lg().child("Decision")) + .gap_5() .child( div() - .text_sm() - .text_color(theme::text_muted()) - .child(approval_type), + .text_2xl() + .text_color(theme::text_primary()) + .child("Pending approvals"), ) - .child( - div() - .min_h(px(160.0)) - .p_3() - .border_1() - .border_color(theme::panel_inset_border()) - .rounded_md() - .bg(theme::panel_inset()) - .child(approval_summary), - ) - .child( - div() - .flex() - .gap_2() - .child(primary_button("Approve", cx, |this, cx| { - this.decide_selected_approval( - ApprovalState::Approved, - "Approved from native shell.", - cx, + .child(div().h(px(1.0)).bg(theme::panel_border())) + .when(self.snapshot.pending_approvals() == 0, |container| { + container.child( + div() + .min_h(px(280.0)) + .flex() + .items_center() + .justify_center() + .child( + div() + .w(px(520.0)) + .text_center() + .child( + div() + .text_xl() + .text_color(theme::text_primary()) + .child("No pending approvals"), + ) + .child( + div() + .mt(px(10.0)) + .text_base() + .text_color(theme::text_muted()) + .child( + "Approval requests from agents will appear here when they need your sign-off.", + ), + ), + ), + ) + }) + .when(self.snapshot.pending_approvals() > 0, |container| { + container.child( + div() + .id("approvals-pending-list") + .border_1() + .rounded_lg() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .children(pending_approvals(&self.snapshot).map(|item| { + let active = + selected_pending_id.as_deref() == Some(item.id.as_str()); + let approval_id = item.id.clone(); + div() + .id(SharedString::from(format!("approval-{}", item.id))) + .px_5() + .py_4() + .border_b_1() + .border_color(theme::panel_border()) + .cursor_pointer() + .bg(if active { + theme::selection_fill() + } else { + theme::panel() + }) + .hover(|style| style.bg(theme::panel_hover())) + .on_click(cx.listener(move |this, _, _, cx| { + this.state.selected_approval_id = Some(approval_id.clone()); + cx.notify(); + })) + .child( + div() + .text_base() + .text_color(theme::text_primary()) + .child(item.payload_summary.clone()), + ) + .child( + div() + .mt(px(6.0)) + .text_sm() + .text_color(theme::text_muted()) + .child(format!( + "{} · {}", + item.approval_type.replace('_', " "), + item.state + )), + ) + })), + ) + }) + .when_some(selected_pending, |container, approval| { + container.child( + div() + .mt(px(8.0)) + .p_5() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .child( + div() + .text_xs() + .text_color(theme::text_muted()) + .child(approval.approval_type.replace('_', " ")), ) - })) - .child(secondary_button("Reject", cx, |this, cx| { - this.decide_selected_approval( - ApprovalState::Rejected, - "Rejected from native shell.", - cx, + .child( + div() + .mt(px(10.0)) + .text_lg() + .text_color(theme::text_primary()) + .child(approval.payload_summary.clone()), ) - })), - ), + .child( + div() + .mt(px(10.0)) + .text_base() + .text_color(theme::text_secondary()) + .child(approval.impact_summary.clone()), + ) + .child( + div() + .mt(px(18.0)) + .flex() + .gap_3() + .child(primary_button("Approve", cx, |this, cx| { + this.decide_selected_approval( + ApprovalState::Approved, + "Approved from native shell.", + cx, + ) + })) + .child(secondary_button("Reject", cx, |this, cx| { + this.decide_selected_approval( + ApprovalState::Rejected, + "Rejected from native shell.", + cx, + ) + })), + ), + ) + }), ) } } + +fn approvals_section<'a>( + title: &'static str, + tasks: impl Iterator, +) -> impl IntoElement { + let rows = tasks.collect::>(); + div() + .gap_5() + .child( + div() + .text_2xl() + .text_color(theme::text_primary()) + .child(title), + ) + .child(div().h(px(1.0)).bg(theme::panel_border())) + .when(rows.is_empty(), |container| { + container.child( + div() + .py_8() + .text_base() + .text_color(theme::text_muted()) + .child("No agent reviews are currently waiting on a human."), + ) + }) + .when(!rows.is_empty(), |container| { + container.child( + div() + .border_1() + .rounded_lg() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .children(rows.into_iter().map(|task| { + div() + .px_5() + .py_4() + .border_b_1() + .border_color(theme::panel_border()) + .child( + div() + .text_base() + .text_color(theme::text_primary()) + .truncate() + .child(task.title.clone()), + ) + .child( + div() + .mt(px(6.0)) + .text_sm() + .text_color(theme::text_muted()) + .child(format!( + "{} · awaiting review", + task.assignee_agent_id + .clone() + .unwrap_or_else(|| "CEO".to_string()) + )), + ) + })), + ) + }) +} + +fn pending_approvals( + snapshot: &domain::ProfileSnapshot, +) -> impl Iterator { + snapshot + .approvals + .iter() + .filter(|item| item.state == "pending" || item.state == "revision_requested") +} diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root/documents.rs b/AgentCompany-Rust/crates/app_ui/src/app_root/documents.rs index 333aeda9..2a3b9fdd 100644 --- a/AgentCompany-Rust/crates/app_ui/src/app_root/documents.rs +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/documents.rs @@ -7,82 +7,147 @@ impl AppRoot { .selected_document_id .as_ref() .and_then(|id| self.snapshot.documents.iter().find(|item| &item.id == id)); - let document_title = selected - .map(|item| item.title.clone()) - .unwrap_or_else(|| "Select a document".to_string()); - let document_content = selected - .map(|item| item.content.clone()) - .unwrap_or_else(|| "No document selected.".to_string()); + div() .flex() - .gap_4() + .flex_col() + .gap_6() .child( div() - .id("documents-list") - .w(px(340.0)) - .p_4() - .gap_2() - .border_1() - .rounded_md() - .border_color(theme::panel_border()) - .bg(theme::panel()) - .overflow_scroll() - .child(div().text_lg().child("Document center")) - .children(self.snapshot.documents.iter().map(|item| { + .flex() + .justify_between() + .items_start() + .child( + div() + .child( + div() + .text_2xl() + .text_color(theme::text_primary()) + .child("Document Center"), + ) + .child( + div() + .mt(px(6.0)) + .text_sm() + .text_color(theme::text_muted()) + .child(format!("{} items", self.snapshot.documents.len())), + ), + ) + .child(primary_button("Save draft", cx, |this, cx| { + this.create_document(cx) + })), + ) + .child(div().h(px(1.0)).bg(theme::panel_border())) + .when(self.snapshot.documents.is_empty(), |container| { + container.child( + div() + .py_8() + .text_base() + .text_color(theme::text_muted()) + .child("No documents have been created yet."), + ) + }) + .when(!self.snapshot.documents.is_empty(), |container| { + container.child(div().id("documents-list").children( + self.snapshot.documents.iter().map(|item| { let active = self.state.selected_document_id.as_deref() == Some(item.id.as_str()); let document_id = item.id.clone(); div() .id(SharedString::from(format!("document-{}", item.id))) - .px_3() - .py_2() - .rounded_md() + .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::panel() + theme::window_bg() }) .hover(|style| style.bg(theme::panel_hover())) .on_click(cx.listener(move |this, _, _, cx| { this.state.selected_document_id = Some(document_id.clone()); cx.notify(); })) - .child(format!("{} · {}", item.status, item.title)) - })), - ) + .child( + div() + .flex_1() + .child( + div() + .text_base() + .text_color(theme::text_primary()) + .truncate() + .child(item.title.clone()), + ) + .child( + div() + .mt(px(8.0)) + .text_sm() + .text_color(theme::text_muted()) + .child(format!( + "{} · v{} · {}", + item.document_type.replace('_', " "), + item.version, + short_date(item.updated_at.as_str()) + )), + ), + ) + .child(document_status(item.status.as_str())) + }), + )) + }) + .child(div().h(px(1.0)).bg(theme::panel_border())) .child( div() - .flex_1() - .p_4() - .gap_3() - .border_1() - .rounded_md() - .border_color(theme::panel_border()) - .bg(theme::panel()) - .child(div().text_lg().child("Quick draft")) + .gap_4() + .child( + div().text_lg().text_color(theme::text_primary()).child( + selected + .map(|item| item.title.clone()) + .unwrap_or_else(|| "Quick draft".to_string()), + ), + ) .child(self.document_title_input.clone()) .child(self.document_body_input.clone()) - .child(primary_button("Save draft", cx, |this, cx| { - this.create_document(cx) - })) - .child(div().h(px(1.0)).bg(theme::panel_inset_border())) - .child( - div() - .text_sm() - .text_color(theme::text_muted()) - .child(document_title), - ) - .child( - div() - .min_h(px(200.0)) - .p_3() - .border_1() - .border_color(theme::panel_inset_border()) - .rounded_md() - .bg(theme::panel_inset()) - .child(document_content), - ), + .when_some(selected, |container, item| { + container.child( + div() + .p_4() + .rounded_lg() + .border_1() + .border_color(theme::panel_inset_border()) + .bg(theme::panel_inset()) + .child(item.content.clone()), + ) + }), ) } } + +fn document_status(status: &str) -> impl IntoElement { + div() + .flex() + .items_center() + .gap_2() + .child( + div() + .w(px(8.0)) + .h(px(8.0)) + .rounded_full() + .bg(theme::badge_dark()), + ) + .child( + div() + .text_xs() + .text_color(theme::text_secondary()) + .child(status.to_uppercase()), + ) +} + +fn short_date(value: &str) -> &str { + value.get(..10).unwrap_or(value) +} diff --git a/AgentCompany-Rust/crates/storage/tests/native_surface_e2e.rs b/AgentCompany-Rust/crates/storage/tests/native_surface_e2e.rs new file mode 100644 index 00000000..c418ee66 --- /dev/null +++ b/AgentCompany-Rust/crates/storage/tests/native_surface_e2e.rs @@ -0,0 +1,82 @@ +use storage::{NewCompanyInput, ProfileStore}; +use tempfile::tempdir; + +#[test] +fn native_surface_snapshot_round_trips_reviews_runs_and_documents() { + 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 Ops".to_string(), + description: "Local service operator".to_string(), + workspace_path: "/tmp/orbit-ops".to_string(), + execution_mode: "ai_ceo".to_string(), + }) + .expect("create company"); + + let ts = support::time::now_iso().expect("timestamp"); + let task_id = uuid::Uuid::new_v4().to_string(); + let approval_id = uuid::Uuid::new_v4().to_string(); + let run_id = uuid::Uuid::new_v4().to_string(); + + let conn = rusqlite::Connection::open(store.db_path()).expect("open raw db"); + conn.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, null, null, null, 'Operationalize launch plan', 'Awaiting review from board', null, null, 'in_review', 'high', ?3, 0, 'review', 1, ?4, null, null, ?4, ?4)", + rusqlite::params![task_id, company_id, run_id, ts], + ) + .expect("insert review task"); + conn.execute( + "insert into approvals (id, company_id, related_task_id, requested_by_agent_id, related_agent_id, type, payload_summary, impact_summary, payload_json, decision_note, state, created_at, updated_at) values (?1, ?2, ?3, null, null, 'plan_review', 'Review launch plan', 'Board decision blocks execution', null, '', 'pending', ?4, ?4)", + rusqlite::params![approval_id, company_id, task_id, ts], + ) + .expect("insert pending approval"); + conn.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, '__board__', null, 'anthropic', 'running', 'Executing launch checklist', null, 'claude-sonnet', 1.5, ?4, null, ?4, ?4)", + rusqlite::params![run_id, company_id, task_id, ts], + ) + .expect("insert running run"); + + let document_id = store + .create_document( + &company_id, + "Launch brief", + "Board-ready operating brief for the native shell.", + ) + .expect("create document"); + + let snapshot = store.snapshot().expect("snapshot"); + assert_eq!( + snapshot.current_company_id.as_deref(), + Some(company_id.as_str()) + ); + assert_eq!(snapshot.pending_approvals(), 1); + assert_eq!(snapshot.active_runs(), 1); + assert_eq!(snapshot.documents.len(), 1); + assert_eq!(snapshot.documents[0].id, document_id); + assert!(snapshot.tasks.iter().any(|task| task.id == task_id)); + assert!( + snapshot + .approvals + .iter() + .any(|approval| approval.id == approval_id) + ); + assert!(snapshot.runs.iter().any(|run| run.id == run_id)); + + let reopened = ProfileStore::open(sandbox.path()).expect("reopen store"); + let reopened_snapshot = reopened.snapshot().expect("reopened snapshot"); + assert_eq!(reopened_snapshot.pending_approvals(), 1); + assert_eq!(reopened_snapshot.active_runs(), 1); + assert!( + reopened_snapshot + .tasks + .iter() + .any(|task| task.id == task_id) + ); + assert!( + reopened_snapshot + .documents + .iter() + .any(|document| document.id == document_id) + ); +}