From 320706fecf42b9fec4fc2113c2b65284b8153583 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 15:50:28 -0400 Subject: [PATCH] Restore native onboarding and dashboard grammar --- .../crates/app_ui/src/app_root.rs | 2 +- .../crates/app_ui/src/app_root/overview.rs | 728 +++++++++++++----- AgentCompany-Rust/prd.json | 2 +- AgentCompany-Rust/progress.txt | 2 +- 4 files changed, 551 insertions(+), 183 deletions(-) diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root.rs b/AgentCompany-Rust/crates/app_ui/src/app_root.rs index 95ede351..068c2880 100644 --- a/AgentCompany-Rust/crates/app_ui/src/app_root.rs +++ b/AgentCompany-Rust/crates/app_ui/src/app_root.rs @@ -406,7 +406,7 @@ impl Render for AppRoot { .overflow_scroll() .p_8() .child(match self.state.section { - SectionId::Overview => self.render_overview().into_any_element(), + 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::Messages => self.render_messages(cx).into_any_element(), diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root/overview.rs b/AgentCompany-Rust/crates/app_ui/src/app_root/overview.rs index 1f9697f0..125bdbc8 100644 --- a/AgentCompany-Rust/crates/app_ui/src/app_root/overview.rs +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/overview.rs @@ -1,240 +1,608 @@ use super::*; impl AppRoot { - pub(super) fn render_overview(&self) -> impl IntoElement + '_ { - let company = self.snapshot.current_company(); - let company_name = company - .map(|item| item.name.clone()) - .unwrap_or_else(|| "No company".to_string()); - let queued_tasks = self + pub(super) fn render_overview(&self, cx: &mut Context) -> impl IntoElement + '_ { + let pending_approvals = self .snapshot - .tasks + .approvals .iter() - .filter(|task| task.status == "todo") - .count(); - let running_agents = self + .filter(|approval| approval.state == "pending") + .collect::>(); + let failed_runs = self + .snapshot + .runs + .iter() + .filter(|run| { + matches!( + run.status.as_str(), + "failed" | "timed_out" | "cancelled" | "interrupted" + ) + }) + .collect::>(); + let unread_messages = self + .snapshot + .agent_messages + .iter() + .filter(|message| message.read_at.is_none()) + .collect::>(); + let budget_alerts = self .snapshot .agents .iter() - .filter(|agent| agent.status == "running") - .count(); + .filter(|agent| { + agent.budget_monthly_usd > 0.0 + && agent.spent_monthly_usd >= agent.budget_monthly_usd * 0.8 + }) + .collect::>(); + let interrupted_runs = self + .snapshot + .runs + .iter() + .filter(|run| run.status == "interrupted") + .collect::>(); + let active_work = self + .snapshot + .runs + .iter() + .filter(|run| matches!(run.status.as_str(), "queued" | "running")) + .take(8) + .collect::>(); + let total_spend = self + .snapshot + .runs + .iter() + .filter_map(|run| run.cost_usd) + .sum::(); div() .flex() .flex_col() - .gap_8() + .gap_6() + .when(!interrupted_runs.is_empty(), |container| { + container.child(recovery_banner(interrupted_runs.as_slice())) + }) + .when(!budget_alerts.is_empty(), |container| { + container.child(budget_alert_banner(budget_alerts.as_slice())) + }) .child( div() - .gap_2() - .child( - div() - .text_3xl() - .text_color(theme::text_primary()) - .child(company_name), - ) - .child( - div() - .flex() - .gap_3() - .items_center() - .text_sm() - .text_color(theme::text_muted()) - .child(metadata_dot(theme::accent_teal())) - .child(format!("{} agents active", self.snapshot.agents.len())) - .child(div().child("•")) - .child(metadata_dot(theme::badge_dark())) - .child(format!( - "{} tasks in progress", - self.snapshot.active_tasks() - )) - .child(div().child("•")) - .child(format!( - "{} completed", - completed_task_count(&self.snapshot) - )), - ), - ) - .child( - div() - .flex() - .gap_4() - .child(metric_panel( + .grid() + .grid_cols(4) + .gap_3() + .child(metric_card( "ACTIVE TASKS", self.snapshot.active_tasks().to_string(), - format!("{} queued", queued_tasks), + format!( + "{} queued", + self.snapshot + .tasks + .iter() + .filter(|task| task.status == "todo") + .count() + ), theme::accent_blue_soft(), theme::accent_blue(), )) - .child(metric_panel( + .child(metric_card( "APPROVALS", - self.snapshot.pending_approvals().to_string(), - if self.snapshot.pending_approvals() == 0 { + pending_approvals.len().to_string(), + if pending_approvals.is_empty() { "All clear".to_string() } else { - "Needs board attention".to_string() + "Needs attention".to_string() }, - theme::panel_muted(), - theme::text_primary(), + theme::accent_orange_soft(), + theme::accent_orange(), )) - .child(metric_panel( + .child(metric_card( "AGENTS", self.snapshot.agents.len().to_string(), - format!("{} running", running_agents), + format!( + "{} running", + self.snapshot + .agents + .iter() + .filter(|agent| agent.status == "running") + .count() + ), theme::accent_teal_soft(), theme::accent_teal(), )) - .child(metric_panel( + .child(metric_card( "TOTAL SPEND", - total_spend_label(&self.snapshot), + format!("${total_spend:.2}"), "Local profile budget trace".to_string(), theme::panel_muted(), theme::text_primary(), )), ) - .child(section_header("ACTIVE WORK", Some("All runs →"))) + .when( + !(pending_approvals.is_empty() + && failed_runs.is_empty() + && unread_messages.is_empty()), + |container| { + container.child(section_label("NEEDS ATTENTION")).child( + div() + .gap_2() + .children(pending_approvals.iter().take(5).map(|approval| { + attention_row( + approval.payload_summary.clone(), + format!( + "{} · pending", + approval.approval_type.replace('_', " ") + ), + theme::accent_orange_soft(), + theme::accent_orange(), + ) + })) + .children(failed_runs.iter().take(3).map(|run| { + attention_row( + overview_run_title(&self.snapshot, run), + run.error_message + .clone() + .unwrap_or_else(|| run.status.clone()), + theme::danger_bg(), + theme::danger(), + ) + })) + .children(unread_messages.iter().take(3).map(|message| { + attention_row( + message.subject.clone(), + message.body.clone(), + theme::accent_teal_soft(), + theme::accent_teal(), + ) + })), + ) + }, + ) + .child(section_label("PERFORMANCE")) .child( div() - .border_1() - .rounded_lg() - .border_color(theme::panel_border()) - .bg(theme::panel()) - .children(self.snapshot.runs.iter().take(6).map(|run| { - let task_title = self - .snapshot - .tasks - .iter() - .find(|task| Some(task.id.as_str()) == run.task_id.as_deref()) - .map(|task| task.title.clone()) - .unwrap_or_else(|| { - if run.summary.trim().is_empty() { - format!("Run {}", trim_id(&run.id)) - } else { - run.summary.clone() - } - }); - let agent_name = self - .snapshot - .agents - .iter() - .find(|agent| agent.id == run.agent_id) - .map(|agent| agent.name.clone()) - .unwrap_or_else(|| "Unknown agent".to_string()); - div() - .px_5() - .py_4() - .border_b_1() - .border_color(theme::panel_border()) - .child( - div() - .text_base() - .text_color(theme::text_primary()) - .child(task_title), - ) - .child( - div() - .text_sm() - .text_color(theme::text_muted()) - .child(format!("{} · {}", agent_name, run.status)), - ) - })), - ) - .child(section_header("RECENT ACTIVITY", None)) - .child( - div() - .border_1() - .rounded_lg() - .border_color(theme::panel_border()) - .bg(theme::panel()) - .children(self.snapshot.activity.iter().take(8).map(|item| { - div() - .px_5() - .py_4() - .border_b_1() - .border_color(theme::panel_border()) - .child( - div() - .text_base() - .text_color(theme::text_primary()) - .child(format!( - "{} {}", - item.actor, - item.action.replace('_', " ") - )), - ) - .child( - div() - .text_sm() - .text_color(theme::text_muted()) - .child(item.detail.clone()), - ) - })), + .grid() + .grid_cols(2) + .gap_3() + .child(run_activity_card(&self.snapshot)) + .child(success_rate_card(&self.snapshot)) + .child(cost_trend_card(&self.snapshot)) + .child(agent_workload_card(&self.snapshot)), ) + .child(section_label("ACTIVE WORK")) + .child(active_work_card(&self.snapshot, active_work.as_slice(), cx)) + .child(section_label("RECENT ACTIVITY")) + .child(activity_feed_card(&self.snapshot)) } } -fn completed_task_count(snapshot: &domain::ProfileSnapshot) -> usize { - snapshot - .tasks - .iter() - .filter(|task| task.status == "done") - .count() +fn recovery_banner(runs: &[&domain::Run]) -> impl IntoElement { + div() + .rounded_lg() + .border_1() + .border_color(theme::danger()) + .bg(theme::danger_bg()) + .p_4() + .gap_3() + .child(div().text_sm().text_color(theme::danger()).child(format!( + "{} interrupted task(s) detected from the previous session", + runs.len() + ))) + .children(runs.iter().map(|run| { + div() + .rounded_md() + .bg(theme::panel()) + .px_4() + .py_3() + .child( + div() + .text_sm() + .text_color(theme::text_primary()) + .child(overview_run_title_from_run(run)), + ) + .child( + div() + .mt(px(4.0)) + .text_xs() + .text_color(theme::text_muted()) + .child(run.id.get(..8).unwrap_or(run.id.as_str()).to_string()), + ) + })) } -fn total_spend_label(snapshot: &domain::ProfileSnapshot) -> String { - let spend = snapshot +fn budget_alert_banner(agents: &[&domain::Agent]) -> impl IntoElement { + div().gap_2().children(agents.iter().map(|agent| { + div() + .rounded_lg() + .border_1() + .border_color(theme::danger()) + .bg(theme::accent_orange_soft()) + .px_4() + .py_3() + .child( + div() + .text_sm() + .text_color(theme::accent_orange()) + .child(format!( + "{} is at ${:.2} / ${:.2} monthly budget.", + agent.name, agent.spent_monthly_usd, agent.budget_monthly_usd + )), + ) + })) +} + +fn metric_card( + label: &'static str, + value: String, + subtitle: String, + icon_bg: gpui::Hsla, + tone: gpui::Hsla, +) -> impl IntoElement { + div() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .p_4() + .child( + div() + .flex() + .gap_4() + .items_center() + .child(div().w(px(48.0)).h(px(48.0)).rounded_lg().bg(icon_bg)) + .child( + div() + .flex_1() + .child(div().text_xs().text_color(theme::text_muted()).child(label)) + .child(div().text_3xl().text_color(tone).child(value)) + .child( + div() + .mt(px(6.0)) + .text_xs() + .text_color(theme::text_muted()) + .child(subtitle), + ), + ), + ) +} + +fn section_label(label: &'static str) -> impl IntoElement { + div().text_xs().text_color(theme::text_muted()).child(label) +} + +fn attention_row( + title: String, + subtitle: String, + tile_bg: gpui::Hsla, + accent: gpui::Hsla, +) -> impl IntoElement { + div() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .px_4() + .py_3() + .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_sm() + .text_color(theme::text_primary()) + .child(title), + ) + .child( + div() + .mt(px(4.0)) + .text_xs() + .text_color(theme::text_muted()) + .line_clamp(2) + .child(subtitle), + ), + ) + .child(div().w(px(10.0)).h(px(10.0)).rounded_full().bg(accent)), + ) +} + +fn run_activity_card(snapshot: &domain::ProfileSnapshot) -> impl IntoElement { + let queued = snapshot + .runs + .iter() + .filter(|run| run.status == "queued") + .count(); + let running = snapshot + .runs + .iter() + .filter(|run| run.status == "running") + .count(); + let completed = snapshot + .runs + .iter() + .filter(|run| run.status == "completed") + .count(); + mini_chart_card( + "RUN ACTIVITY", + vec![ + ("queued".to_string(), queued), + ("running".to_string(), running), + ("completed".to_string(), completed), + ], + theme::accent_blue(), + ) +} + +fn success_rate_card(snapshot: &domain::ProfileSnapshot) -> impl IntoElement { + let finished = snapshot + .runs + .iter() + .filter(|run| matches!(run.status.as_str(), "completed" | "failed" | "timed_out")) + .count(); + let completed = snapshot + .runs + .iter() + .filter(|run| run.status == "completed") + .count(); + let rate = if finished == 0 { + 0.0 + } else { + completed as f32 / finished as f32 + }; + div() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .p_5() + .child( + div() + .text_xs() + .text_color(theme::text_muted()) + .child("SUCCESS RATE"), + ) + .child( + div() + .mt(px(10.0)) + .text_3xl() + .text_color(theme::accent_teal()) + .child(format!("{:.0}%", rate * 100.0)), + ) + .child( + div() + .mt(px(10.0)) + .h(px(10.0)) + .rounded_full() + .bg(theme::panel_muted()) + .child( + div() + .w(gpui::relative(rate.clamp(0.0, 1.0))) + .h_full() + .rounded_full() + .bg(theme::accent_teal()), + ), + ) +} + +fn cost_trend_card(snapshot: &domain::ProfileSnapshot) -> impl IntoElement { + let costs = snapshot .runs .iter() .filter_map(|run| run.cost_usd) - .sum::(); - format!("${spend:.2}") -} - -fn trim_id(value: &str) -> &str { - value.get(..8).unwrap_or(value) -} - -fn metadata_dot(color: gpui::Hsla) -> impl IntoElement { - div().w(px(8.0)).h(px(8.0)).rounded_full().bg(color) -} - -fn section_header(label: &'static str, action: Option<&'static str>) -> impl IntoElement { + .collect::>(); + let total = costs.iter().sum::(); + let average = if costs.is_empty() { + 0.0 + } else { + total / costs.len() as f64 + }; div() - .flex() - .items_center() - .justify_between() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .p_5() + .child( + div() + .text_xs() + .text_color(theme::text_muted()) + .child("COST TREND"), + ) + .child( + div() + .mt(px(10.0)) + .text_3xl() + .text_color(theme::text_primary()) + .child(format!("${total:.2}")), + ) + .child( + div() + .mt(px(8.0)) + .text_xs() + .text_color(theme::text_muted()) + .child(format!("Average ${average:.2} per metered run")), + ) +} + +fn agent_workload_card(snapshot: &domain::ProfileSnapshot) -> impl IntoElement { + let rows = snapshot + .agents + .iter() + .map(|agent| { + let active_runs = snapshot + .runs + .iter() + .filter(|run| run.agent_id == agent.id) + .count(); + (agent.name.clone(), active_runs) + }) + .collect::>(); + mini_chart_card("AGENT WORKLOAD", rows, theme::accent_orange()) +} + +fn mini_chart_card( + label: &'static str, + rows: Vec<(String, usize)>, + accent: gpui::Hsla, +) -> impl IntoElement { + let max = rows + .iter() + .map(|(_, count)| *count) + .max() + .unwrap_or(1) + .max(1) as f32; + div() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .p_5() .child(div().text_xs().text_color(theme::text_muted()).child(label)) - .when_some(action, |container, action_label| { + .children(rows.into_iter().map(|(name, count)| { + div() + .mt(px(12.0)) + .child( + div() + .flex() + .justify_between() + .child( + div() + .text_sm() + .text_color(theme::text_primary()) + .child(name), + ) + .child( + div() + .text_xs() + .text_color(theme::text_muted()) + .child(count.to_string()), + ), + ) + .child( + div() + .mt(px(6.0)) + .h(px(8.0)) + .rounded_full() + .bg(theme::panel_muted()) + .child( + div() + .w(gpui::relative((count as f32 / max).clamp(0.0, 1.0))) + .h_full() + .rounded_full() + .bg(accent), + ), + ) + })) +} + +fn active_work_card<'a>( + snapshot: &'a domain::ProfileSnapshot, + runs: &[&'a domain::Run], + cx: &mut Context, +) -> impl IntoElement + 'a { + div() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .when(runs.is_empty(), |container| { container.child( div() - .text_sm() - .text_color(theme::accent_blue()) - .child(action_label), + .p_6() + .text_center() + .text_color(theme::text_muted()) + .child("No tasks are running right now."), ) }) + .when(!runs.is_empty(), |container| { + container.children(runs.iter().map(|run| { + let run_id = run.id.clone(); + div() + .id(SharedString::from(format!("overview-run-{run_id}"))) + .cursor_pointer() + .hover(|style| style.bg(theme::panel_hover())) + .on_click(cx.listener(move |this, _, _, cx| { + this.state.section = SectionId::Runs; + this.state.selected_run_id = Some(run_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(overview_run_title(snapshot, run)), + ) + .child( + div() + .mt(px(4.0)) + .text_sm() + .text_color(theme::text_muted()) + .child(format!( + "{} · {}", + overview_run_agent(snapshot, run), + run.status + )), + ) + })) + }) } -fn metric_panel( - label: &'static str, - value: String, - detail: String, - tile_bg: gpui::Hsla, - value_color: gpui::Hsla, -) -> impl IntoElement { +fn activity_feed_card(snapshot: &domain::ProfileSnapshot) -> impl IntoElement { div() - .flex_1() - .p_5() - .gap_3() - .border_1() .rounded_lg() + .border_1() .border_color(theme::panel_border()) .bg(theme::panel()) - .child(div().w(px(56.0)).h(px(56.0)).rounded_lg().bg(tile_bg)) - .child(div().text_xs().text_color(theme::text_muted()).child(label)) - .child(div().text_3xl().text_color(value_color).child(value)) - .child( + .children(snapshot.activity.iter().take(10).map(|item| { div() - .text_sm() - .text_color(theme::text_muted()) - .child(detail), - ) + .px_5() + .py_4() + .border_b_1() + .border_color(theme::panel_border()) + .child( + div() + .text_sm() + .text_color(theme::text_primary()) + .child(format!("{} {}", item.actor, item.action.replace('_', " "))), + ) + .child( + div() + .mt(px(4.0)) + .text_xs() + .text_color(theme::text_muted()) + .child(item.detail.clone()), + ) + })) +} + +fn overview_run_title(snapshot: &domain::ProfileSnapshot, run: &domain::Run) -> String { + run.task_id + .as_ref() + .and_then(|task_id| snapshot.tasks.iter().find(|task| &task.id == task_id)) + .map(|task| task.title.clone()) + .unwrap_or_else(|| overview_run_title_from_run(run)) +} + +fn overview_run_title_from_run(run: &domain::Run) -> String { + if run.summary.trim().is_empty() { + format!("Run {}", run.id.get(..8).unwrap_or(run.id.as_str())) + } else { + run.summary.clone() + } +} + +fn overview_run_agent(snapshot: &domain::ProfileSnapshot, run: &domain::Run) -> String { + snapshot + .agents + .iter() + .find(|agent| agent.id == run.agent_id) + .map(|agent| agent.name.clone()) + .unwrap_or_else(|| run.agent_id.clone()) } diff --git a/AgentCompany-Rust/prd.json b/AgentCompany-Rust/prd.json index 99ce4a2f..474dea84 100644 --- a/AgentCompany-Rust/prd.json +++ b/AgentCompany-Rust/prd.json @@ -155,7 +155,7 @@ ], "priority": 11, "passes": false, - "notes": "Audit found missing dashboard banners/charts/attention structure and inbox filter interaction parity." + "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." }, { "id": "US-012", diff --git a/AgentCompany-Rust/progress.txt b/AgentCompany-Rust/progress.txt index c6a48d68..d5d9bdd3 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 \ 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 \ No newline at end of file