From 5b686b0ce1c97d56dfda27e2986b4e3e4b56a8ea 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 17:34:12 -0400 Subject: [PATCH] Restore runs queue and detail grammar --- .../crates/app_ui/src/app_root/runs.rs | 706 ++++++++++-------- AgentCompany-Rust/prd.json | 4 +- AgentCompany-Rust/progress.txt | 2 + 3 files changed, 412 insertions(+), 300 deletions(-) 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 3486318e..d55156c4 100644 --- a/AgentCompany-Rust/crates/app_ui/src/app_root/runs.rs +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/runs.rs @@ -16,299 +16,423 @@ impl AppRoot { .state .selected_run_id .as_ref() - .and_then(|id| self.snapshot.runs.iter().find(|item| &item.id == id)); + .and_then(|id| self.snapshot.runs.iter().find(|item| &item.id == id)) + .or_else(|| filtered_runs.first().copied()); + + if self.snapshot.runs.is_empty() { + return runs_empty_state().into_any_element(); + } div() .flex() - .gap_6() + .gap_8() + .child(render_run_queue_panel( + &self.snapshot, + filtered_runs.as_slice(), + self.state.selected_run_id.as_deref(), + self.state.run_filter, + self.run_search_input.clone(), + cx, + )) + .child(render_run_detail_column(self, selected, cx)) + .into_any_element() + } +} + +fn render_run_queue_panel( + snapshot: &domain::ProfileSnapshot, + runs: &[&domain::Run], + selected_run_id: Option<&str>, + run_filter: RunFilter, + run_search_input: Entity, + cx: &mut Context, +) -> impl IntoElement { + div().w(px(420.0)).gap_5().child( + div() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .p_5() .child( div() - .id("runs-list") - .w(px(420.0)) - .p_5() - .gap_4() - .border_1() - .rounded_lg() - .border_color(theme::panel_border()) - .bg(theme::panel()) - .overflow_scroll() + .flex() + .justify_between() + .items_center() .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!("{} visible runs", filtered_runs.len())), - ), - ) - .child(run_filter_controls(self.state.run_filter, cx)), - ) - .child(self.run_search_input.clone()) - .children(filtered_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 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 { - item.summary.clone() - }; - div() - .id(SharedString::from(format!("run-{}", item.id))) - .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( - 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())), + .text_lg() + .text_color(theme::text_primary()) + .child("Run queue"), ) .child( div() - .mt(px(10.0)) + .mt(px(4.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()), - ) - }) - })), + .text_color(theme::text_muted()) + .child(format!("{} visible runs", runs.len())), + ), + ) + .child(run_filter_controls(run_filter, cx)), ) - .child( - div() - .flex_1() - .gap_5() - .when_some(selected, |container, run| { - container - .child( - 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)), - ) - .child( - 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("Runtime events"), - ) - .child(run_events_panel(&self.snapshot, run.id.as_str())), - ) - .child( - 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("Artifacts"), - ) - .child(run_artifacts_panel(&self.snapshot, run.id.as_str())), - ) - .child(run_live_controls_panel(self, cx)) - }) - .when(selected.is_none(), |container| { - container.child( + .child(div().mt(px(16.0)).child(run_search_input)) + .child(div().mt(px(16.0)).gap_2().children(runs.iter().map(|run| { + run_queue_row(snapshot, run, selected_run_id == Some(run.id.as_str()), cx) + .into_any_element() + }))) + .when(runs.is_empty(), |container| { + container.child( + div() + .mt(px(16.0)) + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel_muted()) + .px_4() + .py_6() + .text_center() + .child( div() - .min_h(px(320.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("Run queue is clear"), - ) - .child( - div() - .mt(px(10.0)) - .text_base() - .text_color(theme::text_muted()) - .child("Choose a run from the queue to inspect execution context, approvals, runtime events, and artifacts."), - ), - ), + .text_sm() + .text_color(theme::text_muted()) + .child("No runs match this filter."), + ), + ) + }), + ) +} + +fn run_queue_row( + snapshot: &domain::ProfileSnapshot, + run: &domain::Run, + active: bool, + cx: &mut Context, +) -> impl IntoElement { + let run_id = run.id.clone(); + let title = run_task_title(snapshot, run); + let subtitle = run_subtitle(snapshot, run); + let supporting_text = if let Some(error_message) = run.error_message.clone() { + if error_message.trim().is_empty() { + run.summary.clone() + } else { + error_message + } + } else if run.summary.trim().is_empty() { + "No summary yet".to_string() + } else { + run.summary.clone() + }; + + div() + .id(SharedString::from(format!("run-row-{}", run.id))) + .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(); + })) + .px_4() + .py_3() + .child( + div() + .flex() + .justify_between() + .items_center() + .gap_3() + .child( + div() + .flex_1() + .child( + div() + .text_sm() + .text_color(theme::text_primary()) + .truncate() + .child(title), ) - }), - ) - } + .child( + div() + .mt(px(4.0)) + .text_xs() + .text_color(theme::text_muted()) + .truncate() + .child(subtitle), + ), + ) + .child(run_status_badge(run.status.as_str())), + ) + .child( + div() + .mt(px(8.0)) + .flex() + .justify_between() + .gap_3() + .child( + div() + .text_xs() + .text_color(theme::text_muted()) + .line_clamp(1) + .child(supporting_text), + ) + .child( + div() + .text_xs() + .text_color(theme::text_muted()) + .child(short_date(run.updated_at.as_str()).to_string()), + ), + ) +} + +fn render_run_detail_column<'a>( + root: &'a AppRoot, + selected: Option<&'a domain::Run>, + cx: &'a mut Context, +) -> impl IntoElement + 'a { + div().flex_1().gap_6().when_some(selected, |container, run| { + container + .child(run_detail_panel(&root.snapshot, run)) + .child(run_trace_panel(&root.snapshot, run)) + .child(secondary_data_panel( + "Runtime events", + run_events_panel(&root.snapshot, run.id.as_str()).into_any_element(), + )) + .child(secondary_data_panel( + "Artifacts", + run_artifacts_panel(&root.snapshot, run.id.as_str()).into_any_element(), + )) + .child(run_live_controls_panel(root, cx)) + }) + .when(selected.is_none(), |container| { + container.child( + div() + .min_h(px(320.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("Select a run"), + ) + .child( + div() + .mt(px(10.0)) + .text_base() + .text_color(theme::text_muted()) + .child("Choose a run from the queue to inspect details, trace, artifacts, and operator actions."), + ), + ), + ) + }) +} + +fn runs_empty_state() -> impl IntoElement { + div() + .min_h(px(320.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("No runs yet"), + ) + .child( + div() + .mt(px(10.0)) + .text_base() + .text_color(theme::text_muted()) + .child("Runs will appear here once your company starts executing work."), + ), + ) +} + +fn run_detail_panel(snapshot: &domain::ProfileSnapshot, run: &domain::Run) -> impl IntoElement { + div() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .p_5() + .child( + div() + .text_xl() + .text_color(theme::text_primary()) + .child(run_task_title(snapshot, run)), + ) + .child( + div() + .mt(px(8.0)) + .text_sm() + .text_color(theme::text_muted()) + .child(run_subtitle(snapshot, run)), + ) + .child( + div() + .mt(px(20.0)) + .child(detail_row( + "Status", + run_status_badge(run.status.as_str()).into_any_element(), + )) + .child(detail_row( + "Agent", + text_value(run_agent_name(snapshot, run)).into_any_element(), + )) + .child(detail_row( + "Connector", + text_value(run.connector_id.clone()).into_any_element(), + )) + .when_some(run.model.clone(), |container, model| { + container.child(detail_row("Model", text_value(model).into_any_element())) + }) + .child(detail_row( + "Created", + text_value(short_date(run.created_at.as_str()).to_string()).into_any_element(), + )) + .child(detail_row( + "Started", + text_value( + run.started_at + .as_deref() + .map(short_date) + .unwrap_or("Pending") + .to_string(), + ) + .into_any_element(), + )) + .when_some(run.finished_at.as_deref(), |container, finished_at| { + container.child(detail_row( + "Finished", + text_value(short_date(finished_at).to_string()).into_any_element(), + )) + }) + .child(detail_row( + "Cost", + text_value( + run.cost_usd + .map(|value| format!("${value:.2}")) + .unwrap_or_else(|| "$0.00".to_string()), + ) + .into_any_element(), + )), + ) + .child( + div() + .mt(px(16.0)) + .rounded_lg() + .border_1() + .border_color(theme::panel_inset_border()) + .bg(theme::panel_inset()) + .p_4() + .text_sm() + .text_color(theme::text_secondary()) + .child(if run.summary.trim().is_empty() { + "No 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)) + .rounded_lg() + .border_1() + .border_color(theme::danger()) + .bg(theme::danger_bg()) + .px_4() + .py_3() + .text_sm() + .text_color(theme::danger()) + .child(error), + ) + } + }) +} + +fn run_trace_panel(snapshot: &domain::ProfileSnapshot, run: &domain::Run) -> impl IntoElement { + div() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .p_5() + .child( + div() + .text_lg() + .text_color(theme::text_primary()) + .child("Execution trace"), + ) + .child(run_timeline(snapshot, run)) +} + +fn secondary_data_panel(title: &'static str, body: gpui::AnyElement) -> impl IntoElement { + div() + .rounded_lg() + .border_1() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .p_5() + .child( + div() + .text_lg() + .text_color(theme::text_primary()) + .child(title), + ) + .child(body) +} + +fn detail_row(label: &'static str, value: gpui::AnyElement) -> impl IntoElement { + div() + .flex() + .justify_between() + .items_center() + .py_2() + .border_b_1() + .border_color(theme::panel_border()) + .child(div().text_xs().text_color(theme::text_muted()).child(label)) + .child(value) +} + +fn text_value(value: String) -> impl IntoElement { + div() + .text_sm() + .text_color(theme::text_primary()) + .child(value) } fn run_task_title(snapshot: &domain::ProfileSnapshot, run: &domain::Run) -> String { @@ -391,22 +515,6 @@ fn run_status_badge(status: &str) -> impl IntoElement { } } -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)) @@ -546,11 +654,11 @@ fn run_live_controls_panel<'a>( cx: &'a mut Context, ) -> impl IntoElement + 'a { div() - .p_5() .rounded_lg() .border_1() .border_color(theme::panel_border()) .bg(theme::panel()) + .p_5() .child( div() .text_lg() @@ -562,7 +670,7 @@ fn run_live_controls_panel<'a>( .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("Secondary runtime controls live here so the main queue/detail hierarchy stays aligned with the source product."), ) .child( div() @@ -587,7 +695,7 @@ fn run_live_controls_panel<'a>( ); })), ) - .child(root.runtime_steer_input.clone()) + .child(div().mt(px(16.0)).child(root.runtime_steer_input.clone())) .child( div() .mt(px(10.0)) @@ -597,7 +705,7 @@ fn run_live_controls_panel<'a>( this.send_runtime_steer(cx); })), ) - .child(root.runtime_follow_up_input.clone()) + .child(div().mt(px(16.0)).child(root.runtime_follow_up_input.clone())) .child( div() .mt(px(10.0)) @@ -707,7 +815,9 @@ fn run_matches_filter( && approval.state == "pending" }) }), - RunFilter::Finished => matches!(run.status.as_str(), "completed" | "failed" | "cancelled"), + RunFilter::Finished => { + matches!(run.status.as_str(), "completed" | "failed" | "cancelled") + } } } diff --git a/AgentCompany-Rust/prd.json b/AgentCompany-Rust/prd.json index 8c43f228..84f712b2 100644 --- a/AgentCompany-Rust/prd.json +++ b/AgentCompany-Rust/prd.json @@ -236,8 +236,8 @@ "cargo test --workspace passes" ], "priority": 17, - "passes": false, - "notes": "Reopened after strict source audit: current runs page keeps real runtime data but still uses custom card grammar." + "passes": true, + "notes": "Passed: native runs now restores the original operational shell more faithfully with a dedicated Run Queue panel on the left and a Run Detail column on the right. Runtime events, artifacts, and operator controls remain available but are demoted to secondary panels below the main detail hierarchy." } ] } diff --git a/AgentCompany-Rust/progress.txt b/AgentCompany-Rust/progress.txt index 984c4a68..3fb1d2e6 100644 --- a/AgentCompany-Rust/progress.txt +++ b/AgentCompany-Rust/progress.txt @@ -20,3 +20,5 @@ Started: 2026-04-19 - Completed US-015 onboarding shell remediation. Native onboarding now restores the original high-level wizard grammar: dedicated welcome screen, left hero region, fixed right-side step panel, top tab strip, and source-aligned back/continue flow while keeping the real storage-backed bootstrap path intact. - Completed US-016 dashboard topology remediation. Native overview now follows the original dashboard grammar more closely: recovery and budget banners lead, metric cards are actionable, performance is followed by budget utilization, attention rows route into the correct sections, active work exposes an All runs affordance, and recent activity remains a dedicated trailing section. + +- Completed US-017 runs shell remediation. Native runs now follow the original RunQueue + RunDetail grammar more closely: the queue is a dedicated left panel with filtering/search/list rows, the right side is a primary run detail column, and runtime events/artifacts/operator actions are preserved as secondary panels instead of dominating the main operational shell.