diff --git a/AgentCompany-Rust/crates/app_models/src/lib.rs b/AgentCompany-Rust/crates/app_models/src/lib.rs index b5f9ceb0..18429e76 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, OnboardingStep}; +pub use state::{AppState, InboxFilter, OnboardingStep}; diff --git a/AgentCompany-Rust/crates/app_models/src/state.rs b/AgentCompany-Rust/crates/app_models/src/state.rs index 774e27d5..b1533235 100644 --- a/AgentCompany-Rust/crates/app_models/src/state.rs +++ b/AgentCompany-Rust/crates/app_models/src/state.rs @@ -8,6 +8,16 @@ pub enum OnboardingStep { Launch, } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum InboxFilter { + #[default] + All, + Approvals, + Reviews, + Failed, + Messages, +} + #[derive(Clone, Debug, Default)] pub struct AppState { pub section: SectionId, @@ -19,6 +29,7 @@ pub struct AppState { pub onboarding_step: OnboardingStep, pub onboarding_connector_id: Option, pub onboarding_execution_mode: &'static str, + pub inbox_filter: InboxFilter, 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 068c2880..3b1620c1 100644 --- a/AgentCompany-Rust/crates/app_ui/src/app_root.rs +++ b/AgentCompany-Rust/crates/app_ui/src/app_root.rs @@ -408,7 +408,7 @@ impl Render for AppRoot { .child(match self.state.section { SectionId::Overview => self.render_overview(cx).into_any_element(), SectionId::Runs => self.render_runs(cx).into_any_element(), - SectionId::Inbox => self.render_inbox().into_any_element(), + SectionId::Inbox => self.render_inbox(cx).into_any_element(), SectionId::Messages => self.render_messages(cx).into_any_element(), SectionId::Approvals => { self.render_approvals(cx).into_any_element() 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 a9f411b2..0171e8a3 100644 --- a/AgentCompany-Rust/crates/app_ui/src/app_root/approvals.rs +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/approvals.rs @@ -2,231 +2,344 @@ use super::*; impl AppRoot { pub(super) fn render_approvals(&self, cx: &mut Context) -> impl IntoElement + '_ { - let selected_pending_id = self + let groups = review_groups(&self.snapshot); + let selected_pending = self .state .selected_approval_id - .clone() - .filter(|approval_id| { - pending_approvals(&self.snapshot).any(|item| &item.id == approval_id) + .as_ref() + .and_then(|approval_id| { + self.snapshot + .approvals + .iter() + .find(|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() .flex_col() - .gap_10() - .child( - approvals_section( - "Agent reviews in progress", - self.snapshot.tasks.iter().filter(|task| task.status == "in_review"), - ), - ) + .gap_8() + .child(section_title("Agent reviews in progress")) .child( div() - .gap_5() + .grid() + .grid_cols(3) + .gap_3() + .when(!groups.board.is_empty(), |container| { + container.child(review_group_card("Board review", groups.board.as_slice())) + }) + .when(!groups.user.is_empty(), |container| { + container.child(review_group_card("Human review", groups.user.as_slice())) + }) + .when(!groups.manager.is_empty(), |container| { + container.child(review_group_card( + "Manager review", + groups.manager.as_slice(), + )) + }) + .when( + groups.board.is_empty() + && groups.user.is_empty() + && groups.manager.is_empty(), + |container| { + container.child(empty_group_card( + "No agent reviews are currently waiting on a human.", + )) + }, + ), + ) + .child(section_title("Pending approvals")) + .child( + div() + .grid() + .grid_cols(2) + .gap_4() + .child(self.render_pending_approval_list(cx)) + .child(self.render_pending_approval_detail(selected_pending, cx)), + ) + } + + fn render_pending_approval_list(&self, cx: &mut Context) -> impl IntoElement + '_ { + let pending = pending_approvals(&self.snapshot).collect::>(); + div() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .when(pending.is_empty(), |container| { + container.child( + div() + .min_h(px(280.0)) + .flex() + .items_center() + .justify_center() + .child( + div() + .w(px(320.0)) + .text_center() + .text_color(theme::text_muted()) + .child("No pending approvals"), + ), + ) + }) + .when(!pending.is_empty(), |container| { + container.children(pending.into_iter().map(|approval| { + let active = + self.state.selected_approval_id.as_deref() == Some(approval.id.as_str()); + let approval_id = approval.id.clone(); + div() + .id(SharedString::from(format!("approval-{}", approval.id))) + .cursor_pointer() + .hover(|style| style.bg(theme::panel_hover())) + .bg(if active { + theme::selection_fill() + } else { + theme::panel() + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.state.selected_approval_id = Some(approval_id.clone()); + cx.notify(); + })) + .px_5() + .py_4() + .border_b_1() + .border_color(theme::panel_border()) + .child( + div() + .text_base() + .text_color(theme::text_primary()) + .child(approval.payload_summary.clone()), + ) + .child( + div() + .mt(px(4.0)) + .text_sm() + .text_color(theme::text_muted()) + .child(format!( + "{} · {}", + approval.approval_type.replace('_', " "), + approval.state + )), + ) + })) + }) + } + + fn render_pending_approval_detail( + &self, + approval: Option<&domain::Approval>, + cx: &mut Context, + ) -> impl IntoElement + '_ { + div() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .p_5() + .when_some(approval, |container, approval| { + container .child( div() - .text_2xl() - .text_color(theme::text_primary()) - .child("Pending approvals"), + .text_xs() + .text_color(theme::text_muted()) + .child(approval.approval_type.replace('_', " ")), ) - .child(div().h(px(1.0)).bg(theme::panel_border())) - .when(self.snapshot.pending_approvals() == 0, |container| { - container.child( + .child( + div() + .mt(px(10.0)) + .text_lg() + .text_color(theme::text_primary()) + .child(approval.payload_summary.clone()), + ) + .child( + div() + .mt(px(10.0)) + .rounded_lg() + .border_1() + .border_color(theme::panel_inset_border()) + .bg(theme::panel_inset()) + .p_4() + .text_sm() + .line_height(px(22.0)) + .text_color(theme::text_secondary()) + .child(approval.impact_summary.clone()), + ) + .when_some(approval.payload_json.clone(), |inner, payload| { + inner.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() + .mt(px(12.0)) .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( - 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, - ) - })), - ), + .p_4() + .text_xs() + .line_height(px(20.0)) + .text_color(theme::text_muted()) + .child(payload), ) - }), - ) + }) + .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, + ) + })), + ) + }) + .when(approval.is_none(), |container| { + container.child( + div() + .min_h(px(320.0)) + .flex() + .items_center() + .justify_center() + .text_color(theme::text_muted()) + .child("Select an approval to inspect its impact and decision surface."), + ) + }) } } -fn approvals_section<'a>( - title: &'static str, - tasks: impl Iterator, -) -> impl IntoElement { - let rows = tasks.collect::>(); +struct ReviewGroups<'a> { + board: Vec>, + user: Vec>, + manager: Vec>, +} + +struct ReviewRow<'a> { + task: &'a domain::Task, + assignee: Option<&'a domain::Agent>, + reviewer: Option<&'a domain::Agent>, +} + +fn review_groups(snapshot: &domain::ProfileSnapshot) -> ReviewGroups<'_> { + let pending_approval_task_ids = snapshot + .approvals + .iter() + .filter(|approval| approval.state == "pending" || approval.state == "revision_requested") + .filter_map(|approval| approval.related_task_id.as_deref()) + .collect::>(); + + let mut board = Vec::new(); + let mut user = Vec::new(); + let mut manager = Vec::new(); + + for task in snapshot + .tasks + .iter() + .filter(|task| task.status == "in_review") + { + let assignee = task + .assignee_agent_id + .as_ref() + .and_then(|agent_id| snapshot.agents.iter().find(|agent| &agent.id == agent_id)); + let reviewer = assignee + .and_then(|assignee| assignee.reports_to.as_ref()) + .and_then(|manager_id| snapshot.agents.iter().find(|agent| &agent.id == manager_id)); + let row = ReviewRow { + task, + assignee, + reviewer, + }; + if pending_approval_task_ids + .iter() + .any(|task_id| *task_id == task.id) + { + board.push(row); + } else if task.requires_user_review || reviewer.is_none() { + user.push(row); + } else { + manager.push(row); + } + } + + ReviewGroups { + board, + user, + manager, + } +} + +fn review_group_card(title: &'static str, rows: &[ReviewRow<'_>]) -> impl IntoElement { div() - .gap_5() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) .child( div() - .text_2xl() - .text_color(theme::text_primary()) + .px_4() + .pt_4() + .pb_2() + .text_xs() + .text_color(theme::text_muted()) .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()) - )), - ) - })), - ) - }) + .children(rows.iter().map(|row| { + div() + .px_4() + .py_3() + .border_b_1() + .border_color(theme::panel_border()) + .child( + div() + .text_sm() + .text_color(theme::text_primary()) + .child(row.task.title.clone()), + ) + .child( + div() + .mt(px(4.0)) + .text_xs() + .text_color(theme::text_muted()) + .child(review_row_subtitle(row)), + ) + })) +} + +fn review_row_subtitle(row: &ReviewRow<'_>) -> String { + let assignee = row + .assignee + .map(|agent| agent.name.clone()) + .unwrap_or_else(|| "Unassigned".to_string()); + match row.reviewer { + Some(reviewer) => format!("{} → reviewed by {}", assignee, reviewer.name), + None => format!("{} · awaiting human review", assignee), + } +} + +fn empty_group_card(message: &'static str) -> impl IntoElement { + div() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .min_h(px(180.0)) + .flex() + .items_center() + .justify_center() + .text_color(theme::text_muted()) + .child(message) +} + +fn section_title(title: &'static str) -> impl IntoElement { + div() + .text_2xl() + .text_color(theme::text_primary()) + .child(title) } fn pending_approvals( diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root/inbox.rs b/AgentCompany-Rust/crates/app_ui/src/app_root/inbox.rs index 5e3d6a1b..165c1ecd 100644 --- a/AgentCompany-Rust/crates/app_ui/src/app_root/inbox.rs +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/inbox.rs @@ -1,9 +1,10 @@ use super::*; -use gpui::{Animation, AnimationExt, pulsating_between}; +use app_models::InboxFilter; +use gpui::{Animation, AnimationExt, SharedString, pulsating_between}; use std::time::Duration; impl AppRoot { - pub(super) fn render_inbox(&self) -> impl IntoElement + '_ { + pub(super) fn render_inbox(&self, cx: &mut Context) -> impl IntoElement + '_ { let pending_approvals = self .snapshot .approvals @@ -38,6 +39,19 @@ impl AppRoot { + review_tasks.len() + failed_runs.len(); + let rows = inbox_rows( + &self.snapshot, + self.state.inbox_filter, + pending_approvals.as_slice(), + review_tasks.as_slice(), + failed_runs.as_slice(), + unread_messages.as_slice(), + ); + let mut row_elements = Vec::with_capacity(rows.len()); + for (index, row) in rows.into_iter().enumerate() { + row_elements.push(row.render(index, cx).into_any_element()); + } + div() .flex() .flex_col() @@ -65,10 +79,12 @@ impl AppRoot { ), ) .child(inbox_filters( + self.state.inbox_filter, pending_approvals.len(), review_tasks.len(), failed_runs.len(), unread_messages.len(), + cx, )) .when(total_attention == 0, |container| { container.child( @@ -101,64 +117,164 @@ impl AppRoot { ), ) }) - .when(total_attention > 0, |container| { - container - .when(!pending_approvals.is_empty(), |content| { - content.child(inbox_group( - "Pending approvals", - pending_approvals.into_iter().map(|item| { - inbox_row( - item.payload_summary.clone(), - format!("{} · pending", item.approval_type.replace('_', " ")), - theme::accent_orange_soft(), - theme::accent_orange(), - ) - }), - )) - }) - .when(!review_tasks.is_empty(), |content| { - content.child(inbox_group( - "Reviews in progress", - review_tasks.into_iter().map(|item| { - inbox_row( - item.title.clone(), - format!("{} · awaiting review", item.status), - theme::accent_blue_soft(), - theme::accent_blue(), - ) - }), - )) - }) - .when(!failed_runs.is_empty(), |content| { - content.child(inbox_group( - "Failed runs", - failed_runs.into_iter().map(|item| { - inbox_row( - inbox_run_title(&self.snapshot, item), - item.error_message.clone().unwrap_or_else(|| item.status.clone()), - theme::danger_bg(), - theme::danger(), - ) - }), - )) - }) - .when(!unread_messages.is_empty(), |content| { - content.child(inbox_group( - "Unread messages", - unread_messages.into_iter().map(|item| { - inbox_row( - item.subject.clone(), - item.body.clone(), - theme::accent_teal_soft(), - theme::accent_teal(), - ) - }), - )) - }) + .when(!row_elements.is_empty(), |container| { + container.child( + div() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .children(row_elements), + ) }) } } +#[derive(Clone)] +struct InboxRow { + title: String, + subtitle: String, + accent: gpui::Hsla, + tile_bg: gpui::Hsla, + action: InboxAction, +} + +#[derive(Clone)] +enum InboxAction { + Approval(String), + ReviewTask(String), + FailedRun(String), + Message(String), +} + +impl InboxRow { + fn render(self, index: usize, cx: &mut Context) -> impl IntoElement { + let action = self.action.clone(); + let title = self.title.clone(); + let subtitle = self.subtitle.clone(); + let accent = self.accent; + let tile_bg = self.tile_bg; + div() + .id(SharedString::from(format!("inbox-row-{index}"))) + .cursor_pointer() + .hover(|style| style.bg(theme::panel_hover())) + .on_click(cx.listener(move |this, _, _, cx| { + match &action { + InboxAction::Approval(approval_id) => { + this.state.section = SectionId::Approvals; + this.state.selected_approval_id = Some(approval_id.clone()); + } + InboxAction::ReviewTask(task_id) => { + if let Some(run_id) = this + .snapshot + .tasks + .iter() + .find(|task| task.id == *task_id) + .and_then(|task| task.active_run_id.clone()) + { + this.state.section = SectionId::Runs; + this.state.selected_run_id = Some(run_id); + } else { + this.state.section = SectionId::Approvals; + } + } + InboxAction::FailedRun(run_id) => { + this.state.section = SectionId::Runs; + this.state.selected_run_id = Some(run_id.clone()); + } + InboxAction::Message(message_id) => { + this.state.section = SectionId::Messages; + this.state.selected_message_id = Some(message_id.clone()); + } + } + cx.notify(); + })) + .px_5() + .py_4() + .border_b_1() + .border_color(theme::panel_border()) + .child( + div() + .flex() + .gap_3() + .items_center() + .child(div().w(px(36.0)).h(px(36.0)).rounded_lg().bg(tile_bg)) + .child( + div() + .flex_1() + .child( + div() + .text_base() + .text_color(theme::text_primary()) + .child(title), + ) + .child( + div() + .mt(px(4.0)) + .text_sm() + .text_color(theme::text_muted()) + .line_clamp(2) + .child(subtitle), + ), + ) + .child(active_filter_dot(accent, index)), + ) + } +} + +fn inbox_rows( + snapshot: &domain::ProfileSnapshot, + filter: InboxFilter, + pending_approvals: &[&domain::Approval], + review_tasks: &[&domain::Task], + failed_runs: &[&domain::Run], + unread_messages: &[&domain::AgentMessage], +) -> Vec { + let mut rows = Vec::new(); + if matches!(filter, InboxFilter::All | InboxFilter::Approvals) { + rows.extend(pending_approvals.iter().map(|approval| InboxRow { + title: approval.payload_summary.clone(), + subtitle: format!("{} · pending", approval.approval_type.replace('_', " ")), + accent: theme::accent_orange(), + tile_bg: theme::accent_orange_soft(), + action: InboxAction::Approval(approval.id.clone()), + })); + } + if matches!(filter, InboxFilter::All | InboxFilter::Reviews) { + rows.extend(review_tasks.iter().map(|task| InboxRow { + title: task.title.clone(), + subtitle: format!("{} · awaiting review", task.status), + accent: theme::accent_blue(), + tile_bg: theme::accent_blue_soft(), + action: InboxAction::ReviewTask(task.id.clone()), + })); + } + if matches!(filter, InboxFilter::All | InboxFilter::Failed) { + rows.extend(failed_runs.iter().map(|run| { + InboxRow { + title: inbox_run_title(snapshot, run), + subtitle: run + .error_message + .clone() + .unwrap_or_else(|| run.status.clone()), + accent: theme::danger(), + tile_bg: theme::danger_bg(), + action: InboxAction::FailedRun(run.id.clone()), + } + })); + } + if matches!(filter, InboxFilter::All | InboxFilter::Messages) { + rows.extend(unread_messages.iter().map(|message| InboxRow { + title: message.subject.clone(), + subtitle: message.body.clone(), + accent: theme::accent_teal(), + tile_bg: theme::accent_teal_soft(), + action: InboxAction::Message(message.id.clone()), + })); + } + rows +} + fn inbox_run_title(snapshot: &domain::ProfileSnapshot, run: &domain::Run) -> String { run.task_id .as_ref() @@ -174,10 +290,12 @@ fn inbox_run_title(snapshot: &domain::ProfileSnapshot, run: &domain::Run) -> Str } fn inbox_filters( + active: InboxFilter, approvals: usize, reviews: usize, failed_runs: usize, messages: usize, + cx: &mut Context, ) -> impl IntoElement { div() .flex() @@ -185,19 +303,53 @@ fn inbox_filters( .child(filter_chip( "All", approvals + reviews + failed_runs + messages, - true, + active == InboxFilter::All, + InboxFilter::All, + cx, + )) + .child(filter_chip( + "Approvals", + approvals, + active == InboxFilter::Approvals, + InboxFilter::Approvals, + cx, + )) + .child(filter_chip( + "Reviews", + reviews, + active == InboxFilter::Reviews, + InboxFilter::Reviews, + cx, + )) + .child(filter_chip( + "Failed", + failed_runs, + active == InboxFilter::Failed, + InboxFilter::Failed, + cx, + )) + .child(filter_chip( + "Messages", + messages, + active == InboxFilter::Messages, + InboxFilter::Messages, + cx, )) - .child(filter_chip("Approvals", approvals, false)) - .child(filter_chip("Reviews", reviews, false)) - .child(filter_chip("Failed", failed_runs, false)) - .child(filter_chip("Messages", messages, false)) } -fn filter_chip(label: &'static str, count: usize, active: bool) -> impl IntoElement { +fn filter_chip( + label: &'static str, + count: usize, + active: bool, + next: InboxFilter, + cx: &mut Context, +) -> impl IntoElement { let chip = div() + .id(SharedString::from(format!("inbox-filter-{label}"))) .px_4() .py_2() .rounded_full() + .cursor_pointer() .bg(if active { theme::primary_action() } else { @@ -209,6 +361,17 @@ fn filter_chip(label: &'static str, count: usize, active: bool) -> impl IntoElem } else { theme::text_secondary() }) + .hover(|style| { + style.bg(if active { + theme::primary_action_hover() + } else { + theme::panel_hover() + }) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.state.inbox_filter = next; + cx.notify(); + })) .child(format!("{} {}", label, count)); if active { chip.with_animation( @@ -224,55 +387,17 @@ fn filter_chip(label: &'static str, count: usize, active: bool) -> impl IntoElem } } -fn inbox_group(label: &'static str, rows: impl Iterator) -> impl IntoElement { +fn active_filter_dot(accent: gpui::Hsla, index: usize) -> impl IntoElement { div() - .gap_3() - .child(div().text_xs().text_color(theme::text_muted()).child(label)) - .child( - div() - .border_1() - .rounded_lg() - .border_color(theme::panel_border()) - .bg(theme::panel()) - .children(rows), - ) -} - -fn inbox_row( - title: String, - subtitle: String, - tile_bg: gpui::Hsla, - accent: gpui::Hsla, -) -> gpui::Div { - div() - .px_5() - .py_4() - .border_b_1() - .border_color(theme::panel_border()) - .child( - div() - .flex() - .gap_3() - .items_start() - .child(div().w(px(14.0)).h(px(14.0)).rounded_full().bg(accent)) - .child( - div() - .flex_1() - .child( - div() - .text_base() - .text_color(theme::text_primary()) - .child(title), - ) - .child( - div() - .mt(px(6.0)) - .text_sm() - .text_color(theme::text_muted()) - .line_clamp(2) - .child(subtitle), - ), - ) - .child(div().w(px(20.0)).h(px(20.0)).rounded_full().bg(tile_bg)), + .w(px(10.0)) + .h(px(10.0)) + .rounded_full() + .bg(accent) + .with_animation( + ("inbox-row-dot", index), + Animation::new(Duration::from_secs(2)) + .repeat() + .with_easing(pulsating_between(0.8, 1.0)), + |element, delta| element.opacity(delta), ) } diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root/runs.rs b/AgentCompany-Rust/crates/app_ui/src/app_root/runs.rs index d81b1d64..43f8598b 100644 --- a/AgentCompany-Rust/crates/app_ui/src/app_root/runs.rs +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/runs.rs @@ -150,43 +150,6 @@ impl AppRoot { .text_color(theme::text_muted()) .child(run.status.clone()), ) - .child( - div() - .mt(px(18.0)) - .gap_3() - .child( - div() - .flex() - .gap_3() - .child(primary_button("Launch native runtime", cx, |this, cx| { - this.launch_selected_run(cx); - })) - .child(secondary_button("Approve selected gate", cx, |this, cx| { - this.decide_selected_approval(domain::ApprovalState::Approved, "Approved from native runtime controls.", cx); - })) - .child(secondary_button("Reject selected gate", cx, |this, cx| { - this.decide_selected_approval(domain::ApprovalState::Rejected, "Rejected from native runtime controls.", cx); - })), - ) - .child(self.runtime_steer_input.clone()) - .child( - div() - .flex() - .justify_end() - .child(primary_button("Send steer", cx, |this, cx| { - this.send_runtime_steer(cx); - })), - ) - .child(self.runtime_follow_up_input.clone()) - .child( - div() - .flex() - .justify_end() - .child(secondary_button("Queue follow-up", cx, |this, cx| { - this.send_runtime_follow_up(cx); - })), - ), - ) .child( div() .mt(px(16.0)) @@ -303,6 +266,7 @@ impl AppRoot { ) .child(run_artifacts_panel(&self.snapshot, run.id.as_str())), ) + .child(run_live_controls_panel(self, cx)) }) .when(selected.is_none(), |container| { container.child( @@ -569,6 +533,74 @@ fn run_artifacts_panel(snapshot: &domain::ProfileSnapshot, run_id: &str) -> impl } } +fn run_live_controls_panel<'a>( + root: &'a AppRoot, + cx: &'a mut Context, +) -> impl IntoElement + 'a { + div() + .p_5() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .child( + div() + .text_lg() + .text_color(theme::text_primary()) + .child("Live runtime controls"), + ) + .child( + div() + .mt(px(8.0)) + .text_sm() + .text_color(theme::text_muted()) + .child("Runtime intervention stays here so the main run summary keeps the original operational hierarchy."), + ) + .child( + div() + .mt(px(16.0)) + .flex() + .gap_3() + .child(primary_button("Launch native runtime", cx, |this, cx| { + this.launch_selected_run(cx); + })) + .child(secondary_button("Approve selected gate", cx, |this, cx| { + this.decide_selected_approval( + domain::ApprovalState::Approved, + "Approved from native runtime controls.", + cx, + ); + })) + .child(secondary_button("Reject selected gate", cx, |this, cx| { + this.decide_selected_approval( + domain::ApprovalState::Rejected, + "Rejected from native runtime controls.", + cx, + ); + })), + ) + .child(root.runtime_steer_input.clone()) + .child( + div() + .mt(px(10.0)) + .flex() + .justify_end() + .child(primary_button("Send steer", cx, |this, cx| { + this.send_runtime_steer(cx); + })), + ) + .child(root.runtime_follow_up_input.clone()) + .child( + div() + .mt(px(10.0)) + .flex() + .justify_end() + .child(secondary_button("Queue follow-up", cx, |this, cx| { + this.send_runtime_follow_up(cx); + })), + ) +} + fn timeline_row(label: &'static str, value: String) -> impl IntoElement { div() .flex() diff --git a/AgentCompany-Rust/prd.json b/AgentCompany-Rust/prd.json index 474dea84..0e947141 100644 --- a/AgentCompany-Rust/prd.json +++ b/AgentCompany-Rust/prd.json @@ -155,7 +155,7 @@ ], "priority": 11, "passes": false, - "notes": "In progress: native dashboard now restores banner/attention/performance/active-work/activity grammar with real local data instead of the earlier simplified placeholder overview. Remaining gap is finer inbox parity and some dashboard micro-structure details versus the Electron source." + "notes": "In progress: dashboard now restores banners, attention rows, performance cards, active work, and activity feed. Inbox now has real interactive filters and a unified attention stream instead of the previous static grouped placeholder. Remaining gap is finer visual parity and final audit." }, { "id": "US-012", @@ -169,7 +169,7 @@ ], "priority": 12, "passes": false, - "notes": "Audit found the live runtime controls now dominate the run detail layout and approvals still flatten original review semantics." + "notes": "In progress: approvals now restore board/user/manager review grouping semantics and runs demote native runtime controls out of the main header hierarchy into a secondary live-controls section. Remaining gap is finer run queue/detail grammar parity." }, { "id": "US-013", diff --git a/AgentCompany-Rust/progress.txt b/AgentCompany-Rust/progress.txt index d5d9bdd3..b2476e10 100644 --- a/AgentCompany-Rust/progress.txt +++ b/AgentCompany-Rust/progress.txt @@ -14,4 +14,4 @@ Started: 2026-04-19 - 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- 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- Rebuilt the native dashboard away from the placeholder metrics/activity shell. It now has real recovery and budget banners, attention rows, a performance section, active work, and recent activity, all driven by persisted local data. Remaining US-011 work is the inbox-side parity and finer dashboard detail matching.\n \ No newline at end of file +- 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- Rebuilt the native dashboard away from the placeholder metrics/activity shell. It now has real recovery and budget banners, attention rows, a performance section, active work, and recent activity, all driven by persisted local data. Remaining US-011 work is the inbox-side parity and finer dashboard detail matching.\n- Restored more source grammar across inbox, approvals, and runs. Inbox now has real interactive filters driving a unified attention stream; approvals again distinguish board/user/manager review groupings; and run detail no longer lets live runtime controls dominate the main header region.\n \ No newline at end of file