From e1b7f52444fb1447147f15b33619f17a41f0b9f7 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:53:51 -0400 Subject: [PATCH] Align runs and inbox native surfaces --- .../crates/app_ui/src/app_root/inbox.rs | 298 +++++++++-- .../crates/app_ui/src/app_root/runs.rs | 489 ++++++++++++++---- 2 files changed, 634 insertions(+), 153 deletions(-) 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 e579b394..579885bc 100644 --- a/AgentCompany-Rust/crates/app_ui/src/app_root/inbox.rs +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/inbox.rs @@ -2,61 +2,263 @@ use super::*; impl AppRoot { pub(super) fn render_inbox(&self) -> impl IntoElement + '_ { + let pending_approvals = self + .snapshot + .approvals + .iter() + .filter(|item| item.state == "pending") + .collect::>(); + let unread_messages = self + .snapshot + .agent_messages + .iter() + .filter(|item| item.read_at.is_none()) + .collect::>(); + let review_tasks = self + .snapshot + .tasks + .iter() + .filter(|task| task.status == "in_review") + .collect::>(); + let failed_runs = self + .snapshot + .runs + .iter() + .filter(|run| { + matches!( + run.status.as_str(), + "failed" | "timed_out" | "cancelled" | "interrupted" + ) + }) + .collect::>(); + let total_attention = pending_approvals.len() + + unread_messages.len() + + review_tasks.len() + + failed_runs.len(); + div() .flex() - .gap_4() + .flex_col() + .gap_6() .child( div() - .flex_1() - .p_4() - .gap_2() - .border_1() - .rounded_md() - .border_color(theme::panel_border()) - .bg(theme::panel()) - .child(div().text_lg().child("Unread messages")) - .children( - self.snapshot - .agent_messages - .iter() - .filter(|item| item.read_at.is_none()) - .take(8) - .map(|item| { + .flex() + .items_center() + .justify_between() + .child( + div() + .child( div() - .py_2() - .border_b_1() - .border_color(theme::panel_inset_border()) - .child(format!("{} · {}", item.subject, item.body)) - }), + .text_xl() + .text_color(theme::text_primary()) + .child("Inbox"), + ) + .child( + div() + .mt(px(6.0)) + .text_sm() + .text_color(theme::text_muted()) + .child(format!("{} items need attention", total_attention)), + ), ), ) - .child( - div() - .flex_1() - .p_4() - .gap_2() - .border_1() - .rounded_md() - .border_color(theme::panel_border()) - .bg(theme::panel()) - .child(div().text_lg().child("Pending approvals")) - .children( - self.snapshot - .approvals - .iter() - .filter(|item| item.state == "pending") - .take(8) - .map(|item| { - div() - .py_2() - .border_b_1() - .border_color(theme::panel_inset_border()) - .child(format!( - "{} · {}", - item.approval_type, item.payload_summary - )) + .child(inbox_filters( + pending_approvals.len(), + review_tasks.len(), + failed_runs.len(), + unread_messages.len(), + )) + .when(total_attention == 0, |container| { + container.child( + div() + .min_h(px(260.0)) + .flex() + .items_center() + .justify_center() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .child( + div() + .w(px(420.0)) + .text_center() + .child( + div() + .text_xl() + .text_color(theme::text_primary()) + .child("All clear"), + ) + .child( + div() + .mt(px(10.0)) + .text_base() + .text_color(theme::text_muted()) + .child("No approvals, reviews, failed runs, or unread internal messages need board attention right now."), + ), + ), + ) + }) + .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(), + ) + }), + )) + }) + }) } } + +fn inbox_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(|| { + if run.summary.trim().is_empty() { + format!("Run {}", run.id.get(..8).unwrap_or(run.id.as_str())) + } else { + run.summary.clone() + } + }) +} + +fn inbox_filters( + approvals: usize, + reviews: usize, + failed_runs: usize, + messages: usize, +) -> impl IntoElement { + div() + .flex() + .gap_2() + .child(filter_chip( + "All", + approvals + reviews + failed_runs + messages, + true, + )) + .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 { + div() + .px_4() + .py_2() + .rounded_full() + .bg(if active { + theme::primary_action() + } else { + theme::panel_muted() + }) + .text_xs() + .text_color(if active { + theme::primary_action_text() + } else { + theme::text_secondary() + }) + .child(format!("{} {}", label, count)) +} + +fn inbox_group(label: &'static str, rows: impl Iterator) -> 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)), + ) +} 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 f7961c3c..f8169b52 100644 --- a/AgentCompany-Rust/crates/app_ui/src/app_root/runs.rs +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/runs.rs @@ -7,150 +7,429 @@ impl AppRoot { .selected_run_id .as_ref() .and_then(|id| self.snapshot.runs.iter().find(|item| &item.id == id)); - let selected_status = selected - .map(|item| item.status.clone()) - .unwrap_or_else(|| "Select a run".to_string()); - let selected_summary = selected - .map(|item| item.summary.clone()) - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "No run selected.".to_string()); - let selected_model = selected - .and_then(|item| item.model.clone()) - .unwrap_or_else(|| "model pending".to_string()); - let selected_agent = selected - .and_then(|run| { - self.snapshot - .agents - .iter() - .find(|agent| agent.id == run.agent_id) - .map(|agent| agent.name.clone()) - }) - .unwrap_or_else(|| "Agent unavailable".to_string()); - let selected_workspace = selected - .and_then(|run| { - run.workspace_id.as_ref().and_then(|workspace_id| { - self.snapshot - .workspaces - .iter() - .find(|workspace| &workspace.id == workspace_id) - .map(|workspace| workspace.name.clone()) - }) - }) - .unwrap_or_else(|| "Workspace pending".to_string()); - let selected_error = selected - .and_then(|item| item.error_message.clone()) - .unwrap_or_default(); div() .flex() - .gap_4() + .gap_6() .child( div() .id("runs-list") - .w(px(360.0)) - .p_4() - .gap_2() + .w(px(420.0)) + .p_5() + .gap_4() .border_1() - .rounded_md() + .rounded_lg() .border_color(theme::panel_border()) .bg(theme::panel()) .overflow_scroll() - .child(div().text_lg().child("Run queue")) + .child( + div() + .flex() + .justify_between() + .items_center() + .child( + div() + .child( + div() + .text_xl() + .text_color(theme::text_primary()) + .child("Run queue"), + ) + .child( + div() + .mt(px(6.0)) + .text_sm() + .text_color(theme::text_muted()) + .child(format!("{} runs", self.snapshot.runs.len())), + ), + ) + .child(run_filter_shell()), + ) + .child(run_search_shell("Search runs")) .children(self.snapshot.runs.iter().map(|item| { let active = self.state.selected_run_id.as_deref() == Some(item.id.as_str()); let run_id = item.id.clone(); - let headline = if item.summary.trim().is_empty() { - format!("{} · {}", item.status, item.id) + let title = run_task_title(&self.snapshot, item); + let subtitle = run_subtitle(&self.snapshot, item); + let summary = if item.summary.trim().is_empty() { + "No summary yet".to_string() } else { - format!("{} · {}", item.status, item.summary) + item.summary.clone() }; div() .id(SharedString::from(format!("run-{}", item.id))) - .px_3() - .py_2() - .rounded_md() - .cursor_pointer() + .p_4() + .rounded_lg() + .border_1() + .border_color(if active { + theme::selection_border() + } else { + theme::panel_border() + }) .bg(if active { theme::selection_fill() } else { theme::panel() }) + .cursor_pointer() .hover(|style| style.bg(theme::panel_hover())) .on_click(cx.listener(move |this, _, _, cx| { this.state.selected_run_id = Some(run_id.clone()); cx.notify(); })) - .child(headline) + .child( + div() + .flex() + .justify_between() + .items_center() + .child( + div() + .flex_1() + .child( + div() + .text_base() + .text_color(theme::text_primary()) + .truncate() + .child(title), + ) + .child( + div() + .mt(px(6.0)) + .text_sm() + .text_color(theme::text_muted()) + .child(subtitle), + ), + ) + .child(run_status_badge(item.status.as_str())), + ) + .child( + div() + .mt(px(10.0)) + .text_sm() + .text_color(theme::text_secondary()) + .line_clamp(2) + .child(summary), + ) + .when_some(item.updated_at.get(..10), |container, date| { + container.child( + div() + .mt(px(10.0)) + .text_xs() + .text_color(theme::text_subtle()) + .child(date.to_string()), + ) + }) })), ) .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("Run inspector")) - .child( - div() - .text_sm() - .text_color(theme::text_muted()) - .child(selected_status), - ) - .child( - div() - .flex() - .gap_2() - .child(self.metric_card("Agent", selected_agent, "Assigned operator")) - .child(self.metric_card( - "Model", - selected_model, - "Runtime-selected model", - )), - ) - .child( - div() - .flex() - .gap_2() - .child(self.metric_card( - "Workspace", - selected_workspace, - "Bound workspace", - )) + .gap_5() + .when_some(selected, |container, run| { + container .child( - self.metric_card( - "Connector", - selected - .map(|item| item.connector_id.clone()) - .unwrap_or_else(|| "connector pending".to_string()), - "Execution provider", - ), - ), - ) - .child( - div() - .min_h(px(180.0)) - .p_3() - .border_1() - .border_color(theme::panel_inset_border()) - .rounded_md() - .bg(theme::panel_inset()) - .child(selected_summary), - ) - .when(!selected_error.is_empty(), |container| { + div() + .p_5() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .child( + div() + .text_xl() + .text_color(theme::text_primary()) + .child(run_task_title(&self.snapshot, run)), + ) + .child( + div() + .mt(px(8.0)) + .text_sm() + .text_color(theme::text_muted()) + .child(run.status.clone()), + ) + .child( + div() + .mt(px(16.0)) + .flex() + .gap_3() + .child(info_tile( + "AGENT", + run_agent_name(&self.snapshot, run), + )) + .child(info_tile( + "MODEL", + run.model.clone().unwrap_or_else(|| "Pending".to_string()), + )), + ) + .child( + div() + .mt(px(12.0)) + .flex() + .gap_3() + .child(info_tile( + "WORKSPACE", + run_workspace_name(&self.snapshot, run), + )) + .child(info_tile( + "CONNECTOR", + run.connector_id.clone(), + )), + ) + .child( + div() + .mt(px(18.0)) + .p_4() + .rounded_lg() + .border_1() + .border_color(theme::panel_inset_border()) + .bg(theme::panel_inset()) + .child(if run.summary.trim().is_empty() { + "No run summary yet.".to_string() + } else { + run.summary.clone() + }), + ) + .when_some(run.error_message.clone(), |content, error| { + if error.trim().is_empty() { + content + } else { + content.child( + div() + .mt(px(16.0)) + .p_4() + .rounded_lg() + .border_1() + .border_color(theme::danger()) + .bg(theme::danger_bg()) + .child(error), + ) + } + }), + ) + .child( + div() + .p_5() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .child( + div() + .flex() + .justify_between() + .items_center() + .child( + div() + .text_lg() + .text_color(theme::text_primary()) + .child("Run timeline"), + ) + .child( + div() + .text_sm() + .text_color(theme::text_muted()) + .child(short_date(run.updated_at.as_str()).to_string()), + ), + ) + .child(run_timeline(&self.snapshot, run)), + ) + }) + .when(selected.is_none(), |container| { container.child( div() - .p_3() + .min_h(px(320.0)) + .flex() + .items_center() + .justify_center() + .rounded_lg() .border_1() - .border_color(theme::primary_action()) - .rounded_md() - .bg(theme::danger_bg()) - .child(selected_error), + .border_color(theme::panel_border()) + .bg(theme::panel()) + .child( + div() + .w(px(420.0)) + .text_center() + .child( + div() + .text_xl() + .text_color(theme::text_primary()) + .child("No run selected"), + ) + .child( + div() + .mt(px(10.0)) + .text_base() + .text_color(theme::text_muted()) + .child("Choose a run from the queue to inspect its status and execution context."), + ), + ), ) }), ) } } + +fn run_task_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(|| { + if run.summary.trim().is_empty() { + format!("Run {}", short_id(run.id.as_str())) + } else { + run.summary.clone() + } + }) +} + +fn run_subtitle(snapshot: &domain::ProfileSnapshot, run: &domain::Run) -> String { + format!( + "{} via {}{}", + run_agent_name(snapshot, run), + run.connector_id, + run.workspace_id + .as_ref() + .map(|_| format!(" · {}", run_workspace_name(snapshot, run))) + .unwrap_or_default() + ) +} + +fn run_agent_name(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()) +} + +fn run_workspace_name(snapshot: &domain::ProfileSnapshot, run: &domain::Run) -> String { + run.workspace_id + .as_ref() + .and_then(|workspace_id| { + snapshot + .workspaces + .iter() + .find(|workspace| &workspace.id == workspace_id) + }) + .map(|workspace| workspace.name.clone()) + .unwrap_or_else(|| "Workspace pending".to_string()) +} + +fn run_status_badge(status: &str) -> impl IntoElement { + div() + .px_3() + .py_2() + .rounded_full() + .bg(match status { + "running" => theme::accent_teal_soft(), + "failed" | "timed_out" | "cancelled" | "interrupted" => theme::danger_bg(), + _ => theme::panel_muted(), + }) + .text_xs() + .text_color(match status { + "running" => theme::accent_teal(), + "failed" | "timed_out" | "cancelled" | "interrupted" => theme::danger(), + _ => theme::text_secondary(), + }) + .child(status.to_uppercase()) +} + +fn info_tile(label: &'static str, value: String) -> impl IntoElement { + div() + .flex_1() + .p_4() + .rounded_lg() + .bg(theme::panel_muted()) + .child(div().text_xs().text_color(theme::text_muted()).child(label)) + .child( + div() + .mt(px(8.0)) + .text_sm() + .text_color(theme::text_primary()) + .child(value), + ) +} + +fn run_timeline(snapshot: &domain::ProfileSnapshot, run: &domain::Run) -> impl IntoElement { + div() + .mt(px(16.0)) + .gap_3() + .child(timeline_row( + "Created", + short_date(run.created_at.as_str()).to_string(), + )) + .child(timeline_row( + "Started", + run.started_at + .as_deref() + .map(short_date) + .unwrap_or("Pending") + .to_string(), + )) + .child(timeline_row( + "Task", + run.task_id + .as_ref() + .and_then(|task_id| snapshot.tasks.iter().find(|task| &task.id == task_id)) + .map(|task| task.status.clone()) + .unwrap_or_else(|| "Unlinked".to_string()), + )) + .child(timeline_row( + "Updated", + short_date(run.updated_at.as_str()).to_string(), + )) +} + +fn timeline_row(label: &'static str, value: String) -> impl IntoElement { + div() + .flex() + .justify_between() + .items_center() + .py_2() + .border_b_1() + .border_color(theme::panel_border()) + .child(div().text_sm().text_color(theme::text_muted()).child(label)) + .child( + div() + .text_sm() + .text_color(theme::text_primary()) + .child(value), + ) +} + +fn run_filter_shell() -> impl IntoElement { + div() + .px_4() + .py_3() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .text_sm() + .text_color(theme::text_secondary()) + .child("All") +} + +fn run_search_shell(label: &'static str) -> impl IntoElement { + div() + .px_4() + .py_3() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .text_sm() + .text_color(theme::text_subtle()) + .child(label) +} + +fn short_id(value: &str) -> &str { + value.get(..8).unwrap_or(value) +} + +fn short_date(value: &str) -> &str { + value.get(..10).unwrap_or(value) +}