Restore native communication shell grammar

This commit is contained in:
ZacharyZhang-NY committed 2026-04-19 16:06:28 -04:00
1 parent 95c539380b
commit ac9be2b639
3 files changed
+447 -292

No files matched your search

@@ -7,227 +7,36 @@ use super::{
};
impl AppRoot {
pub(super) fn render_messages(&self, cx: &mut Context<Self>) -> impl IntoElement + '_ {
pub(super) fn render_messages<'a>(
&'a self,
cx: &'a mut Context<Self>,
) -> 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::<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<_>>();
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<String>,
}
#[derive(Clone)]
struct ChannelGroup {
label: &'static str,
rows: Vec<ChannelRow>,
}
fn message_channel_groups(snapshot: &domain::ProfileSnapshot) -> Vec<ChannelGroup> {
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::<String, Vec<&domain::AgentMessage>>::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::<Vec<_>>();
ChannelRow {
key,
label,
count,
urgent,
rows,
}
})
.collect::<Vec<_>>();
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<String>,
cx: &'a mut Context<AppRoot>,
) -> 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::<usize>()
))
.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<AppRoot>,
) -> 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::<Vec<_>>()
}
+2 -2
View File
@@ -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."
}
]
}
+1 -1
View File
@@ -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
- 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