Restore native operational surface grammar
This commit is contained in:
1 parent
edbebe6109
commit
bd8c420718
8 files changed
+633
-352
No files matched your search
@@ -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};
|
||||
@@ -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<String>,
|
||||
pub onboarding_execution_mode: &'static str,
|
||||
pub inbox_filter: InboxFilter,
|
||||
pub status_line: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -2,231 +2,344 @@ use super::*;
|
||||
|
||||
impl AppRoot {
|
||||
pub(super) fn render_approvals(&self, cx: &mut Context<Self>) -> 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<Self>) -> impl IntoElement + '_ {
|
||||
let pending = pending_approvals(&self.snapshot).collect::<Vec<_>>();
|
||||
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<Self>,
|
||||
) -> 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<Item = &'a domain::Task>,
|
||||
) -> impl IntoElement {
|
||||
let rows = tasks.collect::<Vec<_>>();
|
||||
struct ReviewGroups<'a> {
|
||||
board: Vec<ReviewRow<'a>>,
|
||||
user: Vec<ReviewRow<'a>>,
|
||||
manager: Vec<ReviewRow<'a>>,
|
||||
}
|
||||
|
||||
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::<Vec<_>>();
|
||||
|
||||
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(
|
||||
|
||||
@@ -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<Self>) -> 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<AppRoot>) -> 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<InboxRow> {
|
||||
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<AppRoot>,
|
||||
) -> 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<AppRoot>,
|
||||
) -> 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<Item = gpui::Div>) -> 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),
|
||||
)
|
||||
}
|
||||
@@ -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<AppRoot>,
|
||||
) -> 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()
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
- 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
|
||||
Reference in new issue
Block a user