From 5097591725353ddac9311489c0828c53a52ed0b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 19 Apr 2026 01:32:40 -0400 Subject: [PATCH] refactor: split native app root by screen --- .../crates/app_ui/src/app_root.rs | 723 +----------------- .../crates/app_ui/src/app_root/approvals.rs | 97 +++ .../crates/app_ui/src/app_root/documents.rs | 84 ++ .../crates/app_ui/src/app_root/inbox.rs | 57 ++ .../crates/app_ui/src/app_root/messages.rs | 91 +++ .../crates/app_ui/src/app_root/onboarding.rs | 38 + .../crates/app_ui/src/app_root/overview.rs | 80 ++ .../crates/app_ui/src/app_root/runs.rs | 152 ++++ .../crates/app_ui/src/app_root/settings.rs | 34 + .../crates/app_ui/src/app_root/sidebar.rs | 64 ++ .../crates/app_ui/src/app_root/workspaces.rs | 45 ++ .../2026-04-19-native-gpui-rewrite-plan.md | 211 +++++ 12 files changed, 964 insertions(+), 712 deletions(-) create mode 100644 AgentCompany-Rust/crates/app_ui/src/app_root/approvals.rs create mode 100644 AgentCompany-Rust/crates/app_ui/src/app_root/documents.rs create mode 100644 AgentCompany-Rust/crates/app_ui/src/app_root/inbox.rs create mode 100644 AgentCompany-Rust/crates/app_ui/src/app_root/messages.rs create mode 100644 AgentCompany-Rust/crates/app_ui/src/app_root/onboarding.rs create mode 100644 AgentCompany-Rust/crates/app_ui/src/app_root/overview.rs create mode 100644 AgentCompany-Rust/crates/app_ui/src/app_root/runs.rs create mode 100644 AgentCompany-Rust/crates/app_ui/src/app_root/settings.rs create mode 100644 AgentCompany-Rust/crates/app_ui/src/app_root/sidebar.rs create mode 100644 AgentCompany-Rust/crates/app_ui/src/app_root/workspaces.rs create mode 100644 AgentCompany-Rust/docs/plans/2026-04-19-native-gpui-rewrite-plan.md diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root.rs b/AgentCompany-Rust/crates/app_ui/src/app_root.rs index ca5cd28d..98bb0c8c 100644 --- a/AgentCompany-Rust/crates/app_ui/src/app_root.rs +++ b/AgentCompany-Rust/crates/app_ui/src/app_root.rs @@ -10,6 +10,17 @@ use domain::{ApprovalState, ProfileSnapshot, SectionId}; use gpui::{App, Context, Entity, SharedString, Window, div, prelude::*, px, rgb}; use storage::NewCompanyInput; +mod approvals; +mod documents; +mod inbox; +mod messages; +mod onboarding; +mod overview; +mod runs; +mod settings; +mod sidebar; +mod workspaces; + pub struct AppRoot { store: SharedStore, snapshot: ProfileSnapshot, @@ -229,718 +240,6 @@ impl AppRoot { .child(div().text_3xl().text_color(rgb(0x171411)).child(value)) .child(div().text_sm().text_color(rgb(0x6f665d)).child(detail)) } - - fn render_sidebar(&self, cx: &mut Context) -> impl IntoElement + '_ { - let sections = [ - SectionId::Overview, - SectionId::Runs, - SectionId::Inbox, - SectionId::Messages, - SectionId::Approvals, - SectionId::Documents, - SectionId::Workspaces, - SectionId::Settings, - ]; - div() - .w(px(208.0)) - .h_full() - .p_4() - .gap_4() - .flex() - .flex_col() - .bg(rgb(0x161514)) - .text_color(rgb(0xf5eee4)) - .child(div().text_lg().child("AgentCompany")) - .children(self.snapshot.companies.iter().map(|company| { - 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))) - .px_3() - .py_2() - .rounded_md() - .cursor_pointer() - .bg(if active { rgb(0x9e3d2d) } else { rgb(0x211d1a) }) - .hover(|style| style.bg(rgb(0x362c27))) - .on_click(cx.listener(move |this, _, _, cx| { - this.switch_company(company_id.clone(), cx) - })) - .child(company.name.clone()) - })) - .child(div().h(px(1.0)).bg(rgb(0x2f2924))) - .children(sections.into_iter().map(|section| { - let active = self.state.section == section; - div() - .id(SharedString::from(format!( - "section-{}", - section.label().to_lowercase() - ))) - .px_3() - .py_2() - .rounded_md() - .cursor_pointer() - .bg(if active { rgb(0xefe2d4) } else { rgb(0x161514) }) - .text_color(if active { rgb(0x171411) } else { rgb(0xf5eee4) }) - .hover(|style| style.bg(rgb(0x2c2825))) - .on_click(cx.listener(move |this, _, _, cx| { - this.state.section = section; - cx.notify(); - })) - .child(section.label()) - })) - } - - fn render_overview(&self) -> impl IntoElement + '_ { - let company = self.snapshot.current_company(); - let company_name = company - .map(|item| item.name.clone()) - .unwrap_or_else(|| "No company".to_string()); - div() - .flex() - .flex_col() - .gap_4() - .child( - div() - .flex() - .gap_4() - .child(self.metric_card( - "Active tasks", - self.snapshot.active_tasks().to_string(), - "Tasks in todo, review, or execution", - )) - .child(self.metric_card( - "Pending approvals", - self.snapshot.pending_approvals().to_string(), - "Human review gates", - )) - .child(self.metric_card( - "Unread messages", - self.snapshot.unread_messages().to_string(), - "Company and direct channels", - )) - .child(self.metric_card( - "Active runs", - self.snapshot.active_runs().to_string(), - "Queued, running, and waiting runs", - )), - ) - .child( - div() - .flex() - .gap_4() - .child( - div() - .flex_1() - .p_4() - .gap_2() - .border_1() - .rounded_md() - .border_color(rgb(0xd9cdbf)) - .bg(rgb(0xfffbf5)) - .child(div().text_lg().child(company_name)) - .children(self.snapshot.tasks.iter().take(6).map(|task| { - div() - .py_2() - .border_b_1() - .border_color(rgb(0xeee3d7)) - .child(format!("{} · {}", task.status, task.title)) - })), - ) - .child( - div() - .flex_1() - .p_4() - .gap_2() - .border_1() - .rounded_md() - .border_color(rgb(0xd9cdbf)) - .bg(rgb(0xfffbf5)) - .child(div().text_lg().child("Recent activity")) - .children(self.snapshot.activity.iter().take(6).map(|item| { - div() - .py_2() - .border_b_1() - .border_color(rgb(0xeee3d7)) - .child(format!("{} · {}", item.action, item.detail)) - })), - ), - ) - } - - fn render_runs(&self, cx: &mut Context) -> impl IntoElement + '_ { - let selected = self - .state - .selected_run_id - .as_ref() - .and_then(|id| self.snapshot.runs.iter().find(|item| &item.id == id)); - let selected_status = selected - .map(|item| item.status.clone()) - .unwrap_or_else(|| "Select a run".to_string()); - let selected_summary = selected - .map(|item| item.summary.clone()) - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "No run selected.".to_string()); - let selected_model = selected - .and_then(|item| item.model.clone()) - .unwrap_or_else(|| "model pending".to_string()); - let selected_agent = selected - .and_then(|run| { - self.snapshot - .agents - .iter() - .find(|agent| agent.id == run.agent_id) - .map(|agent| agent.name.clone()) - }) - .unwrap_or_else(|| "Agent unavailable".to_string()); - let selected_workspace = selected - .and_then(|run| { - run.workspace_id.as_ref().and_then(|workspace_id| { - self.snapshot - .workspaces - .iter() - .find(|workspace| &workspace.id == workspace_id) - .map(|workspace| workspace.name.clone()) - }) - }) - .unwrap_or_else(|| "Workspace pending".to_string()); - let selected_error = selected - .and_then(|item| item.error_message.clone()) - .unwrap_or_default(); - - div() - .flex() - .gap_4() - .child( - div() - .id("runs-list") - .w(px(360.0)) - .p_4() - .gap_2() - .border_1() - .rounded_md() - .border_color(rgb(0xd9cdbf)) - .bg(rgb(0xfffbf5)) - .overflow_scroll() - .child(div().text_lg().child("Run queue")) - .children(self.snapshot.runs.iter().map(|item| { - let active = - self.state.selected_run_id.as_deref() == Some(item.id.as_str()); - let run_id = item.id.clone(); - let headline = if item.summary.trim().is_empty() { - format!("{} · {}", item.status, item.id) - } else { - format!("{} · {}", item.status, item.summary) - }; - div() - .id(SharedString::from(format!("run-{}", item.id))) - .px_3() - .py_2() - .rounded_md() - .cursor_pointer() - .bg(if active { rgb(0xefe2d4) } else { rgb(0xfffbf5) }) - .hover(|style| style.bg(rgb(0xf4ede4))) - .on_click(cx.listener(move |this, _, _, cx| { - this.state.selected_run_id = Some(run_id.clone()); - cx.notify(); - })) - .child(headline) - })), - ) - .child( - div() - .flex_1() - .p_4() - .gap_3() - .border_1() - .rounded_md() - .border_color(rgb(0xd9cdbf)) - .bg(rgb(0xfffbf5)) - .child(div().text_lg().child("Run inspector")) - .child( - div() - .text_sm() - .text_color(rgb(0x6f665d)) - .child(selected_status), - ) - .child( - div() - .flex() - .gap_2() - .child(self.metric_card("Agent", selected_agent, "Assigned operator")) - .child(self.metric_card( - "Model", - selected_model, - "Runtime-selected model", - )), - ) - .child( - div() - .flex() - .gap_2() - .child(self.metric_card( - "Workspace", - selected_workspace, - "Bound workspace", - )) - .child( - self.metric_card( - "Connector", - selected - .map(|item| item.connector_id.clone()) - .unwrap_or_else(|| "connector pending".to_string()), - "Execution provider", - ), - ), - ) - .child( - div() - .min_h(px(180.0)) - .p_3() - .border_1() - .border_color(rgb(0xeee3d7)) - .rounded_md() - .bg(rgb(0xf7f0e7)) - .child(selected_summary), - ) - .when(!selected_error.is_empty(), |container| { - container.child( - div() - .p_3() - .border_1() - .border_color(rgb(0x9e3d2d)) - .rounded_md() - .bg(rgb(0xffefe8)) - .child(selected_error), - ) - }), - ) - } - - fn render_inbox(&self) -> impl IntoElement + '_ { - div() - .flex() - .gap_4() - .child( - div() - .flex_1() - .p_4() - .gap_2() - .border_1() - .rounded_md() - .border_color(rgb(0xd9cdbf)) - .bg(rgb(0xfffbf5)) - .child(div().text_lg().child("Unread messages")) - .children( - self.snapshot - .agent_messages - .iter() - .filter(|item| item.read_at.is_none()) - .take(8) - .map(|item| { - div() - .py_2() - .border_b_1() - .border_color(rgb(0xeee3d7)) - .child(format!("{} · {}", item.subject, item.body)) - }), - ), - ) - .child( - div() - .flex_1() - .p_4() - .gap_2() - .border_1() - .rounded_md() - .border_color(rgb(0xd9cdbf)) - .bg(rgb(0xfffbf5)) - .child(div().text_lg().child("Pending approvals")) - .children( - self.snapshot - .approvals - .iter() - .filter(|item| item.state == "pending") - .take(8) - .map(|item| { - div().py_2().border_b_1().border_color(rgb(0xeee3d7)).child( - format!("{} · {}", item.approval_type, item.payload_summary), - ) - }), - ), - ) - } - - fn render_messages(&self, cx: &mut Context) -> impl IntoElement + '_ { - let selected = self.state.selected_message_id.as_ref().and_then(|id| { - self.snapshot - .agent_messages - .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()); - div() - .flex() - .gap_4() - .child( - div() - .id("messages-list") - .w(px(320.0)) - .p_4() - .gap_2() - .border_1() - .rounded_md() - .border_color(rgb(0xd9cdbf)) - .bg(rgb(0xfffbf5)) - .overflow_scroll() - .child(div().text_lg().child("Channels")) - .children(self.snapshot.agent_messages.iter().map(|item| { - let active = - self.state.selected_message_id.as_deref() == Some(item.id.as_str()); - let message_id = item.id.clone(); - div() - .id(SharedString::from(format!("message-{}", item.id))) - .px_3() - .py_2() - .rounded_md() - .cursor_pointer() - .bg(if active { rgb(0xefe2d4) } else { rgb(0xfffbf5) }) - .hover(|style| style.bg(rgb(0xf4ede4))) - .on_click(cx.listener(move |this, _, _, cx| { - this.state.selected_message_id = Some(message_id.clone()); - cx.notify(); - })) - .child(format!("{} · {}", item.subject, item.priority)) - })), - ) - .child( - div() - .flex_1() - .p_4() - .gap_3() - .border_1() - .rounded_md() - .border_color(rgb(0xd9cdbf)) - .bg(rgb(0xfffbf5)) - .child(div().text_lg().child("Conversation")) - .child( - div() - .text_sm() - .text_color(rgb(0x6f665d)) - .child(selected_subject), - ) - .child( - div() - .min_h(px(220.0)) - .p_3() - .border_1() - .border_color(rgb(0xeee3d7)) - .rounded_md() - .bg(rgb(0xf7f0e7)) - .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) - })), - ), - ) - } - - fn render_approvals(&self, cx: &mut Context) -> impl IntoElement + '_ { - let selected = self - .state - .selected_approval_id - .as_ref() - .and_then(|id| self.snapshot.approvals.iter().find(|item| &item.id == id)); - let approval_type = selected - .map(|item| item.approval_type.clone()) - .unwrap_or_else(|| "Select an approval".to_string()); - let approval_summary = selected - .map(|item| item.impact_summary.clone()) - .unwrap_or_else(|| "No approval selected.".to_string()); - div() - .flex() - .gap_4() - .child( - div() - .id("approvals-list") - .w(px(340.0)) - .p_4() - .gap_2() - .border_1() - .rounded_md() - .border_color(rgb(0xd9cdbf)) - .bg(rgb(0xfffbf5)) - .overflow_scroll() - .child(div().text_lg().child("Approval queue")) - .children(self.snapshot.approvals.iter().map(|item| { - let active = - self.state.selected_approval_id.as_deref() == Some(item.id.as_str()); - let approval_id = item.id.clone(); - div() - .id(SharedString::from(format!("approval-{}", item.id))) - .px_3() - .py_2() - .rounded_md() - .cursor_pointer() - .bg(if active { rgb(0xefe2d4) } else { rgb(0xfffbf5) }) - .hover(|style| style.bg(rgb(0xf4ede4))) - .on_click(cx.listener(move |this, _, _, cx| { - this.state.selected_approval_id = Some(approval_id.clone()); - cx.notify(); - })) - .child(format!("{} · {}", item.state, item.payload_summary)) - })), - ) - .child( - div() - .flex_1() - .p_4() - .gap_3() - .border_1() - .rounded_md() - .border_color(rgb(0xd9cdbf)) - .bg(rgb(0xfffbf5)) - .child(div().text_lg().child("Decision")) - .child( - div() - .text_sm() - .text_color(rgb(0x6f665d)) - .child(approval_type), - ) - .child( - div() - .min_h(px(160.0)) - .p_3() - .border_1() - .border_color(rgb(0xeee3d7)) - .rounded_md() - .bg(rgb(0xf7f0e7)) - .child(approval_summary), - ) - .child( - div() - .flex() - .gap_2() - .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, - ) - })), - ), - ) - } - - fn render_documents(&self, cx: &mut Context) -> impl IntoElement + '_ { - let selected = self - .state - .selected_document_id - .as_ref() - .and_then(|id| self.snapshot.documents.iter().find(|item| &item.id == id)); - let document_title = selected - .map(|item| item.title.clone()) - .unwrap_or_else(|| "Select a document".to_string()); - let document_content = selected - .map(|item| item.content.clone()) - .unwrap_or_else(|| "No document selected.".to_string()); - div() - .flex() - .gap_4() - .child( - div() - .id("documents-list") - .w(px(340.0)) - .p_4() - .gap_2() - .border_1() - .rounded_md() - .border_color(rgb(0xd9cdbf)) - .bg(rgb(0xfffbf5)) - .overflow_scroll() - .child(div().text_lg().child("Document center")) - .children(self.snapshot.documents.iter().map(|item| { - let active = - self.state.selected_document_id.as_deref() == Some(item.id.as_str()); - let document_id = item.id.clone(); - div() - .id(SharedString::from(format!("document-{}", item.id))) - .px_3() - .py_2() - .rounded_md() - .cursor_pointer() - .bg(if active { rgb(0xefe2d4) } else { rgb(0xfffbf5) }) - .hover(|style| style.bg(rgb(0xf4ede4))) - .on_click(cx.listener(move |this, _, _, cx| { - this.state.selected_document_id = Some(document_id.clone()); - cx.notify(); - })) - .child(format!("{} · {}", item.status, item.title)) - })), - ) - .child( - div() - .flex_1() - .p_4() - .gap_3() - .border_1() - .rounded_md() - .border_color(rgb(0xd9cdbf)) - .bg(rgb(0xfffbf5)) - .child(div().text_lg().child("Quick draft")) - .child(self.document_title_input.clone()) - .child(self.document_body_input.clone()) - .child(primary_button("Save draft", cx, |this, cx| { - this.create_document(cx) - })) - .child(div().h(px(1.0)).bg(rgb(0xeee3d7))) - .child( - div() - .text_sm() - .text_color(rgb(0x6f665d)) - .child(document_title), - ) - .child( - div() - .min_h(px(200.0)) - .p_3() - .border_1() - .border_color(rgb(0xeee3d7)) - .rounded_md() - .bg(rgb(0xf7f0e7)) - .child(document_content), - ), - ) - } - - fn render_workspaces(&self, cx: &mut Context) -> impl IntoElement + '_ { - div() - .flex() - .flex_col() - .gap_3() - .children(self.snapshot.workspaces.iter().map(|workspace| { - let active = - self.state.selected_workspace_id.as_deref() == Some(workspace.id.as_str()); - let workspace_id = workspace.id.clone(); - div() - .id(SharedString::from(format!("workspace-{}", workspace.id))) - .p_4() - .gap_2() - .border_1() - .rounded_md() - .border_color(rgb(0xd9cdbf)) - .bg(if active { rgb(0xf7f0e7) } else { rgb(0xfffbf5) }) - .cursor_pointer() - .hover(|style| style.bg(rgb(0xf4ede4))) - .on_click(cx.listener(move |this, _, _, cx| { - this.state.selected_workspace_id = Some(workspace_id.clone()); - cx.notify(); - })) - .child(div().text_lg().child(workspace.name.clone())) - .child( - div() - .text_sm() - .text_color(rgb(0x6f665d)) - .child(workspace.local_path.clone()), - ) - .child(div().text_sm().text_color(rgb(0x6f665d)).child(format!( - "{} · {}", - workspace.state, - if workspace.is_primary { - "primary" - } else { - "linked" - } - ))) - })) - } - - fn render_settings(&self) -> impl IntoElement + '_ { - let current = self.snapshot.current_company(); - let execution_mode = current - .map(|item| item.execution_mode.clone()) - .unwrap_or_else(|| "ai_ceo".to_string()); - div() - .flex() - .flex_col() - .gap_4() - .child(self.metric_card( - "Profile path", - self.snapshot.profile_path.clone(), - "Electron-compatible local profile", - )) - .child(self.metric_card( - "Theme", - self.snapshot.theme.clone(), - "Stored in the shared settings table", - )) - .child(self.metric_card( - "Locale", - self.snapshot.locale.clone(), - "Read from settings or the OS locale", - )) - .child(self.metric_card( - "Connectors", - self.snapshot.connectors.len().to_string(), - execution_mode, - )) - } - - fn render_onboarding(&self, cx: &mut Context) -> impl IntoElement + '_ { - div() - .size_full() - .p_8() - .flex() - .gap_8() - .bg(rgb(0xf3efe8)) - .child( - div() - .flex_1() - .gap_4() - .child(div().text_3xl().text_color(rgb(0x171411)).child("Start a native AgentCompany profile")) - .child(div().text_sm().text_color(rgb(0x6f665d)).child("Create the first company, point it at a workspace, and keep the SQLite profile local.")) - .child(self.company_name_input.clone()) - .child(self.mission_input.clone()) - .child(self.workspace_input.clone()) - .child(primary_button("Create company", cx, |this, cx| this.create_company(cx))), - ) - .child( - div() - .w(px(380.0)) - .p_5() - .gap_3() - .border_1() - .rounded_md() - .border_color(rgb(0xd9cdbf)) - .bg(rgb(0xfffbf5)) - .child(div().text_lg().child("What lands in SQLite")) - .child(div().text_sm().text_color(rgb(0x6f665d)).child("Company")) - .child(div().text_sm().text_color(rgb(0x6f665d)).child("Primary workspace")) - .child(div().text_sm().text_color(rgb(0x6f665d)).child("Connector catalog")) - .child(div().text_sm().text_color(rgb(0x6f665d)).child("Shared profile settings")), - ) - } } impl Render for AppRoot { diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root/approvals.rs b/AgentCompany-Rust/crates/app_ui/src/app_root/approvals.rs new file mode 100644 index 00000000..a3eb7c11 --- /dev/null +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/approvals.rs @@ -0,0 +1,97 @@ +use super::*; + +impl AppRoot { + pub(super) fn render_approvals(&self, cx: &mut Context) -> impl IntoElement + '_ { + let selected = self + .state + .selected_approval_id + .as_ref() + .and_then(|id| self.snapshot.approvals.iter().find(|item| &item.id == id)); + let approval_type = selected + .map(|item| item.approval_type.clone()) + .unwrap_or_else(|| "Select an approval".to_string()); + let approval_summary = selected + .map(|item| item.impact_summary.clone()) + .unwrap_or_else(|| "No approval selected.".to_string()); + div() + .flex() + .gap_4() + .child( + div() + .id("approvals-list") + .w(px(340.0)) + .p_4() + .gap_2() + .border_1() + .rounded_md() + .border_color(rgb(0xd9cdbf)) + .bg(rgb(0xfffbf5)) + .overflow_scroll() + .child(div().text_lg().child("Approval queue")) + .children(self.snapshot.approvals.iter().map(|item| { + let active = + self.state.selected_approval_id.as_deref() == Some(item.id.as_str()); + let approval_id = item.id.clone(); + div() + .id(SharedString::from(format!("approval-{}", item.id))) + .px_3() + .py_2() + .rounded_md() + .cursor_pointer() + .bg(if active { rgb(0xefe2d4) } else { rgb(0xfffbf5) }) + .hover(|style| style.bg(rgb(0xf4ede4))) + .on_click(cx.listener(move |this, _, _, cx| { + this.state.selected_approval_id = Some(approval_id.clone()); + cx.notify(); + })) + .child(format!("{} · {}", item.state, item.payload_summary)) + })), + ) + .child( + div() + .flex_1() + .p_4() + .gap_3() + .border_1() + .rounded_md() + .border_color(rgb(0xd9cdbf)) + .bg(rgb(0xfffbf5)) + .child(div().text_lg().child("Decision")) + .child( + div() + .text_sm() + .text_color(rgb(0x6f665d)) + .child(approval_type), + ) + .child( + div() + .min_h(px(160.0)) + .p_3() + .border_1() + .border_color(rgb(0xeee3d7)) + .rounded_md() + .bg(rgb(0xf7f0e7)) + .child(approval_summary), + ) + .child( + div() + .flex() + .gap_2() + .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, + ) + })), + ), + ) + } +} diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root/documents.rs b/AgentCompany-Rust/crates/app_ui/src/app_root/documents.rs new file mode 100644 index 00000000..39685da2 --- /dev/null +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/documents.rs @@ -0,0 +1,84 @@ +use super::*; + +impl AppRoot { + pub(super) fn render_documents(&self, cx: &mut Context) -> impl IntoElement + '_ { + let selected = self + .state + .selected_document_id + .as_ref() + .and_then(|id| self.snapshot.documents.iter().find(|item| &item.id == id)); + let document_title = selected + .map(|item| item.title.clone()) + .unwrap_or_else(|| "Select a document".to_string()); + let document_content = selected + .map(|item| item.content.clone()) + .unwrap_or_else(|| "No document selected.".to_string()); + div() + .flex() + .gap_4() + .child( + div() + .id("documents-list") + .w(px(340.0)) + .p_4() + .gap_2() + .border_1() + .rounded_md() + .border_color(rgb(0xd9cdbf)) + .bg(rgb(0xfffbf5)) + .overflow_scroll() + .child(div().text_lg().child("Document center")) + .children(self.snapshot.documents.iter().map(|item| { + let active = + self.state.selected_document_id.as_deref() == Some(item.id.as_str()); + let document_id = item.id.clone(); + div() + .id(SharedString::from(format!("document-{}", item.id))) + .px_3() + .py_2() + .rounded_md() + .cursor_pointer() + .bg(if active { rgb(0xefe2d4) } else { rgb(0xfffbf5) }) + .hover(|style| style.bg(rgb(0xf4ede4))) + .on_click(cx.listener(move |this, _, _, cx| { + this.state.selected_document_id = Some(document_id.clone()); + cx.notify(); + })) + .child(format!("{} · {}", item.status, item.title)) + })), + ) + .child( + div() + .flex_1() + .p_4() + .gap_3() + .border_1() + .rounded_md() + .border_color(rgb(0xd9cdbf)) + .bg(rgb(0xfffbf5)) + .child(div().text_lg().child("Quick draft")) + .child(self.document_title_input.clone()) + .child(self.document_body_input.clone()) + .child(primary_button("Save draft", cx, |this, cx| { + this.create_document(cx) + })) + .child(div().h(px(1.0)).bg(rgb(0xeee3d7))) + .child( + div() + .text_sm() + .text_color(rgb(0x6f665d)) + .child(document_title), + ) + .child( + div() + .min_h(px(200.0)) + .p_3() + .border_1() + .border_color(rgb(0xeee3d7)) + .rounded_md() + .bg(rgb(0xf7f0e7)) + .child(document_content), + ), + ) + } +} diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root/inbox.rs b/AgentCompany-Rust/crates/app_ui/src/app_root/inbox.rs new file mode 100644 index 00000000..490163f7 --- /dev/null +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/inbox.rs @@ -0,0 +1,57 @@ +use super::*; + +impl AppRoot { + pub(super) fn render_inbox(&self) -> impl IntoElement + '_ { + div() + .flex() + .gap_4() + .child( + div() + .flex_1() + .p_4() + .gap_2() + .border_1() + .rounded_md() + .border_color(rgb(0xd9cdbf)) + .bg(rgb(0xfffbf5)) + .child(div().text_lg().child("Unread messages")) + .children( + self.snapshot + .agent_messages + .iter() + .filter(|item| item.read_at.is_none()) + .take(8) + .map(|item| { + div() + .py_2() + .border_b_1() + .border_color(rgb(0xeee3d7)) + .child(format!("{} · {}", item.subject, item.body)) + }), + ), + ) + .child( + div() + .flex_1() + .p_4() + .gap_2() + .border_1() + .rounded_md() + .border_color(rgb(0xd9cdbf)) + .bg(rgb(0xfffbf5)) + .child(div().text_lg().child("Pending approvals")) + .children( + self.snapshot + .approvals + .iter() + .filter(|item| item.state == "pending") + .take(8) + .map(|item| { + div().py_2().border_b_1().border_color(rgb(0xeee3d7)).child( + format!("{} · {}", item.approval_type, item.payload_summary), + ) + }), + ), + ) + } +} diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root/messages.rs b/AgentCompany-Rust/crates/app_ui/src/app_root/messages.rs new file mode 100644 index 00000000..b01aa821 --- /dev/null +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/messages.rs @@ -0,0 +1,91 @@ +use super::*; + +impl AppRoot { + pub(super) fn render_messages(&self, cx: &mut Context) -> impl IntoElement + '_ { + let selected = self.state.selected_message_id.as_ref().and_then(|id| { + self.snapshot + .agent_messages + .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()); + div() + .flex() + .gap_4() + .child( + div() + .id("messages-list") + .w(px(320.0)) + .p_4() + .gap_2() + .border_1() + .rounded_md() + .border_color(rgb(0xd9cdbf)) + .bg(rgb(0xfffbf5)) + .overflow_scroll() + .child(div().text_lg().child("Channels")) + .children(self.snapshot.agent_messages.iter().map(|item| { + let active = + self.state.selected_message_id.as_deref() == Some(item.id.as_str()); + let message_id = item.id.clone(); + div() + .id(SharedString::from(format!("message-{}", item.id))) + .px_3() + .py_2() + .rounded_md() + .cursor_pointer() + .bg(if active { rgb(0xefe2d4) } else { rgb(0xfffbf5) }) + .hover(|style| style.bg(rgb(0xf4ede4))) + .on_click(cx.listener(move |this, _, _, cx| { + this.state.selected_message_id = Some(message_id.clone()); + cx.notify(); + })) + .child(format!("{} · {}", item.subject, item.priority)) + })), + ) + .child( + div() + .flex_1() + .p_4() + .gap_3() + .border_1() + .rounded_md() + .border_color(rgb(0xd9cdbf)) + .bg(rgb(0xfffbf5)) + .child(div().text_lg().child("Conversation")) + .child( + div() + .text_sm() + .text_color(rgb(0x6f665d)) + .child(selected_subject), + ) + .child( + div() + .min_h(px(220.0)) + .p_3() + .border_1() + .border_color(rgb(0xeee3d7)) + .rounded_md() + .bg(rgb(0xf7f0e7)) + .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) + })), + ), + ) + } +} diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root/onboarding.rs b/AgentCompany-Rust/crates/app_ui/src/app_root/onboarding.rs new file mode 100644 index 00000000..662b728e --- /dev/null +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/onboarding.rs @@ -0,0 +1,38 @@ +use super::*; + +impl AppRoot { + pub(super) fn render_onboarding(&self, cx: &mut Context) -> impl IntoElement + '_ { + div() + .size_full() + .p_8() + .flex() + .gap_8() + .bg(rgb(0xf3efe8)) + .child( + div() + .flex_1() + .gap_4() + .child(div().text_3xl().text_color(rgb(0x171411)).child("Start a native AgentCompany profile")) + .child(div().text_sm().text_color(rgb(0x6f665d)).child("Create the first company, point it at a workspace, and keep the SQLite profile local.")) + .child(self.company_name_input.clone()) + .child(self.mission_input.clone()) + .child(self.workspace_input.clone()) + .child(primary_button("Create company", cx, |this, cx| this.create_company(cx))), + ) + .child( + div() + .w(px(380.0)) + .p_5() + .gap_3() + .border_1() + .rounded_md() + .border_color(rgb(0xd9cdbf)) + .bg(rgb(0xfffbf5)) + .child(div().text_lg().child("What lands in SQLite")) + .child(div().text_sm().text_color(rgb(0x6f665d)).child("Company")) + .child(div().text_sm().text_color(rgb(0x6f665d)).child("Primary workspace")) + .child(div().text_sm().text_color(rgb(0x6f665d)).child("Connector catalog")) + .child(div().text_sm().text_color(rgb(0x6f665d)).child("Shared profile settings")), + ) + } +} diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root/overview.rs b/AgentCompany-Rust/crates/app_ui/src/app_root/overview.rs new file mode 100644 index 00000000..4bfe7239 --- /dev/null +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/overview.rs @@ -0,0 +1,80 @@ +use super::*; + +impl AppRoot { + pub(super) fn render_overview(&self) -> impl IntoElement + '_ { + let company = self.snapshot.current_company(); + let company_name = company + .map(|item| item.name.clone()) + .unwrap_or_else(|| "No company".to_string()); + div() + .flex() + .flex_col() + .gap_4() + .child( + div() + .flex() + .gap_4() + .child(self.metric_card( + "Active tasks", + self.snapshot.active_tasks().to_string(), + "Tasks in todo, review, or execution", + )) + .child(self.metric_card( + "Pending approvals", + self.snapshot.pending_approvals().to_string(), + "Human review gates", + )) + .child(self.metric_card( + "Unread messages", + self.snapshot.unread_messages().to_string(), + "Company and direct channels", + )) + .child(self.metric_card( + "Active runs", + self.snapshot.active_runs().to_string(), + "Queued, running, and waiting runs", + )), + ) + .child( + div() + .flex() + .gap_4() + .child( + div() + .flex_1() + .p_4() + .gap_2() + .border_1() + .rounded_md() + .border_color(rgb(0xd9cdbf)) + .bg(rgb(0xfffbf5)) + .child(div().text_lg().child(company_name)) + .children(self.snapshot.tasks.iter().take(6).map(|task| { + div() + .py_2() + .border_b_1() + .border_color(rgb(0xeee3d7)) + .child(format!("{} · {}", task.status, task.title)) + })), + ) + .child( + div() + .flex_1() + .p_4() + .gap_2() + .border_1() + .rounded_md() + .border_color(rgb(0xd9cdbf)) + .bg(rgb(0xfffbf5)) + .child(div().text_lg().child("Recent activity")) + .children(self.snapshot.activity.iter().take(6).map(|item| { + div() + .py_2() + .border_b_1() + .border_color(rgb(0xeee3d7)) + .child(format!("{} · {}", item.action, item.detail)) + })), + ), + ) + } +} diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root/runs.rs b/AgentCompany-Rust/crates/app_ui/src/app_root/runs.rs new file mode 100644 index 00000000..b6fdf1a5 --- /dev/null +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/runs.rs @@ -0,0 +1,152 @@ +use super::*; + +impl AppRoot { + pub(super) fn render_runs(&self, cx: &mut Context) -> impl IntoElement + '_ { + let selected = self + .state + .selected_run_id + .as_ref() + .and_then(|id| self.snapshot.runs.iter().find(|item| &item.id == id)); + let selected_status = selected + .map(|item| item.status.clone()) + .unwrap_or_else(|| "Select a run".to_string()); + let selected_summary = selected + .map(|item| item.summary.clone()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "No run selected.".to_string()); + let selected_model = selected + .and_then(|item| item.model.clone()) + .unwrap_or_else(|| "model pending".to_string()); + let selected_agent = selected + .and_then(|run| { + self.snapshot + .agents + .iter() + .find(|agent| agent.id == run.agent_id) + .map(|agent| agent.name.clone()) + }) + .unwrap_or_else(|| "Agent unavailable".to_string()); + let selected_workspace = selected + .and_then(|run| { + run.workspace_id.as_ref().and_then(|workspace_id| { + self.snapshot + .workspaces + .iter() + .find(|workspace| &workspace.id == workspace_id) + .map(|workspace| workspace.name.clone()) + }) + }) + .unwrap_or_else(|| "Workspace pending".to_string()); + let selected_error = selected + .and_then(|item| item.error_message.clone()) + .unwrap_or_default(); + + div() + .flex() + .gap_4() + .child( + div() + .id("runs-list") + .w(px(360.0)) + .p_4() + .gap_2() + .border_1() + .rounded_md() + .border_color(rgb(0xd9cdbf)) + .bg(rgb(0xfffbf5)) + .overflow_scroll() + .child(div().text_lg().child("Run queue")) + .children(self.snapshot.runs.iter().map(|item| { + let active = + self.state.selected_run_id.as_deref() == Some(item.id.as_str()); + let run_id = item.id.clone(); + let headline = if item.summary.trim().is_empty() { + format!("{} · {}", item.status, item.id) + } else { + format!("{} · {}", item.status, item.summary) + }; + div() + .id(SharedString::from(format!("run-{}", item.id))) + .px_3() + .py_2() + .rounded_md() + .cursor_pointer() + .bg(if active { rgb(0xefe2d4) } else { rgb(0xfffbf5) }) + .hover(|style| style.bg(rgb(0xf4ede4))) + .on_click(cx.listener(move |this, _, _, cx| { + this.state.selected_run_id = Some(run_id.clone()); + cx.notify(); + })) + .child(headline) + })), + ) + .child( + div() + .flex_1() + .p_4() + .gap_3() + .border_1() + .rounded_md() + .border_color(rgb(0xd9cdbf)) + .bg(rgb(0xfffbf5)) + .child(div().text_lg().child("Run inspector")) + .child( + div() + .text_sm() + .text_color(rgb(0x6f665d)) + .child(selected_status), + ) + .child( + div() + .flex() + .gap_2() + .child(self.metric_card("Agent", selected_agent, "Assigned operator")) + .child(self.metric_card( + "Model", + selected_model, + "Runtime-selected model", + )), + ) + .child( + div() + .flex() + .gap_2() + .child(self.metric_card( + "Workspace", + selected_workspace, + "Bound workspace", + )) + .child( + self.metric_card( + "Connector", + selected + .map(|item| item.connector_id.clone()) + .unwrap_or_else(|| "connector pending".to_string()), + "Execution provider", + ), + ), + ) + .child( + div() + .min_h(px(180.0)) + .p_3() + .border_1() + .border_color(rgb(0xeee3d7)) + .rounded_md() + .bg(rgb(0xf7f0e7)) + .child(selected_summary), + ) + .when(!selected_error.is_empty(), |container| { + container.child( + div() + .p_3() + .border_1() + .border_color(rgb(0x9e3d2d)) + .rounded_md() + .bg(rgb(0xffefe8)) + .child(selected_error), + ) + }), + ) + } +} diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root/settings.rs b/AgentCompany-Rust/crates/app_ui/src/app_root/settings.rs new file mode 100644 index 00000000..d6007a81 --- /dev/null +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/settings.rs @@ -0,0 +1,34 @@ +use super::*; + +impl AppRoot { + pub(super) fn render_settings(&self) -> impl IntoElement + '_ { + let current = self.snapshot.current_company(); + let execution_mode = current + .map(|item| item.execution_mode.clone()) + .unwrap_or_else(|| "ai_ceo".to_string()); + div() + .flex() + .flex_col() + .gap_4() + .child(self.metric_card( + "Profile path", + self.snapshot.profile_path.clone(), + "Electron-compatible local profile", + )) + .child(self.metric_card( + "Theme", + self.snapshot.theme.clone(), + "Stored in the shared settings table", + )) + .child(self.metric_card( + "Locale", + self.snapshot.locale.clone(), + "Read from settings or the OS locale", + )) + .child(self.metric_card( + "Connectors", + self.snapshot.connectors.len().to_string(), + execution_mode, + )) + } +} diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root/sidebar.rs b/AgentCompany-Rust/crates/app_ui/src/app_root/sidebar.rs new file mode 100644 index 00000000..04669a98 --- /dev/null +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/sidebar.rs @@ -0,0 +1,64 @@ +use super::*; + +impl AppRoot { + pub(super) fn render_sidebar(&self, cx: &mut Context) -> impl IntoElement + '_ { + let sections = [ + SectionId::Overview, + SectionId::Runs, + SectionId::Inbox, + SectionId::Messages, + SectionId::Approvals, + SectionId::Documents, + SectionId::Workspaces, + SectionId::Settings, + ]; + div() + .w(px(208.0)) + .h_full() + .p_4() + .gap_4() + .flex() + .flex_col() + .bg(rgb(0x161514)) + .text_color(rgb(0xf5eee4)) + .child(div().text_lg().child("AgentCompany")) + .children(self.snapshot.companies.iter().map(|company| { + 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))) + .px_3() + .py_2() + .rounded_md() + .cursor_pointer() + .bg(if active { rgb(0x9e3d2d) } else { rgb(0x211d1a) }) + .hover(|style| style.bg(rgb(0x362c27))) + .on_click(cx.listener(move |this, _, _, cx| { + this.switch_company(company_id.clone(), cx) + })) + .child(company.name.clone()) + })) + .child(div().h(px(1.0)).bg(rgb(0x2f2924))) + .children(sections.into_iter().map(|section| { + let active = self.state.section == section; + div() + .id(SharedString::from(format!( + "section-{}", + section.label().to_lowercase() + ))) + .px_3() + .py_2() + .rounded_md() + .cursor_pointer() + .bg(if active { rgb(0xefe2d4) } else { rgb(0x161514) }) + .text_color(if active { rgb(0x171411) } else { rgb(0xf5eee4) }) + .hover(|style| style.bg(rgb(0x2c2825))) + .on_click(cx.listener(move |this, _, _, cx| { + this.state.section = section; + cx.notify(); + })) + .child(section.label()) + })) + } +} diff --git a/AgentCompany-Rust/crates/app_ui/src/app_root/workspaces.rs b/AgentCompany-Rust/crates/app_ui/src/app_root/workspaces.rs new file mode 100644 index 00000000..c1415b50 --- /dev/null +++ b/AgentCompany-Rust/crates/app_ui/src/app_root/workspaces.rs @@ -0,0 +1,45 @@ +use super::*; + +impl AppRoot { + pub(super) fn render_workspaces(&self, cx: &mut Context) -> impl IntoElement + '_ { + div() + .flex() + .flex_col() + .gap_3() + .children(self.snapshot.workspaces.iter().map(|workspace| { + let active = + self.state.selected_workspace_id.as_deref() == Some(workspace.id.as_str()); + let workspace_id = workspace.id.clone(); + div() + .id(SharedString::from(format!("workspace-{}", workspace.id))) + .p_4() + .gap_2() + .border_1() + .rounded_md() + .border_color(rgb(0xd9cdbf)) + .bg(if active { rgb(0xf7f0e7) } else { rgb(0xfffbf5) }) + .cursor_pointer() + .hover(|style| style.bg(rgb(0xf4ede4))) + .on_click(cx.listener(move |this, _, _, cx| { + this.state.selected_workspace_id = Some(workspace_id.clone()); + cx.notify(); + })) + .child(div().text_lg().child(workspace.name.clone())) + .child( + div() + .text_sm() + .text_color(rgb(0x6f665d)) + .child(workspace.local_path.clone()), + ) + .child(div().text_sm().text_color(rgb(0x6f665d)).child(format!( + "{} · {}", + workspace.state, + if workspace.is_primary { + "primary" + } else { + "linked" + } + ))) + })) + } +} diff --git a/AgentCompany-Rust/docs/plans/2026-04-19-native-gpui-rewrite-plan.md b/AgentCompany-Rust/docs/plans/2026-04-19-native-gpui-rewrite-plan.md new file mode 100644 index 00000000..5d287d0d --- /dev/null +++ b/AgentCompany-Rust/docs/plans/2026-04-19-native-gpui-rewrite-plan.md @@ -0,0 +1,211 @@ +# AgentCompany-Rust Native GPUI Rewrite Plan + +Goal: rebuild AgentCompany as a native Rust + GPUI desktop app that preserves the current Electron product surface, UI/UX structure, and core execution semantics while replacing the entire application stack with Rust crates and a typed Pi Mono sidecar boundary. + +Architecture: keep the PRD four-layer split intact. UI lives in `crates/app_ui`, page/application state in `crates/app_models`, business entities and rules in `crates/domain`, persistence and sidecar orchestration in `crates/storage`, `crates/runtime`, and `crates/pi_bridge`. Preserve Electron parity by reading the Electron renderer, screenshots, and e2e flows as the truth source for layout and workflow behavior. + +Tech Stack: Rust 2024, GPUI, rusqlite, anyhow/thiserror, Pi Mono sidecar over typed JSON RPC, cargo fmt/clippy/test. + +--- + +## Phase 0: Ground truth and guardrails + +### Task 0.1: Lock the rewrite truth sources into the repo +Objective: make the migration inputs explicit so implementation never guesses. + +Files: +- Create: `docs/reference/electron-parity.md` +- Reference: `PRD.md`, `../README.md`, `../问题.md`, `../screenshots/*`, `../src/renderer/**/*`, `../tests/e2e/desktop.smoke.spec.ts` + +Verification: +- Document section-to-section mapping for onboarding, dashboard, communication, approvals, documents, and settings. +- Confirm every item in PRD section 5 and 14 has a Rust destination. + +### Task 0.2: Stabilize the Rust baseline +Objective: restore and verify the existing Rust workspace before structural changes. + +Files: +- Existing workspace only + +Verification commands: +- `cargo fmt --check` +- `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- `cargo test --workspace` + +Status at plan creation: complete. + +--- + +## Phase 1: UI shell parity foundation + +### Task 1.1: Split the monolithic `app_root.rs` +Objective: move each page renderer into a dedicated module so the UI can grow toward parity without a god file. + +Files: +- Modify: `crates/app_ui/src/app_root.rs` +- Create: `crates/app_ui/src/app_root/sidebar.rs` +- Create: `crates/app_ui/src/app_root/overview.rs` +- Create: `crates/app_ui/src/app_root/runs.rs` +- Create: `crates/app_ui/src/app_root/inbox.rs` +- Create: `crates/app_ui/src/app_root/messages.rs` +- Create: `crates/app_ui/src/app_root/approvals.rs` +- Create: `crates/app_ui/src/app_root/documents.rs` +- Create: `crates/app_ui/src/app_root/workspaces.rs` +- Create: `crates/app_ui/src/app_root/settings.rs` +- Create: `crates/app_ui/src/app_root/onboarding.rs` + +Verification: +- `cargo fmt --check` +- `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- `cargo test --workspace` + +### Task 1.2: Introduce explicit shell/theme tokens +Objective: replace ad-hoc colors and spacing with a tokenized shell that matches the Electron visual hierarchy. + +Files: +- Create: `crates/app_ui/src/theme.rs` +- Create: `crates/app_ui/src/shell.rs` +- Modify: `crates/app_ui/src/lib.rs` +- Modify: `crates/app_ui/src/app_root.rs` + +Verification: +- main shell matches screenshot structure: left sidebar, top title bar, content canvas, onboarding split screen. +- `cargo fmt --check` +- `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- `cargo test --workspace` + +### Task 1.3: Add parity notes per major screen +Objective: each Rust screen states which Electron screen and screenshot it is matching. + +Files: +- Create: `docs/reference/dashboard-parity.md` +- Create: `docs/reference/onboarding-parity.md` +- Create: `docs/reference/communication-parity.md` +- Create: `docs/reference/approvals-parity.md` + +Verification: +- Each note names source files and screenshots. + +--- + +## Phase 2: Data model and storage completion + +### Task 2.1: Extend domain IDs and typed entities +Objective: replace stringly-typed cross-domain references with PRD-aligned newtypes and explicit enums. + +Files: +- Modify: `crates/domain/src/lib.rs` +- Modify: `crates/domain/src/models.rs` +- Modify: `crates/domain/src/enums.rs` +- Create as needed under `crates/domain/src/` + +Verification: +- repository and runtime APIs accept semantic IDs instead of free-form strings where feasible. +- `cargo test --workspace` + +### Task 2.2: Expand SQLite schema toward PRD entities +Objective: support thread state, artifacts, workspace lease state, audit events, and richer approvals. + +Files: +- Modify: `crates/storage/src/schema.rs` +- Modify: `crates/storage/src/store.rs` +- Create: `migrations/*` when schema versioning is introduced +- Create tests under `crates/storage/tests/` + +Verification: +- migration tests cover schema bootstrap and round-trip persistence for company, run, approval, message, document, workspace, and artifact slices. + +--- + +## Phase 3: Runtime contract completion + +### Task 3.1: Expand Pi bridge protocol to PRD contract +Objective: cover handshake, run lifecycle, tool lifecycle, approvals, steer, follow-up, and compaction events. + +Files: +- Modify: `crates/pi_bridge/src/protocol.rs` +- Modify: `crates/pi_bridge/src/client.rs` +- Modify: `crates/runtime/src/service.rs` +- Modify: `crates/runtime/tests/sidecar_contract.rs` + +Verification: +- sidecar contract tests assert handshake, start run, tool approval, steer, follow-up, and run finished flow. + +### Task 3.2: Add runtime projection into storage/app state +Objective: runtime events must land in durable state and visible UI state. + +Files: +- Modify: `crates/runtime/src/lib.rs` +- Modify: `crates/storage/src/store.rs` +- Modify: `crates/app_models/src/state.rs` +- Add tests where needed + +Verification: +- a synthetic runtime event sequence updates run status, approvals, messages, and artifacts in one end-to-end test. + +--- + +## Phase 4: Vertical workflow parity + +### Task 4.1: Onboarding parity +Objective: reproduce the Electron onboarding flow, step structure, and persistence semantics. + +### Task 4.2: Dashboard parity +Objective: reproduce metrics, active work, and recent activity layout. + +### Task 4.3: Communication parity +Objective: reproduce three-column message layout, message detail, and composer flow. + +### Task 4.4: Approval parity +Objective: reproduce approval queue/detail/actions and connect decisions to runtime. + +### Task 4.5: Documents/workspaces parity +Objective: reproduce document center and workspace/artifact visibility. + +Verification for each task: +- UI-visible proof in Rust layout +- persisted state proof in SQLite +- runtime behavior proof where applicable +- automated test proof + +--- + +## Phase 5: End-to-end verification + +### Task 5.1: Native smoke harness +Objective: add a Rust-native smoke path that boots the app against a temp profile and exercises the primary first-run flow. + +Files: +- Create: `crates/app/tests/native_smoke.rs` or equivalent supported harness +- Add any test support modules needed under `crates/app/tests/` + +Verification: +- first-run onboarding bootstrap is exercised against a temp profile +- smoke test is part of `cargo test --workspace` + +### Task 5.2: Runtime + storage end-to-end flow +Objective: one test drives onboarding bootstrap, run creation, tool approval, message writeback, and artifact registration through Rust crates. + +Files: +- Create: `crates/runtime/tests/runtime_storage_e2e.rs` +- Modify storage/runtime code as needed + +Verification: +- `cargo test --workspace runtime_storage_e2e -- --nocapture` + +--- + +## Phase 6: Hardening and release readiness + +### Task 6.1: Cross-platform shell and path abstraction +### Task 6.2: crash recovery and session restore +### Task 6.3: packaging/release pipeline + +Verification baseline: +- `cargo fmt --check` +- `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- `cargo test --workspace` + +--- + +Execution rule: every completed slice must leave the workspace green and in a committable state before the next slice starts.