diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root/messages.rs b/AgentCompany-Rust/crates/app_ui/src/app_root/messages.rs index 428fb0d5..69035bf2 100644 --- a/AgentCompany-Rust/crates/app_ui/src/app_root/messages.rs +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/messages.rs @@ -7,227 +7,36 @@ use super::{ }; impl AppRoot { - pub(super) fn render_messages(&self, cx: &mut Context) -> impl IntoElement + '_ { + pub(super) fn render_messages<'a>( + &'a self, + cx: &'a mut Context, + ) -> impl IntoElement + 'a { let selected = self.state.selected_message_id.as_ref().and_then(|id| { self.snapshot .agent_messages .iter() .find(|item| &item.id == id) }); - let company_wide = self - .snapshot - .agent_messages - .iter() - .filter(|item| item.channel == "company") - .collect::>(); - let department = self - .snapshot - .agent_messages - .iter() - .filter(|item| item.channel == "department") - .collect::>(); - let direct = self - .snapshot - .agent_messages - .iter() - .filter(|item| item.channel == "direct") - .collect::>(); - let task_threads = self - .snapshot - .agent_messages - .iter() - .filter(|item| { - item.channel != "company" - && item.channel != "department" - && item.channel != "direct" - }) - .collect::>(); + let channel_groups = message_channel_groups(&self.snapshot); + let selected_channel = selected.map(message_channel_key).or_else(|| { + channel_groups + .first() + .and_then(|group| group.rows.first().map(|row| row.key.clone())) + }); + let visible_messages = selected_channel + .as_ref() + .map(|channel_key| messages_for_channel(&self.snapshot, channel_key)) + .unwrap_or_default(); div() .flex() .gap_4() - .child( - div() - .w(px(356.0)) - .p_5() - .gap_4() - .border_1() - .rounded_lg() - .border_color(theme::panel_border()) - .bg(theme::panel()) - .child(message_section_label("COMPANY-WIDE")) - .children(company_wide.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.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(render_message_sidebar( + &channel_groups, + selected_channel.as_deref(), + &self.state.selected_message_id, + cx, + )) .child( div() .flex_1() @@ -235,92 +44,438 @@ impl AppRoot { .rounded_lg() .border_color(theme::panel_border()) .bg(theme::panel()) - .child( - div() - .flex() - .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(render_message_header( + selected_channel.as_deref(), + visible_messages.len(), + )) .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)) + .when(visible_messages.is_empty(), |container| { + container.child(selected_message_card(None)) + }) + .when(!visible_messages.is_empty(), |container| { + container.children(visible_messages.iter().map(|message| { + let is_selected = self.state.selected_message_id.as_deref() + == Some(message.id.as_str()); + let message_id = message.id.clone(); + div() + .id(SharedString::from(format!( + "message-stream-{}", + message.id + ))) + .cursor_pointer() + .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(if is_selected { + selected_message_card(Some(message)).into_any_element() + } else { + compact_message_card(message).into_any_element() + }) + })) }), ) - .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), - )), - ), - ), - ), + .child(render_message_composer(self, selected, cx)), ) + .child(render_message_inspector(selected)) } } + +#[derive(Clone)] +struct ChannelRow { + key: String, + label: String, + count: usize, + urgent: bool, + rows: Vec, +} + +#[derive(Clone)] +struct ChannelGroup { + label: &'static str, + rows: Vec, +} + +fn message_channel_groups(snapshot: &domain::ProfileSnapshot) -> Vec { + let company = build_channel_group(snapshot, "COMPANY", |message| message.channel == "company"); + let department = build_channel_group(snapshot, "DEPARTMENT", |message| { + message.channel == "department" + }); + let direct = build_channel_group(snapshot, "DIRECT MESSAGES", |message| { + message.channel == "direct" + }); + let task_threads = build_channel_group(snapshot, "TASK DISCUSSIONS", |message| { + matches!(message.channel.as_str(), "project" | "incident" | "run") + }); + [company, department, direct, task_threads] + .into_iter() + .filter(|group| !group.rows.is_empty()) + .collect() +} + +fn build_channel_group( + snapshot: &domain::ProfileSnapshot, + label: &'static str, + predicate: impl Fn(&domain::AgentMessage) -> bool, +) -> ChannelGroup { + let mut channels = std::collections::BTreeMap::>::new(); + for message in snapshot + .agent_messages + .iter() + .filter(|message| predicate(message)) + { + channels + .entry(message_channel_key(message)) + .or_default() + .push(message); + } + let rows = channels + .into_iter() + .map(|(key, messages)| { + let label = channel_label(snapshot, messages.as_slice()); + let count = messages + .iter() + .filter(|message| message.read_at.is_none()) + .count(); + let urgent = messages + .iter() + .any(|message| message.priority == "urgent" && message.read_at.is_none()); + let rows = messages + .iter() + .map(|message| message.id.clone()) + .collect::>(); + ChannelRow { + key, + label, + count, + urgent, + rows, + } + }) + .collect::>(); + ChannelGroup { label, rows } +} + +fn channel_label(snapshot: &domain::ProfileSnapshot, messages: &[&domain::AgentMessage]) -> String { + let Some(first) = messages.first() else { + return "#empty".to_string(); + }; + match first.channel.as_str() { + "company" => "#company-wide".to_string(), + "department" => format!( + "#{}", + first + .channel_target_id + .clone() + .unwrap_or_else(|| "department".to_string()) + ), + "direct" => first + .to_agent_id + .as_ref() + .and_then(|agent_id| snapshot.agents.iter().find(|agent| &agent.id == agent_id)) + .map(|agent| agent.name.clone()) + .unwrap_or_else(|| "Direct".to_string()), + "project" => format!( + "#{}", + first + .channel_target_id + .clone() + .unwrap_or_else(|| "project".to_string()) + ), + "incident" => format!( + "#{}", + first + .channel_target_id + .clone() + .unwrap_or_else(|| "incident".to_string()) + ), + "run" => format!( + "Run {}", + first + .channel_target_id + .clone() + .unwrap_or_default() + .get(..8) + .unwrap_or("run") + ), + other => other.to_string(), + } +} + +fn render_message_sidebar<'a>( + groups: &'a [ChannelGroup], + active_channel: Option<&'a str>, + selected_message_id: &'a Option, + cx: &'a mut Context, +) -> impl IntoElement + 'a { + div() + .w(px(280.0)) + .p_3() + .gap_3() + .border_1() + .rounded_lg() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .children(groups.iter().map(|group| { + div() + .gap_2() + .child(message_section_label(group.label)) + .children(group.rows.iter().map(|channel| { + let active = active_channel == Some(channel.key.as_str()); + let next_message_id = channel + .rows + .first() + .cloned() + .or_else(|| selected_message_id.clone()) + .unwrap_or_default(); + div() + .id(SharedString::from(format!( + "message-channel-{}", + channel.key + ))) + .cursor_pointer() + .hover(|style| style.bg(theme::panel_hover())) + .bg(if active { + theme::selection_fill() + } else { + theme::panel() + }) + .rounded_lg() + .px_3() + .py_2() + .on_click(cx.listener(move |this, _, _, cx| { + if !next_message_id.is_empty() { + this.state.selected_message_id = Some(next_message_id.clone()); + } + cx.notify(); + })) + .child( + div() + .flex() + .items_center() + .gap_3() + .child( + div() + .w(px(28.0)) + .h(px(28.0)) + .rounded_lg() + .bg(theme::panel_muted()), + ) + .child( + div().flex_1().child( + div() + .text_sm() + .text_color(if active { + theme::sidebar_text_active() + } else { + theme::text_secondary() + }) + .truncate() + .child(channel.label.clone()), + ), + ) + .when(channel.urgent, |container| { + container.child( + div() + .w(px(8.0)) + .h(px(8.0)) + .rounded_full() + .bg(theme::danger()), + ) + }) + .when(channel.count > 0, |container| { + container.child( + div() + .px_2() + .py_1() + .rounded_full() + .bg(theme::primary_action()) + .text_xs() + .text_color(theme::primary_action_text()) + .child(channel.count.to_string()), + ) + }), + ) + })) + })) + .child( + div() + .flex() + .justify_between() + .text_xs() + .text_color(theme::text_muted()) + .child(format!( + "{} channels", + groups.iter().map(|group| group.rows.len()).sum::() + )) + .child("message graph"), + ) +} + +fn render_message_header(active_channel: Option<&str>, visible_count: usize) -> impl IntoElement { + div() + .flex() + .justify_between() + .items_center() + .px_5() + .py_4() + .border_b_1() + .border_color(theme::panel_border()) + .child( + div() + .child( + div() + .text_lg() + .text_color(theme::text_primary()) + .child(active_channel.unwrap_or("Select a channel").to_string()), + ) + .child( + div() + .mt(px(4.0)) + .text_sm() + .text_color(theme::text_muted()) + .child(format!("{} visible items", visible_count)), + ), + ) + .child( + div() + .flex() + .gap_3() + .child(search_shell("Search messages")) + .child(filter_shell()), + ) +} + +fn render_message_composer<'a>( + root: &'a AppRoot, + selected: Option<&'a domain::AgentMessage>, + cx: &'a mut Context, +) -> impl IntoElement + 'a { + 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(root.message_input.clone()), + ) + .child( + div() + .mt(px(10.0)) + .flex() + .justify_between() + .items_center() + .child( + div() + .text_xs() + .text_color(theme::text_muted()) + .child("Reply, mention, or route an operational update."), + ) + .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) + })), + ), + ) +} + +fn render_message_inspector(selected: Option<&domain::AgentMessage>) -> impl IntoElement { + div() + .w(px(320.0)) + .border_1() + .rounded_lg() + .border_color(theme::panel_border()) + .bg(theme::panel()) + .p_5() + .child( + div() + .text_lg() + .text_color(theme::text_primary()) + .child("Thread inspector"), + ) + .child(selected_message_card(selected)) + .when_some(secondary_message(selected), |container, message| { + container.child(compact_message_card(message)) + }) +} + +fn message_channel_key(message: &domain::AgentMessage) -> String { + match message.channel.as_str() { + "company" => "company-all".to_string(), + "department" => format!( + "department-{}", + message + .channel_target_id + .clone() + .unwrap_or_else(|| "general".to_string()) + ), + "direct" => { + let to = message + .to_agent_id + .clone() + .unwrap_or_else(|| "broadcast".to_string()); + format!("direct-{}-{}", message.from_agent_id, to) + } + "project" => format!( + "project-{}", + message + .channel_target_id + .clone() + .unwrap_or_else(|| "general".to_string()) + ), + "incident" => format!( + "incident-{}", + message + .channel_target_id + .clone() + .unwrap_or_else(|| "room".to_string()) + ), + "run" => format!( + "run-{}", + message + .channel_target_id + .clone() + .unwrap_or_else(|| "run".to_string()) + ), + other => format!("{}-misc", other), + } +} + +fn messages_for_channel<'a>( + snapshot: &'a domain::ProfileSnapshot, + channel_key: &str, +) -> Vec<&'a domain::AgentMessage> { + snapshot + .agent_messages + .iter() + .filter(|message| message_channel_key(message) == channel_key) + .collect::>() +} diff --git a/AgentCompany-Rust/prd.json b/AgentCompany-Rust/prd.json index de120745..5905eccc 100644 --- a/AgentCompany-Rust/prd.json +++ b/AgentCompany-Rust/prd.json @@ -183,7 +183,7 @@ ], "priority": 13, "passes": false, - "notes": "In progress: native Document Center now restores a two-section grammar with documents prose plus work products (sourced from persisted artifacts), instead of the earlier flat list plus quick draft shell. Remaining gap is finer editor/message parity and final source-to-native audit." + "notes": "In progress: native Document Center now separates documents prose and work products using persisted artifacts. Messages now restores a more source-like communication shell with grouped channels, unread badges, a channel header, a message stream, a composer area, and a side inspector. Remaining work is the final source-to-native parity audit and any last micro-gaps it surfaces." }, { "id": "US-014", @@ -197,7 +197,7 @@ ], "priority": 14, "passes": false, - "notes": "Created after post-completion audit found significant remaining UI grammar gaps." + "notes": "Audit not yet finalized. The post-completion remediation cycle has now rebuilt onboarding, dashboard, inbox, approvals, runs, documents, and messages toward source grammar. Next step is a final explicit audit pass to decide which remaining deviations are still material." } ] } diff --git a/AgentCompany-Rust/progress.txt b/AgentCompany-Rust/progress.txt index 770d32c3..64f1e8c4 100644 --- a/AgentCompany-Rust/progress.txt +++ b/AgentCompany-Rust/progress.txt @@ -14,4 +14,4 @@ Started: 2026-04-19 - Added runtime_contract_snapshot_e2e.rs to verify runtime event and artifact persistence across reopen. - Runtime contract integration advanced: RuntimeEvent ingestion now persists message/tool/artifact/run-finished events into storage and exposes them in the runs inspector. - Wired a live native runtime session into the GPUI Runs surface. Selected runs now launch Pi Mono through env-configured stdio sidecar settings, runtime tool calls create approval rows, approval decisions send ToolApproval back into the live session, runtime messages project into agent_messages, and run lifecycle still persists into run_events/artifacts/runs.\n- Closed the final native verification matrix. The workspace now has explicit storage-backed e2e coverage for onboarding bootstrap, approvals/documents/runs visibility, settings/workspace mutations, live runtime contract flows, and inbox attention derivation.\n- Reopened Ralph parity work after a direct source audit. The current native app has real runtime/data flows, but several surfaces still diverge from Electron page grammar: onboarding is too collapsed, dashboard misses banner/attention/chart structure, inbox filters are not real, run detail is dominated by native control slabs not present in source, approvals flatten board/user/manager semantics, and documents still lack work-product/editor hierarchy. Added US-010 through US-014 to close these parity gaps honestly. -- Rebuilt native onboarding away from the old static split shell. It now has a real multi-stage company/mission/launch flow, screenshot-aligned narrative/summary composition, real connector readiness derived from snapshot data, and a storage bootstrap transaction that creates company, workspace, goal, CEO, kickoff project, kickoff task, and queued run. Remaining US-010 work is the finer original wizard-tab parity.\n- 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- Restored document-center grammar toward the source surface: the native page now separates documents prose from work products and uses persisted artifacts as the work-product list instead of hiding them behind the runs inspector only.\n \ No newline at end of file +- Rebuilt native onboarding away from the old static split shell. It now has a real multi-stage company/mission/launch flow, screenshot-aligned narrative/summary composition, real connector readiness derived from snapshot data, and a storage bootstrap transaction that creates company, workspace, goal, CEO, kickoff project, kickoff task, and queued run. Remaining US-010 work is the finer original wizard-tab parity.\n- Rebuilt the native dashboard away from the placeholder metrics/activity shell. It now has real recovery and budget banners, attention rows, a performance section, active work, and recent activity, all driven by persisted local data. Remaining US-011 work is the inbox-side parity and finer dashboard detail matching.\n- 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- Restored document-center grammar toward the source surface: the native page now separates documents prose from work products and uses persisted artifacts as the work-product list instead of hiding them behind the runs inspector only.\n- Restored the communication shell toward the source product grammar: grouped channels, unread badges, channel header, message stream, composer, and side inspector. Combined with the earlier document-center rewrite, the remaining work is now the final parity audit rather than major page-grammar gaps.\n \ No newline at end of file