Refine GPUI shell UX surfaces

This commit is contained in:
ZacharyZhang-NY committed 2026-04-19 13:32:58 -04:00
1 parent 22ba7a9004
commit 6809df15de
7 files changed
+617 -122

No files matched your search

+45 -14
View File
@@ -14,6 +14,7 @@ mod approvals;
mod documents;
mod inbox;
mod messages;
mod messages_parts;
mod onboarding;
mod overview;
mod runs;
@@ -267,7 +268,7 @@ impl Render for AppRoot {
div()
.size_full()
.flex()
.bg(theme::canvas())
.bg(theme::window_bg())
.child(self.render_sidebar(cx))
.child(
div()
@@ -276,30 +277,60 @@ impl Render for AppRoot {
.flex_col()
.child(
div()
.px_6()
.py_4()
.px_8()
.py_5()
.border_b_1()
.border_color(theme::panel_border())
.bg(theme::shell_header())
.child(
div()
.text_xl()
.text_color(theme::text_primary())
.child(header),
)
.child(div().text_sm().text_color(theme::text_muted()).child(
self.state.status_line.clone().unwrap_or_else(|| {
"Native Rust shell is reading the shared local profile."
.to_string()
}),
)),
.flex()
.items_center()
.justify_between()
.child(
div()
.child(
div()
.text_3xl()
.text_color(theme::text_primary())
.child(self.state.section.label()),
)
.child(
div()
.text_sm()
.text_color(theme::text_muted())
.child(header),
)
.child(
div()
.text_sm()
.text_color(theme::text_subtle())
.child(self.state.status_line.clone().unwrap_or_else(|| {
"Native Rust shell is reading the shared local profile."
.to_string()
})),
),
)
.child(
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("Show console"),
),
),
)
.child(
div()
.id("main-scroll")
.flex_1()
.overflow_scroll()
.p_6()
.p_8()
.child(match self.state.section {
SectionId::Overview => self.render_overview().into_any_element(),
SectionId::Runs => self.render_runs(cx).into_any_element(),
@@ -1,4 +1,10 @@
use super::*;
use super::{
messages_parts::{
compact_message_card, composer_chip, composer_subject, filter_shell, message_section_label,
search_shell, secondary_message, selected_message_card, selected_recipient,
},
*,
};
impl AppRoot {
pub(super) fn render_messages(&self, cx: &mut Context<Self>) -> impl IntoElement + '_ {
@@ -8,36 +14,60 @@ impl AppRoot {
.iter()
.find(|item| &item.id == id)
});
let selected_subject = selected
.map(|item| item.subject.clone())
.unwrap_or_else(|| "Select a thread".to_string());
let selected_body = selected
.map(|item| item.body.clone())
.unwrap_or_else(|| "No message selected.".to_string());
let company_wide = self
.snapshot
.agent_messages
.iter()
.filter(|item| item.channel == "company")
.collect::<Vec<_>>();
let department = self
.snapshot
.agent_messages
.iter()
.filter(|item| item.channel == "department")
.collect::<Vec<_>>();
let direct = self
.snapshot
.agent_messages
.iter()
.filter(|item| item.channel == "direct")
.collect::<Vec<_>>();
let task_threads = self
.snapshot
.agent_messages
.iter()
.filter(|item| {
item.channel != "company"
&& item.channel != "department"
&& item.channel != "direct"
})
.collect::<Vec<_>>();
div()
.flex()
.gap_4()
.child(
div()
.id("messages-list")
.w(px(320.0))
.p_4()
.gap_2()
.w(px(356.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("Channels"))
.children(self.snapshot.agent_messages.iter().map(|item| {
.child(message_section_label("COMPANY-WIDE"))
.children(company_wide.iter().map(|message| {
let active =
self.state.selected_message_id.as_deref() == Some(item.id.as_str());
let message_id = item.id.clone();
self.state.selected_message_id.as_deref() == Some(message.id.as_str());
let message_id = message.id.clone();
div()
.id(SharedString::from(format!("message-{}", item.id)))
.px_3()
.py_2()
.rounded_md()
.id(SharedString::from(format!("message-{}", message.id)))
.flex()
.justify_between()
.items_center()
.px_4()
.py_3()
.rounded_lg()
.cursor_pointer()
.bg(if active {
theme::selection_fill()
@@ -49,46 +79,247 @@ impl AppRoot {
this.state.selected_message_id = Some(message_id.clone());
cx.notify();
}))
.child(format!("{} · {}", item.subject, item.priority))
})),
.child(
div()
.text_sm()
.text_color(if active {
theme::sidebar_text_active()
} else {
theme::text_secondary()
})
.truncate()
.child(message.subject.clone()),
)
.when(message.priority == "urgent", |container| {
container.child(
div()
.w(px(10.0))
.h(px(10.0))
.rounded_full()
.bg(theme::danger()),
)
})
}))
.child(message_section_label("DEPARTMENT"))
.children(department.iter().map(|message| {
let active =
self.state.selected_message_id.as_deref() == Some(message.id.as_str());
let message_id = message.id.clone();
div()
.id(SharedString::from(format!("message-{}", message.id)))
.flex()
.justify_between()
.items_center()
.px_4()
.py_3()
.rounded_lg()
.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_message_id = Some(message_id.clone());
cx.notify();
}))
.child(
div()
.text_sm()
.text_color(if active {
theme::sidebar_text_active()
} else {
theme::text_secondary()
})
.truncate()
.child(message.subject.clone()),
)
}))
.child(message_section_label("DIRECT MESSAGES"))
.children(direct.iter().map(|message| {
let active =
self.state.selected_message_id.as_deref() == Some(message.id.as_str());
let message_id = message.id.clone();
div()
.id(SharedString::from(format!("message-{}", message.id)))
.flex()
.justify_between()
.items_center()
.px_4()
.py_3()
.rounded_lg()
.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_message_id = Some(message_id.clone());
cx.notify();
}))
.child(
div()
.text_sm()
.text_color(if active {
theme::sidebar_text_active()
} else {
theme::text_secondary()
})
.truncate()
.child(message.subject.clone()),
)
.when(message.read_at.is_none(), |container| {
container.child(
div()
.w(px(10.0))
.h(px(10.0))
.rounded_full()
.bg(theme::danger()),
)
})
}))
.child(message_section_label("TASK DISCUSSIONS"))
.children(task_threads.iter().map(|message| {
let active =
self.state.selected_message_id.as_deref() == Some(message.id.as_str());
let message_id = message.id.clone();
div()
.id(SharedString::from(format!("message-{}", message.id)))
.flex()
.justify_between()
.items_center()
.px_4()
.py_3()
.rounded_lg()
.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_message_id = Some(message_id.clone());
cx.notify();
}))
.child(
div()
.text_sm()
.text_color(if active {
theme::sidebar_text_active()
} else {
theme::text_secondary()
})
.truncate()
.child(message.subject.clone()),
)
}))
.child(div().flex_1())
.child(
div()
.flex()
.justify_between()
.text_xs()
.text_color(theme::text_muted())
.child(format!("{} channels", self.snapshot.agent_messages.len()))
.child(format!("{} agents", self.snapshot.agents.len())),
),
)
.child(
div()
.flex_1()
.p_4()
.gap_3()
.border_1()
.rounded_md()
.rounded_lg()
.border_color(theme::panel_border())
.bg(theme::panel())
.child(div().text_lg().child("Conversation"))
.child(
div()
.text_sm()
.text_color(theme::text_muted())
.child(selected_subject),
)
.child(
div()
.min_h(px(220.0))
.p_3()
.border_1()
.border_color(theme::panel_inset_border())
.rounded_md()
.bg(theme::panel_inset())
.child(selected_body),
)
.child(self.message_input.clone())
.child(
div()
.flex()
.gap_2()
.child(primary_button("Send update", cx, |this, cx| {
this.send_message(cx)
}))
.child(secondary_button("Mark read", cx, |this, cx| {
this.mark_selected_message_read(cx)
})),
.justify_between()
.items_center()
.px_5()
.py_4()
.border_b_1()
.border_color(theme::panel_border())
.child(
div().text_lg().text_color(theme::text_primary()).child(
selected
.map(|message| message.subject.clone())
.unwrap_or_else(|| "Select a conversation".to_string()),
),
)
.child(
div()
.flex()
.gap_3()
.child(search_shell("Search messages"))
.child(filter_shell()),
),
)
.child(
div()
.id("messages-stream")
.flex_1()
.p_4()
.overflow_scroll()
.child(selected_message_card(selected))
.when_some(secondary_message(selected), |container, message| {
container.child(compact_message_card(message))
}),
)
.child(
div()
.px_4()
.py_3()
.border_t_1()
.border_color(theme::panel_border())
.bg(theme::panel())
.child(
div()
.flex()
.gap_3()
.child(composer_chip(selected_recipient(selected).as_str()))
.child(composer_subject(selected)),
)
.child(
div()
.mt(px(12.0))
.p_3()
.rounded_lg()
.bg(theme::panel_inset())
.border_1()
.border_color(theme::panel_inset_border())
.child(self.message_input.clone()),
)
.child(
div()
.mt(px(10.0))
.flex()
.justify_between()
.items_center()
.child(
div()
.text_xs()
.text_color(theme::text_muted())
.child("Press Cmd+Enter to send"),
)
.child(
div()
.flex()
.gap_2()
.child(primary_button("Send update", cx, |this, cx| {
this.send_message(cx)
}))
.child(secondary_button(
"Mark read",
cx,
|this, cx| this.mark_selected_message_read(cx),
)),
),
),
),
)
}
@@ -0,0 +1,217 @@
use super::*;
pub(super) fn message_section_label(label: &'static str) -> impl IntoElement {
div().text_xs().text_color(theme::text_muted()).child(label)
}
pub(super) fn selected_message_card(message: Option<&domain::AgentMessage>) -> impl IntoElement {
let subject = message
.map(|item| item.subject.clone())
.unwrap_or_else(|| "No message selected".to_string());
let body = message.map(|item| item.body.clone()).unwrap_or_else(|| {
"Choose a company channel, direct thread, or task discussion from the left rail."
.to_string()
});
div()
.p_5()
.rounded_lg()
.border_1()
.border_color(theme::panel_border())
.bg(theme::panel())
.child(message_header(message, false))
.child(
div()
.mt(px(16.0))
.text_lg()
.text_color(theme::text_primary())
.child(subject),
)
.child(
div()
.mt(px(12.0))
.text_base()
.text_color(theme::text_secondary())
.child(body),
)
.child(
div()
.mt(px(16.0))
.text_sm()
.text_color(theme::text_muted())
.child("↩ Reply"),
)
}
pub(super) fn compact_message_card(message: &domain::AgentMessage) -> impl IntoElement {
div()
.mt(px(16.0))
.p_5()
.rounded_lg()
.border_1()
.border_color(theme::panel_border())
.bg(theme::panel())
.child(message_header(Some(message), true))
.child(
div()
.mt(px(12.0))
.text_base()
.text_color(theme::text_primary())
.child(message.subject.clone()),
)
.child(
div()
.mt(px(8.0))
.text_sm()
.text_color(theme::text_muted())
.line_clamp(2)
.child(message.body.clone()),
)
}
fn message_header(message: Option<&domain::AgentMessage>, urgent: bool) -> impl IntoElement {
let sender = message
.map(|item| trim_sender(item.from_agent_id.as_str()))
.unwrap_or("AC");
let timestamp = message
.and_then(|item| item.created_at.get(11..16))
.unwrap_or("--:--")
.to_string();
div()
.flex()
.items_center()
.gap_3()
.child(
div()
.w(px(44.0))
.h(px(44.0))
.rounded_full()
.bg(theme::accent_orange_soft())
.text_color(theme::accent_orange())
.flex()
.items_center()
.justify_center()
.child(sender.to_string()),
)
.child(
div()
.flex()
.gap_2()
.items_center()
.child(
div().text_sm().text_color(theme::accent_orange()).child(
message
.map(|item| item.sender_kind.clone())
.unwrap_or_else(|| "human".to_string()),
),
)
.child(
div().text_sm().text_color(theme::text_muted()).child(
message
.map(|item| item.channel.clone())
.unwrap_or_else(|| "company".to_string()),
),
)
.child(
div()
.px_2()
.py_1()
.rounded_full()
.bg(theme::accent_orange_soft())
.text_xs()
.text_color(theme::accent_orange())
.child("OPERATIONS"),
)
.child(
div()
.text_sm()
.text_color(theme::text_muted())
.child(timestamp),
)
.when(urgent, |container| {
container.child(
div()
.px_2()
.py_1()
.rounded_full()
.bg(theme::danger_bg())
.text_xs()
.text_color(theme::danger())
.child("URGENT"),
)
}),
)
}
pub(super) fn secondary_message(
selected: Option<&domain::AgentMessage>,
) -> Option<&domain::AgentMessage> {
selected
}
fn trim_sender(sender: &str) -> &str {
sender.get(..2).unwrap_or(sender)
}
pub(super) fn search_shell(label: &'static str) -> impl IntoElement {
div()
.w(px(240.0))
.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)
}
pub(super) fn 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("Filters")
}
pub(super) fn composer_chip(label: &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_secondary())
.child(label.to_string())
}
pub(super) fn composer_subject(message: Option<&domain::AgentMessage>) -> impl IntoElement {
div()
.flex_1()
.px_4()
.py_3()
.rounded_lg()
.border_1()
.border_color(theme::panel_border())
.bg(theme::panel())
.text_sm()
.text_color(theme::text_subtle())
.child(
message
.map(|item| item.subject.clone())
.unwrap_or_else(|| "Subject (optional)".to_string()),
)
}
pub(super) fn selected_recipient(message: Option<&domain::AgentMessage>) -> String {
message
.and_then(|item| item.to_agent_id.clone())
.unwrap_or_else(|| "CEO (CEO)".to_string())
}
@@ -130,7 +130,12 @@ fn eyebrow(label: &'static str) -> impl IntoElement {
.gap_3()
.items_center()
.child(div().w(px(48.0)).h(px(1.0)).bg(theme::text_subtle()))
.child(div().text_xs().text_color(theme::text_subtle()).child(label))
.child(
div()
.text_xs()
.text_color(theme::text_subtle())
.child(label),
)
}
fn onboarding_steps() -> impl IntoElement {
@@ -168,7 +173,12 @@ fn readiness_panel() -> impl IntoElement {
div()
.w(px(520.0))
.gap_3()
.child(div().text_xs().text_color(theme::text_muted()).child("CONNECTOR READINESS"))
.child(
div()
.text_xs()
.text_color(theme::text_muted())
.child("CONNECTOR READINESS"),
)
.child(readiness_row("Claude Code", "ready", true))
.child(readiness_row("Codex", "ready", true))
.child(readiness_row("Gemini CLI", "auth required", false))
@@ -186,18 +196,17 @@ fn readiness_row(label: &'static str, state: &'static str, ready: bool) -> impl
.flex()
.gap_3()
.items_center()
.child(div().w(px(16.0)).h(px(16.0)).rounded_full().bg(if ready {
theme::accent_teal()
} else {
theme::accent_orange()
}))
.child(
div()
.w(px(16.0))
.h(px(16.0))
.rounded_full()
.bg(if ready {
theme::accent_teal()
} else {
theme::accent_orange()
}),
.text_sm()
.text_color(theme::text_primary())
.child(label),
)
.child(div().text_sm().text_color(theme::text_primary()).child(label))
.child(div().text_sm().text_color(theme::text_muted()).child(state))
}
@@ -225,7 +234,12 @@ fn launch_tile(label: &'static str, value: &'static str) -> impl IntoElement {
.rounded_lg()
.bg(theme::panel_muted())
.child(div().text_xs().text_color(theme::text_muted()).child(label))
.child(div().text_sm().text_color(theme::text_primary()).child(value))
.child(
div()
.text_sm()
.text_color(theme::text_primary())
.child(value),
)
}
fn rgb_hex(hex: u32) -> gpui::Hsla {
@@ -43,9 +43,15 @@ impl AppRoot {
.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(format!(
"{} tasks in progress",
self.snapshot.active_tasks()
))
.child(div().child("•"))
.child(format!("{} completed", completed_task_count(&self.snapshot))),
.child(format!(
"{} completed",
completed_task_count(&self.snapshot)
)),
),
)
.child(
@@ -149,7 +155,11 @@ impl AppRoot {
div()
.text_base()
.text_color(theme::text_primary())
.child(format!("{} {}", item.actor, item.action.replace('_', " "))),
.child(format!(
"{} {}",
item.actor,
item.action.replace('_', " ")
)),
)
.child(
div()
@@ -192,12 +202,7 @@ fn section_header(label: &'static str, action: Option<&'static str>) -> impl Int
.flex()
.items_center()
.justify_between()
.child(
div()
.text_xs()
.text_color(theme::text_muted())
.child(label),
)
.child(div().text_xs().text_color(theme::text_muted()).child(label))
.when_some(action, |container, action_label| {
container.child(
div()
@@ -223,14 +228,13 @@ fn metric_panel(
.rounded_lg()
.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().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(div().text_sm().text_color(theme::text_muted()).child(detail))
.child(
div()
.text_sm()
.text_color(theme::text_muted())
.child(detail),
)
}
@@ -1,4 +1,5 @@
use super::*;
use gpui::Window;
impl AppRoot {
pub(super) fn render_sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement + '_ {
@@ -7,7 +8,10 @@ impl AppRoot {
(SectionId::Runs, Some(self.snapshot.active_runs())),
(SectionId::Inbox, Some(self.snapshot.active_tasks())),
(SectionId::Messages, Some(self.snapshot.unread_messages())),
(SectionId::Approvals, Some(self.snapshot.pending_approvals())),
(
SectionId::Approvals,
Some(self.snapshot.pending_approvals()),
),
(SectionId::Documents, None),
(SectionId::Workspaces, None),
];
@@ -57,15 +61,12 @@ impl AppRoot {
.border_color(theme::panel_border())
.bg(theme::panel())
.child(
div()
.text_sm()
.text_color(theme::text_primary())
.child(
self.snapshot
.current_company()
.map(|company| company.name.clone())
.unwrap_or_else(|| "AgentCompany".to_string()),
),
div().text_sm().text_color(theme::text_primary()).child(
self.snapshot
.current_company()
.map(|company| company.name.clone())
.unwrap_or_else(|| "AgentCompany".to_string()),
),
)
.child(
div()
@@ -91,7 +92,8 @@ impl AppRoot {
),
)
.children(self.snapshot.companies.iter().map(|company| {
let active = self.snapshot.current_company_id.as_deref() == Some(company.id.as_str());
let active =
self.snapshot.current_company_id.as_deref() == Some(company.id.as_str());
let company_id = company.id.clone();
div()
.id(SharedString::from(format!("company-{}", company.id)))
@@ -189,10 +191,15 @@ impl AppRoot {
}))
.child(div().flex_1())
.child(div().h(px(1.0)).bg(theme::sidebar_divider()))
.child(sidebar_footer_row("Settings", self.state.section == SectionId::Settings, cx, |this, cx| {
this.state.section = SectionId::Settings;
cx.notify();
}))
.child(sidebar_footer_row(
"Settings",
self.state.section == SectionId::Settings,
cx,
|this, cx| {
this.state.section = SectionId::Settings;
cx.notify();
},
))
.child(
div()
.px_4()
@@ -223,6 +230,7 @@ where
V: 'static,
{
div()
.id(SharedString::from(format!("sidebar-footer-{label}")))
.px_4()
.py_3()
.rounded_lg()
@@ -238,7 +246,9 @@ where
theme::sidebar_text()
})
.hover(|style| style.bg(theme::sidebar_hover()))
.on_click(cx.listener(move |this, _, _, cx| on_click(this, cx)))
.on_click(
cx.listener(move |this, _: &gpui::ClickEvent, _: &mut Window, cx| on_click(this, cx)),
)
.child(label)
}
@@ -4,10 +4,6 @@ pub fn window_bg() -> Hsla {
rgb(0xf7f8fb).into()
}
pub fn canvas() -> Hsla {
rgb(0xf7f8fb).into()
}
pub fn shell_header() -> Hsla {
rgb(0xffffff).into()
}
@@ -108,18 +104,10 @@ pub fn sidebar_bg() -> Hsla {
rgb(0xffffff).into()
}
pub fn sidebar_surface() -> Hsla {
rgb(0xffffff).into()
}
pub fn sidebar_hover() -> Hsla {
rgb(0xf4f6fa).into()
}
pub fn sidebar_surface_hover() -> Hsla {
rgb(0xf4f6fa).into()
}
pub fn sidebar_divider() -> Hsla {
rgb(0xe7eaf0).into()
}