diff --git a/crates/deckard-app/src/activity_view.rs b/crates/deckard-app/src/activity_view.rs index edfc386..71e1538 100644 --- a/crates/deckard-app/src/activity_view.rs +++ b/crates/deckard-app/src/activity_view.rs @@ -36,14 +36,14 @@ use std::time::{SystemTime, UNIX_EPOCH}; use gpui::{ - div, px, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, + div, px, AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, StatefulInteractiveElement, Styled, }; use gpui_component::{ button::{Button, ButtonVariants}, h_flex, scroll::ScrollableElement, - v_flex, ActiveTheme, Icon, IconName, Sizable, + v_flex, ActiveTheme, Icon, IconName, Sizable, Theme, }; use deckard_contract::{ @@ -55,7 +55,10 @@ use deckard_contract::{ use crate::money::money; use crate::shell::Shell; use crate::theme; -use crate::widgets::agent_mark; +use crate::widgets::{ + agent_mark, caution_line, identity_mark, kv_row, meta_obj, meta_section, origin_header, + status_glyph, KvValue, Origin, StatusGlyph, +}; /// The displayed subject for an action's origin: the agent's handle when an agent acted, "You" /// when the foreground app did (E2, #182 — one agent in demo scope, named via `Shell::agent_handle`). @@ -1452,6 +1455,289 @@ fn activity_shell(inner: gpui::AnyElement) -> impl IntoElement { ) } +// ───────────────────────────────────────────────────────────────────────────── +// The Activity surface's right metadata rail (E3, #183; DESIGN §IA). Read-only — +// approve/deny stay in the feed's own review, so the no-blind-approve invariant +// lives there; the rail only ever *shows* the record in focus. Lives here (not in +// `shell_rail.rs`) to reuse the feed's private summary helpers rather than fork them. +// ───────────────────────────────────────────────────────────────────────────── + +impl Shell { + /// The Activity rail's title + body, contextual to what's in focus: the selected pending + /// request's compact clear-signing, else the most-recent broadcast transaction's receipt, else a + /// quiet empty state. `activity_selected` indexes the pending subset ([`activity_pending`]), so a + /// highlighted row is always the one detailed — never "Nothing selected." while a row is selected. + pub(crate) fn activity_rail(&self, cx: &mut Context) -> (&'static str, AnyElement) { + let theme = cx.theme(); + let handle = self.agent_handle(); + let pending = activity_pending(&self.activity); + // The rail matches the on-screen review: when one is open (`activity_reviewing`) and its + // record is still pending, detail THAT request; otherwise the highlighted pending row. So an + // open review of X never shows a different row's clear-signing beside it. + let focused = self + .activity_reviewing + .and_then(|id| pending.iter().copied().find(|r| r.request_id == id)) + .or_else(|| pending.get(self.activity_selected).copied()); + if let Some(rec) = focused { + return ( + "Pending request", + request_rail_body(rec, &handle, self.mask, theme), + ); + } + // Nothing pending → the latest thing that actually broadcast (newest-first feed order). + if let Some(rec) = self.activity.iter().find(|r| r.tx_hash.is_some()) { + return ( + "This transaction", + tx_rail_body(rec, &handle, self.mask, theme), + ); + } + ("Activity", empty_rail_body(theme)) + } +} + +/// The compact headline verb for a pending request (`Shielding` / `Sending` / `Swapping`). +fn request_verb(payload: &PendingPayloadView) -> &'static str { + match payload { + PendingPayloadView::Tx(intent) => match intent.kind { + IntentKind::Send => "Sending", + IntentKind::Shield => "Shielding", + IntentKind::Unshield => "Unshielding", + IntentKind::ContractCall => "Calling", + }, + PendingPayloadView::Order(_) => "Swapping", + PendingPayloadView::Approve { .. } => "Approving", + PendingPayloadView::Message(_) => "Signing", + } +} + +/// The lowercase action word for a settled receipt's sub-line (`shield · confirmed`). +fn action_word(payload: &PendingPayloadView) -> &'static str { + match payload { + PendingPayloadView::Tx(intent) => match intent.kind { + IntentKind::Send => "send", + IntentKind::Shield => "shield", + IntentKind::Unshield => "unshield", + IntentKind::ContractCall => "call", + }, + PendingPayloadView::Order(_) => "swap", + PendingPayloadView::Approve { .. } => "approve", + PendingPayloadView::Message(_) => "signature", + } +} + +/// The selected pending request as **compact clear-signing** (DESIGN §The request-origin model): the +/// origin header (agent = cyan, you = amber) + the verb/amount + the fence that's holding it + the +/// irreversible caution. A read-only echo of the feed's review — the arm-delay confirm + resolve +/// stay on the feed, so the rail can never blind-approve. +fn request_rail_body( + rec: &ActivityRecord, + agent_handle: &str, + mask: bool, + theme: &Theme, +) -> AnyElement { + let fg = theme.foreground; + let muted = theme.muted_foreground; + let success = theme.success; + let warn = theme.warning; + let mono = theme.mono_font_family.clone(); + let amber = theme::amber(theme.is_dark()); + + // An agent proposes → the cyan origin header. An `App`-origin pending request is ambiguous — + // a foreground action OR a browser/dapp request the daemon always cards (`daemon.rs`) — and + // carries no domain on the record, so the rail stays NEUTRAL rather than claim a false amber + // human origin (two signal colors only). A precise dapp identity + trust badge lands when the + // bridge carries its origin (ADR-0001 / #44). + let header = match rec.origin { + ProposalOrigin::Agent => origin_header( + Origin::Agent { + handle: agent_handle, + }, + None, + theme, + ), + ProposalOrigin::App => neutral_request_header(theme), + }; + + // The verb is the sans label; the second line is the amount/object in mono (golden ref's + // `txlabel` + `txmini`) — for a Tx that's the value (+ recipient), never a re-stated verb. + let object_line = match &rec.payload { + PendingPayloadView::Tx(intent) => { + let amount = format!("{} ETH", masked_amount(intent.value, mask)); + match intent.kind { + IntentKind::Send => format!("{amount} → {}", short_address(&intent.to)), + _ => amount, + } + } + _ => payload_summary(&rec.payload, mask), + }; + let headline = v_flex() + .w_full() + .gap_1() + .child( + div() + .text_sm() + .text_color(muted) + .child(request_verb(&rec.payload)), + ) + .child( + div() + .w_full() + .min_w_0() + .truncate() + .font_family(mono.clone()) + .text_color(fg) + .child(object_line), + ) + .into_any_element(); + + // What's holding it: the ACTUAL breached fence (daemon-recomputed), or — for a guardrail hold + // that breached no cap — simply that it awaits approval. + let (fence_label, fence_val) = match cite_phrase(rec.reason) { + Some(phrase) => (cite_label(rec.reason), phrase), + None => ("Held for", "your approval"), + }; + let facts = v_flex().w_full().gap_2().child(kv_row( + fence_label, + KvValue::Sans(fence_val), + muted, + fg, + success, + warn, + mono.clone(), + )); + + v_flex() + .w_full() + .gap_4() + .child(header) + .child(headline) + .child(facts) + .child(caution_line(amber, fg, false, "This can't be undone.")) + .into_any_element() +} + +/// The most-recent broadcast transaction's **receipt** (compact): the actor identity, a status +/// glyph + one-line result (an executed shield reads "moved … to your private balance"), and the +/// facts the record actually carries — time + the tx hash. Block/fee aren't on an `ActivityRecord`, +/// so the rail never invents them. +fn tx_rail_body(rec: &ActivityRecord, agent_handle: &str, mask: bool, theme: &Theme) -> AnyElement { + let fg = theme.foreground; + let muted = theme.muted_foreground; + let success = theme.success; + let warn = theme.warning; + let mono = theme.mono_font_family.clone(); + let is_dark = theme.is_dark(); + + let actor = origin_subject(rec.origin, agent_handle); + let mark = if rec.origin == ProposalOrigin::Agent { + agent_mark( + actor, + crate::tokens::MARK_LG, + crate::tokens::RADIUS_ROW, + theme::agent(is_dark), + theme::agent_tint(is_dark), + ) + } else { + identity_mark( + actor, + crate::tokens::MARK_LG, + crate::tokens::RADIUS_ROW, + theme::identity_square(is_dark), + fg, + ) + }; + let sub = format!("{} · confirmed", action_word(&rec.payload)); + let obj = meta_obj(mark, actor, &sub, theme); + + let status = h_flex() + .w_full() + .items_center() + .gap_2() + .child(status_glyph(StatusGlyph::Confirmed, theme)) + .child( + div() + .min_w_0() + .truncate() + .text_sm() + .text_color(fg) + .child(settled_outcome_label(rec, mask)), + ) + .into_any_element(); + + let now = now_ms(); + let mut facts = v_flex().w_full().gap_2().child(kv_row( + "Time", + KvValue::Sans(&relative_time(rec.timestamp_ms, now)), + muted, + fg, + success, + warn, + mono.clone(), + )); + if let Some(hash) = rec.tx_hash { + facts = facts.child(kv_row( + "Hash", + KvValue::Mono(&short_tx(&hash)), + muted, + fg, + success, + warn, + mono.clone(), + )); + } + + v_flex() + .w_full() + .gap_4() + .child(obj) + .child(status) + .child(meta_section(None, facts.into_any_element(), theme)) + .into_any_element() +} + +/// A neutral "awaiting your approval" header for an ambiguous `App`-origin pending request. The +/// two-signal model reserves amber for a genuine human and cyan for an agent, so an origin the rail +/// can't attribute (foreground vs. browser bridge) gets neither — it mirrors `origin_header`'s +/// mark + line + bottom-hairline anatomy in neutral. +fn neutral_request_header(theme: &Theme) -> AnyElement { + let primary = theme.foreground; + let border = theme.border; + let id_fill = theme::identity_square(theme.is_dark()); + h_flex() + .w_full() + .items_center() + .gap_3() + .pb_4() + .border_b_1() + .border_color(border) + .child(identity_mark( + "Request", + crate::tokens::MARK_LG, + crate::tokens::RADIUS_ROW, + id_fill, + primary, + )) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .text_color(primary) + .child("Awaiting your approval"), + ) + .into_any_element() +} + +/// The quiet Activity rail when the feed is empty — nothing pending, nothing broadcast to detail. +fn empty_rail_body(theme: &Theme) -> AnyElement { + div() + .text_sm() + .text_color(theme.muted_foreground) + .child("No activity yet.") + .into_any_element() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/deckard-app/src/main.rs b/crates/deckard-app/src/main.rs index dfaaa18..39ab00e 100644 --- a/crates/deckard-app/src/main.rs +++ b/crates/deckard-app/src/main.rs @@ -23,6 +23,7 @@ mod settings; mod settings_view; mod shell; mod shell_chrome; +mod shell_rail; mod shield_view; mod signer; mod swap; @@ -222,11 +223,15 @@ fn main() { // 6. Open the window. `TitleBar::title_bar_options()` makes the title // bar transparent + insets the traffic lights so `Shell`'s custom // `TitleBar` element draws edge-to-edge underneath. - let bounds = Bounds::centered(None, size(px(880.0), px(620.0)), cx); + // Three-pane shell (E3, #183): 248px sidebar + main + a 300px always-on right + // metadata rail. The default + minimum widths give the centered 460px confirm card + // (`tokens::CONFIRM_W`) room to breathe beside both chrome columns (248 + 300 + 460 + + // padding), so a value move never clips at app width — the three-pane no-overflow AC. + let bounds = Bounds::centered(None, size(px(1200.0), px(760.0)), cx); let options = WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), titlebar: Some(TitleBar::title_bar_options()), - window_min_size: Some(size(px(560.0), px(420.0))), + window_min_size: Some(size(px(1100.0), px(560.0))), ..Default::default() }; diff --git a/crates/deckard-app/src/palette.rs b/crates/deckard-app/src/palette.rs index 060ed97..ae76661 100644 --- a/crates/deckard-app/src/palette.rs +++ b/crates/deckard-app/src/palette.rs @@ -211,11 +211,6 @@ impl Shell { )) .child(div().text_color(muted).child(name)) } - Selection::Project => row - .child(crate::widgets::identity_mark( - "Personal", sm, radius, id_square, fg, - )) - .child(div().text_color(muted).child("Personal")), Selection::Agent => { let handle = self.agent_handle(); row.child(crate::widgets::agent_mark( diff --git a/crates/deckard-app/src/shell.rs b/crates/deckard-app/src/shell.rs index 9153a3b..635897b 100644 --- a/crates/deckard-app/src/shell.rs +++ b/crates/deckard-app/src/shell.rs @@ -75,7 +75,6 @@ fn write_then_unlock( /// key-less automation on the same wallet EOA. #[derive(Clone, Copy, PartialEq, Eq)] pub enum Selection { - Project, Wallet, Agent, } @@ -3270,12 +3269,6 @@ impl Render for Shell { .overflow_y_scrollbar() .child(self.render_wallet_home(cx)) .into_any_element(), - (Selection::Project, Surface::Home) => div() - .id("scroll-project") - .size_full() - .overflow_y_scrollbar() - .child(self.render_project_home(cx)) - .into_any_element(), // The agent's own surface (DESIGN.md v2 §The agent interaction model): selected // from the sidebar Agents group. Rendered entirely from policy data + the agent's // activity slice. @@ -3290,24 +3283,42 @@ impl Render for Shell { .size_full() .child(title_bar) .child( - h_flex().size_full().child(self.render_sidebar(cx)).child( - // Fill the full pane height (like the sidebar's `.h_full()`): `h_flex` - // centers its children vertically, so without this the content column - // collapses to its intrinsic height and floats mid-pane — the - // breadcrumb, content, and bottom status strip then bunch up and overlap - // whenever a view is shorter than the viewport. - v_flex() - .flex_1() - .h_full() - .min_w_0() - .min_h_0() - .child(self.render_breadcrumb(cx)) - // The slot is a `v_flex`, not a plain `div` (gpui defaults to - // `display: block`): the centered Receive/Shield roots use `flex_1` + - // `justify_center`, which only fill + center inside a flex parent. - .child(v_flex().flex_1().min_h_0().child(content)) - .child(self.render_status_strip(cx)), - ), + // Three-pane shell (E3, #183): sidebar · main · the always-on right metadata + // rail. The rail is a fixed-width `flex_shrink_0` sibling (never collapsible), + // so the main column between them stays `flex_1` + `min_w_0` and content can + // run off neither edge — the no-horizontal-overflow invariant. + // + // `flex_1` + `min_h_0` (not `size_full`): the row must fill exactly the space + // BELOW the fixed title bar and never grow past it. Otherwise an over-tall + // surface (the wallet home, whose internal scroll is imperfect) stretches the + // row, pushing the sidebar's footer (Activity + Settings) and the status strip + // off the bottom on some surfaces but not others — the jarring "Settings comes + // and goes" bug. Clamped here, over-tall content scrolls inside the middle + // column and the three panes stay full-height on every surface. + h_flex() + .w_full() + .flex_1() + .min_h_0() + .child(self.render_sidebar(cx)) + .child( + // Fill the full pane height (like the sidebar's `.h_full()`): `h_flex` + // centers its children vertically, so without this the content column + // collapses to its intrinsic height and floats mid-pane — the + // breadcrumb, content, and bottom status strip then bunch up and overlap + // whenever a view is shorter than the viewport. + v_flex() + .flex_1() + .h_full() + .min_w_0() + .min_h_0() + .child(self.render_breadcrumb(cx)) + // The slot is a `v_flex`, not a plain `div` (gpui defaults to + // `display: block`): the centered Receive/Shield roots use `flex_1` + // + `justify_center`, which only fill + center inside a flex parent. + .child(v_flex().flex_1().min_h_0().child(content)) + .child(self.render_status_strip(cx)), + ) + .child(self.render_meta_rail(cx)), ) .children(self.palette_open.then(|| self.render_palette(cx))) .into_any_element() diff --git a/crates/deckard-app/src/shell_chrome.rs b/crates/deckard-app/src/shell_chrome.rs index 406f8bc..fe64975 100644 --- a/crates/deckard-app/src/shell_chrome.rs +++ b/crates/deckard-app/src/shell_chrome.rs @@ -40,22 +40,24 @@ impl Shell { } } - /// The entity the breadcrumb names (E2, #182): the agent handle on the agent home, "Personal" - /// on the project home, otherwise the wallet's name — the wallet is the entity every action - /// surface (Send/Receive/Shield/Swap/Settings) acts on. Drops the old `Personal ›` prefix and - /// the literal word Wallet. `is_agent` picks the cyan agent mark over the neutral identity mark. + /// The entity the breadcrumb names (E2, #182): the agent handle on the agent home, otherwise + /// the wallet's name — the wallet is the entity every action surface (Send/Receive/Shield/ + /// Swap/Settings) acts on. Drops the old `Personal ›` prefix and the literal word Wallet (the + /// Projects layer is gone as of E3, #183). `is_agent` picks the cyan agent mark over the + /// neutral identity mark. fn breadcrumb_entity(&self) -> (String, bool) { match (self.surface, self.selection) { (Surface::Home, Selection::Agent) => (self.agent_handle(), true), - (Surface::Home, Selection::Project) => ("Personal".to_string(), false), _ => (self.wallet_name(), false), } } - /// The hand-built sidebar tree: a PROJECTS label, one project row, a Wallets group + one - /// named wallet row, an Agents group + the first-class agent row (its cyan `agent_mark` + - /// handle + status), a flex spacer, an Activity ledger row, and a footer gear that opens - /// Settings. Neutral throughout except the agent's cyan mark. + /// The hand-built sidebar tree — a single column of the account's real entities (DESIGN §IA, + /// E3 #183: **no Projects layer**). Top-level groups are the things that actually exist: + /// **Wallets** (named wallet row + balance), **Agents** (the first-class cyan `agent_mark` + + /// handle + status), and **Connections** (dapp origins — a reserved, list-only slot; deep + /// connection management is deferred, ADR-0001 / #44). A flex spacer, then the footer Activity + /// ledger row + the gear that opens Settings. Neutral throughout except the agent's cyan mark. pub fn render_sidebar(&self, cx: &mut Context) -> impl IntoElement { let theme = cx.theme(); let fg = theme.foreground; @@ -72,8 +74,6 @@ impl Shell { let needs_you_count = crate::activity_view::activity_pending(&self.activity).len(); let activity_active = self.surface == Surface::Activity; - let project_selected = - self.surface == Surface::Home && self.selection == Selection::Project; let wallet_selected = self.surface == Surface::Home && self.selection == Selection::Wallet; let agent_selected = self.surface == Surface::Home && self.selection == Selection::Agent; let agent = theme::agent(is_dark); @@ -105,34 +105,7 @@ impl Shell { .bg(theme.sidebar) .border_r_1() .border_color(border) - // PROJECTS header. - .child(group_label("PROJECTS")) - // Project row. - .child( - div() - .id("nav-project") - .mx_2() - .px_2() - .py_1p5() - .rounded_md() - .when(project_selected, |e| e.bg(lift)) - .cursor_pointer() - .child( - h_flex() - .items_center() - .gap_2() - .child(crate::widgets::identity_mark( - "Personal", - crate::tokens::MARK_SM, - crate::tokens::RADIUS_ROW, - id_square, - fg, - )) - .child(div().text_sm().text_color(fg).child("Personal")), - ) - .on_click(cx.listener(|this, _, _, cx| this.select(Selection::Project, cx))), - ) - // Wallets group. + // Wallets group (DESIGN §IA — the first top-level entity; no Projects parent above it). .child(group_label("Wallets")) .child( div() @@ -209,6 +182,19 @@ impl Shell { ) .on_click(cx.listener(|this, _, _, cx| this.select(Selection::Agent, cx))), ) + // Connections group — dapp origins (DESIGN §IA). A reserved, list-only slot: the + // request-origin model exists, but deep connection management is deferred (ADR-0001 / + // #44), so the group announces itself with a quiet empty state until a bridged dapp + // origin appears. Non-interactive on purpose — nothing to select here yet. + .child(group_label("Connections")) + .child( + div().mx_2().px_2().py_1p5().child( + div() + .text_sm() + .text_color(muted) + .child("No connected sites yet"), + ), + ) // Spacer pushes the footer rows to the bottom. .child(div().flex_1()) // Activity ledger — a sibling of Settings (bottom): the full cross-agent record AND @@ -267,7 +253,7 @@ impl Shell { } /// The 44px breadcrumb bar: `[mark] ` on the left — the entity the current view is - /// about (`Meridian`, the agent `Kyoto`, or `Personal`), plus `› ` on an action surface + /// about (the wallet `Meridian` or the agent `Kyoto`), plus `› ` on an action surface /// (`Meridian › Send`). Identity is named (E2, #182): no `Personal ›` prefix, no literal Wallet. /// The neutral network pill + ⌘K affordance + theme toggle sit on the right. pub fn render_breadcrumb(&self, cx: &mut Context) -> impl IntoElement { diff --git a/crates/deckard-app/src/shell_rail.rs b/crates/deckard-app/src/shell_rail.rs new file mode 100644 index 0000000..644284e --- /dev/null +++ b/crates/deckard-app/src/shell_rail.rs @@ -0,0 +1,201 @@ +//! The always-on right metadata rail (E3, #183; DESIGN §Information architecture) — the third pane +//! of the shell, contextual to the focused object. It is **always on, never collapsible**: a +//! selected wallet shows its holdings/status + the agent cap ledger, a selected agent its fence, the +//! Activity surface the selected request's clear-signing or the latest transaction's receipt (that +//! last dispatch lives in `activity_view.rs`, beside the feed's own summary helpers, so it reuses +//! them rather than re-deriving). +//! +//! Composed entirely from the E1 rail primitives (`meta_rail`/`meta_section`/`meta_obj`/`kv_row`): +//! the rail clamps (each row `min_w_0` + truncate, the column `flex_shrink_0`), so content can never +//! run off the pane. It only ever *reads* state — no action lives here (approve/deny stay on the +//! feed), so it needs no ⌘K command of its own. + +use gpui::{div, AnyElement, Context, IntoElement, ParentElement, Styled}; +use gpui_component::{v_flex, ActiveTheme}; + +use crate::shell::{Selection, Shell, Surface}; +use crate::theme; +use crate::widgets::{kv_row, meta_obj, meta_rail, meta_section, KvValue}; + +impl Shell { + /// The right metadata rail, dispatched on what the shell has in focus. Always returns a body — + /// there is no "off" state (the rail is not collapsible) — so the wallet home, an action + /// surface, the agent, and Activity each get contextual detail. + pub fn render_meta_rail(&self, cx: &mut Context) -> AnyElement { + let (title, body): (&'static str, AnyElement) = match (self.selection, self.surface) { + // Activity owns its own dispatch (pending request → transaction → empty), next to the + // feed's summary helpers it reuses. + (_, Surface::Activity) => self.activity_rail(cx), + (Selection::Agent, Surface::Home) => ("This agent", self.agent_rail_body(cx)), + // The wallet is the focused entity on its home and on every action surface it hosts + // (Send/Receive/Shield/Swap/Settings) — the rail stays useful throughout a value move. + _ => ("This wallet", self.wallet_rail_body(cx)), + }; + let theme = cx.theme(); + meta_rail(title, body, theme) + } + + /// The focused wallet's rail: identity object + the honest facts the app actually holds + /// (balance, verified-read status, network — never an invented USD figure, DESIGN §Trust) + the + /// live agent cap ledger (the SAME `PolicyGet` fence the daemon enforces). + fn wallet_rail_body(&self, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let fg = theme.foreground; + let muted = theme.muted_foreground; + let success = theme.success; + let warn = theme.warning; + let mono = theme.mono_font_family.clone(); + let id_square = theme::identity_square(theme.is_dark()); + + let name = self.wallet_name(); + let addr = crate::widgets::short_addr(&self.display_address.to_checksum(None)); + let mark = crate::widgets::identity_mark( + &name, + crate::tokens::MARK_LG, + crate::tokens::RADIUS_ROW, + id_square, + fg, + ); + let obj = meta_obj(mark, &name, &addr, theme); + + let balance = self + .portfolio + .as_ref() + .map(|p| { + crate::money::mask_money( + self.mask, + &format!("{} ETH", deckard_core::format_amount(p.native_wei, 18, 4)), + ) + }) + .unwrap_or_else(|| "—".to_string()); + let synced = match self.synced_block { + Some(b) => format!("block {b}"), + None => "syncing…".to_string(), + }; + // Never claim "Verified" the read doesn't back, and never render a downgrade quiet (DESIGN + // §Trust rule 9): `Verified` is success-tinted, a `Degraded`/`Unsynced` read is the LOUD + // `warn` tag (matching the status strip), and a not-yet-synced read is a neutral dash. + let status_kv = match &self.read_status { + Some(deckard_core::ReadStatus::Verified) => KvValue::Ok("Verified"), + Some(deckard_core::ReadStatus::Degraded { .. }) => KvValue::Warn("Degraded"), + Some(deckard_core::ReadStatus::Unsynced { .. }) => KvValue::Warn("Not verified"), + None => KvValue::Sans("—"), + }; + let network = deckard_core::for_chain(self.chain_id()) + .map(|c| c.network_name) + .unwrap_or("Unknown network"); + + let kv = |label: &str, value: KvValue| { + kv_row(label, value, muted, fg, success, warn, mono.clone()) + }; + let facts = v_flex() + .w_full() + .gap_2() + .child(kv("Balance", KvValue::Mono(&balance))) + .child(kv("Synced", KvValue::Sans(&synced))) + .child(kv("Status", status_kv)) + .child(kv("Network", KvValue::Sans(network))) + .into_any_element(); + + let mut col = v_flex().w_full().gap_4().child(obj).child(facts); + + // Agent caps here — the live fence, never a hardcoded number (DESIGN §cap enforcement is + // real). Only when the daemon's policy has landed (it answers `PolicyGet` even while locked). + if let Some(p) = self.agent_policy.as_ref() { + // 6dp trims trailing zeros (`format_amount`), matching the daemon's canonical cap + // display in `agent_policy_rows`. + let eth = |wei| format!("{} ETH", deckard_core::format_amount(wei, 18, 6)); + let handle = self.agent_handle(); + let daily_label = format!("{handle} daily"); + let daily_val = format!( + "{} / {}", + crate::money::mask_money(self.mask, ð(p.spent_today_wei)), + eth(p.daily_cap_wei) + ); + // Honest per-tx: "no limit" / "denied" instead of a false "0 ETH" (shared helper). + let per_tx = crate::welcome::per_tx_cap_display(p); + let per_tx_val = if per_tx.ends_with("ETH") { + KvValue::Mono(&per_tx) + } else { + KvValue::Sans(&per_tx) + }; + let caps = v_flex() + .w_full() + .gap_2() + .child(kv(&daily_label, KvValue::Mono(&daily_val))) + .child(kv("Per-transaction", per_tx_val)) + .into_any_element(); + col = col.child(meta_section(Some("Agent caps"), caps, theme)); + + // The golden ref's trailing composition metasec. Only "Agents" is engine-backed today — + // the app models one agent, armed unless a STOP revoked it — so the "Connections" count + // is omitted (not invented) until the browser bridge lands (ADR-0001 / #44). + let agents = if p.revoked { "1 stopped" } else { "1 active" }; + let summary = v_flex() + .w_full() + .gap_2() + .child(kv("Agents", KvValue::Sans(agents))) + .into_any_element(); + col = col.child(meta_section(None, summary, theme)); + } + + col.into_any_element() + } + + /// The focused agent's rail (DESIGN keeps the agent surface light this pass): identity object + + /// the live policy fence, reusing the exact `agent_policy_rows` mapping the wallet-home agent + /// card renders, so the two never drift. + fn agent_rail_body(&self, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let fg = theme.foreground; + let muted = theme.muted_foreground; + let success = theme.success; + let warn = theme.warning; + let mono = theme.mono_font_family.clone(); + let is_dark = theme.is_dark(); + + let handle = self.agent_handle(); + let mark = crate::widgets::agent_mark( + &handle, + crate::tokens::MARK_LG, + crate::tokens::RADIUS_ROW, + theme::agent(is_dark), + theme::agent_tint(is_dark), + ); + let status = match self.agent_policy.as_ref() { + Some(p) if p.revoked => "stopped", + Some(_) => "acting", + None => "idle", + }; + let obj = meta_obj(mark, &handle, status, theme); + + let body = if let Some(p) = self.agent_policy.as_ref() { + let mut facts = v_flex().w_full().gap_2(); + for (label, value) in crate::welcome::agent_policy_rows(p, self.mask) { + facts = facts.child(kv_row( + label, + KvValue::Sans(&value), + muted, + fg, + success, + warn, + mono.clone(), + )); + } + facts.into_any_element() + } else { + div() + .text_sm() + .text_color(muted) + .child("Policy not loaded yet.") + .into_any_element() + }; + + v_flex() + .w_full() + .gap_4() + .child(obj) + .child(body) + .into_any_element() + } +} diff --git a/crates/deckard-app/src/welcome.rs b/crates/deckard-app/src/welcome.rs index 95ad479..5ebe592 100644 --- a/crates/deckard-app/src/welcome.rs +++ b/crates/deckard-app/src/welcome.rs @@ -33,6 +33,23 @@ struct Holding { max_frac: usize, } +/// The agent's per-transaction Send cap as an HONEST display string (DESIGN §Trust: the UI must +/// never claim a fence the engine doesn't set). `per_tx_cap_for` returns `None` in two opposite +/// cases, so a bare `unwrap_or(0)` would print a false "0 ETH": no Send rule at all (send is +/// denied) vs. a Send rule with no per-tx ceiling (capped only by the daily budget). Spell both out +/// instead of collapsing them to zero. Shared so the wallet-home card, the wallet rail, and the +/// agent rail all read the same fence. +pub(crate) fn per_tx_cap_display(p: &deckard_contract::Policy) -> String { + use deckard_contract::IntentKind; + if p.approval_for(IntentKind::Send).is_none() { + return "denied".to_string(); + } + match p.per_tx_cap_for(IntentKind::Send) { + Some(cap) => format!("{} ETH", deckard_core::format_amount(cap, 18, 6)), + None => "no limit".to_string(), + } +} + /// The agent policy card's rows, built from the daemon's LIVE policy — the same fence /// `deckard_policy_get` shows an MCP client. Pure so the mapping is testable: an empty /// allowlist honestly reads "any", the approval mode is spelled out, and a STOP @@ -45,12 +62,7 @@ pub(crate) fn agent_policy_rows( use deckard_contract::{Allowlist, ApprovalMode, IntentKind}; let eth = |wei: U256| format!("{} ETH", deckard_core::format_amount(wei, 18, 6)); vec![ - ( - "Per-transaction cap", - eth(p - .per_tx_cap_for(IntentKind::Send) - .unwrap_or(deckard_core::U256::ZERO)), - ), + ("Per-transaction cap", per_tx_cap_display(p)), ("Daily budget", format!("{} / day", eth(p.daily_cap_wei))), ( "Spent today", @@ -597,95 +609,6 @@ impl Shell { } col.into_any_element() } - - /// Project home — the aggregate-of-one for the demo's single project: the - /// wallet's balance plus a one-line composition (1 wallet · 1 agent). Real - /// multi-wallet aggregation is fast-follow. - pub fn render_project_home(&self, cx: &mut Context) -> impl IntoElement { - let theme = cx.theme(); - let fg = theme.foreground; - let muted = theme.muted_foreground; - let border = theme.border; - let mono: SharedString = theme.mono_font_family.clone(); - let id_square = theme::identity_square(theme.is_dark()); - let masked = self.mask; - - let native_wei = self.portfolio.as_ref().map(|p| p.native_wei); - - div() - .size_full() - .p_8() - // TODO(scroll): restore a scrollable main pane via a Stateful - // `div().id(..).overflow_y_scroll()` (the agent draft mis-ordered - // gpui-component's `overflow_y_scrollbar`). Content is short for now. - .child( - v_flex() - .items_start() - .max_w(px(680.0)) - .gap_6() - .child( - h_flex() - .items_center() - .gap_3() - .child(identity_mark("Personal", px(28.0), px(6.0), id_square, fg)) - .child( - div() - .text_xl() - .font_weight(FontWeight::SEMIBOLD) - .text_color(fg) - .child("Personal"), - ), - ) - .child( - v_flex() - .w_full() - .gap_3() - .child( - div() - .id("project-balance-hero") - .cursor_pointer() - .text_3xl() - .font_weight(FontWeight::SEMIBOLD) - .map(|el| match native_wei { - Some(wei) => el.child(money( - wei, - 18, - 4, - Some("ETH"), - masked, - mono.clone(), - fg, - muted, - )), - None => el - .font_family(mono.clone()) - .text_color(muted) - .child("Syncing…"), - }) - .on_click(cx.listener(|this, _, _, cx| this.toggle_mask(cx))), - ) - .children(native_wei.map(|_| { - allocation_bar( - vec![AllocSegment { - label: "Public".into(), - fraction: 1.0, - tone: id_square, - }], - masked, - border, - muted, - fg, - ) - })), - ) - .child( - div() - .text_sm() - .text_color(muted) - .child("1 wallet · 1 agent"), - ), - ) - } } /// One segment of the [`allocation_bar`]: a label, its share of the whole (0..=1), diff --git a/crates/deckard-app/src/widgets.rs b/crates/deckard-app/src/widgets.rs index 6580c54..4e0f828 100644 --- a/crates/deckard-app/src/widgets.rs +++ b/crates/deckard-app/src/widgets.rs @@ -393,7 +393,9 @@ pub(crate) fn action_tag(kind: ActionKind, raise: Hsla, border: Hsla, text: Hsla /// The state a [`status_glyph`] carries. #[derive(Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] // reason: variants selected per row/receipt by E6/E7 (#186/#187). +// reason: `Confirmed` is wired by the E3 transaction rail (#183); `Failed`/`Pending`/`Live`/ +// `Neutral` land with the E6/E7 feed + full receipt (#186/#187). +#[allow(dead_code)] pub(crate) enum StatusGlyph { /// Confirmed / approved / executed — a `success` check. Confirmed, @@ -410,7 +412,6 @@ pub(crate) enum StatusGlyph { /// The Lucide icon for a [`StatusGlyph`]. Pure so it is unit-testable. No `clock` ships in the /// icon set, so `Pending`/`Live` use the loader ring — the DESIGN "clock-ring = pending" intent /// (a ring, not a checkmark); the color separates awaiting-you (amber) from an agent (cyan). -#[allow(dead_code)] // reason: the icon half of `status_glyph`; consumed via `status_glyph`. fn status_icon(state: StatusGlyph) -> IconName { match state { StatusGlyph::Confirmed => IconName::CircleCheck, @@ -426,7 +427,6 @@ fn status_icon(state: StatusGlyph) -> IconName { /// The icon shape backs the color, so it survives grayscale. // reason: consumed by the v4 Activity feed + Transaction receipt (E6/E7, #186/#187); E1 lands one // glyph set so the feed + receipt stop re-rolling per-file status SVGs. -#[allow(dead_code)] pub(crate) fn status_glyph(state: StatusGlyph, theme: &Theme) -> AnyElement { let is_dark = theme.is_dark(); let tone = match state { @@ -442,32 +442,35 @@ pub(crate) fn status_glyph(state: StatusGlyph, theme: &Theme) -> AnyElement { .into_any_element() } -/// A [`kv_row`] value: mono by default, sans for a human phrase ("Ethereum · mainnet"), or -/// `success`-tinted for a verified / OK state. -#[allow(dead_code)] // reason: variants selected per fact by the rail / Review (E3/E5). +/// A [`kv_row`] value: mono by default, sans for a human phrase ("Ethereum · mainnet"), +/// `success`-tinted for a verified / OK state, or `warn`-tinted for a loud trust downgrade +/// ("Not verified" — DESIGN §Trust rule 9: a downgrade is never rendered quiet). pub(crate) enum KvValue<'a> { Mono(&'a str), Sans(&'a str), Ok(&'a str), + Warn(&'a str), } /// The ONE key/value row (DESIGN §Widget vocabulary): label-left `text.muted`, value-right (mono -/// `text.primary`, or sans, or `success`), the row clamped so a long value truncates rather than -/// overflowing. Shared by clear-signing quiet-facts, the policy ledger, and the metadata rail. +/// `text.primary`, or sans, or `success`, or `warn` for a loud downgrade), the row clamped so a long +/// value truncates rather than overflowing. Shared by clear-signing quiet-facts, the policy ledger, +/// and the metadata rail. // reason: consumed by the v4 metadata rail (E3, #183) + the Review quiet facts (E5, #185). -#[allow(dead_code)] pub(crate) fn kv_row( label: &str, value: KvValue, muted: Hsla, primary: Hsla, success: Hsla, + warn: Hsla, mono: SharedString, ) -> AnyElement { let (text, is_mono, color) = match value { KvValue::Mono(v) => (v, true, primary), KvValue::Sans(v) => (v, false, primary), KvValue::Ok(v) => (v, false, success), + KvValue::Warn(v) => (v, false, warn), }; h_flex() .w_full() @@ -539,7 +542,9 @@ pub(crate) fn page_header( /// Who a shared-Review request came FROM (DESIGN §The request-origin model): the human, an agent, /// or a dapp. The verb is the human's action (`You are sending`); agents `propose`, dapps `request`. -#[allow(dead_code)] // reason: constructed per request by the shared Review (E5, #185) + rail (E3). +// reason: `You`/`Agent` are wired by the E3 request rail (#183); `Dapp` lands with the browser +// bridge origin (ADR-0001 / #44). +#[allow(dead_code)] pub(crate) enum Origin<'a> { /// The human principal — a round identity mark (`account` seeds it) + amber `You are {verb}`. You { account: &'a str, verb: &'a str }, @@ -605,7 +610,6 @@ fn trust_badge(trust: Trust, theme: &Theme) -> AnyElement { /// signal color; the agent mark is the bordered cyan squircle ([`agent_mark`], handle-aware). /// `You` is amber, an agent is cyan, a dapp is neutral. // reason: consumed by the ONE shared Review (E5, #185) + the rail's compact clear-signing (E3). -#[allow(dead_code)] pub(crate) fn origin_header(origin: Origin, trust: Option, theme: &Theme) -> AnyElement { let is_dark = theme.is_dark(); let amber = crate::theme::amber(is_dark); @@ -775,7 +779,6 @@ pub(crate) fn balance_diff(rows: &[DiffRow], theme: &Theme) -> AnyElement { /// collapsible; contextual to the focused object"). A fixed-width column — a titled 48px head over /// a scrollable body. E3 fills `body` with `meta_section` / `meta_obj` / `kv_row` blocks. // reason: consumed by the three-pane shell (E3, #183) — home / request / transaction rail bodies. -#[allow(dead_code)] pub(crate) fn meta_rail(title: &str, body: AnyElement, theme: &Theme) -> AnyElement { let border = theme.border; let rail_bg = theme.sidebar; // bg.rail @@ -819,7 +822,6 @@ pub(crate) fn meta_rail(title: &str, body: AnyElement, theme: &Theme) -> AnyElem /// A ruled sub-section inside [`meta_rail`] (DESIGN: a `.metasec` — a top hairline + an optional /// `section_label` + body). Groups a set of `kv_row`s under a quiet label. // reason: consumed by the three-pane shell rail bodies (E3, #183). -#[allow(dead_code)] pub(crate) fn meta_section(label: Option<&str>, body: AnyElement, theme: &Theme) -> AnyElement { let border = theme.border; let muted = theme.muted_foreground; @@ -837,7 +839,6 @@ pub(crate) fn meta_section(label: Option<&str>, body: AnyElement, theme: &Theme) /// The identity object at the top of a rail body (DESIGN: a `.metaobj` — a caller-built `mark` + /// name (600) + a mono sub-line, e.g. the truncated address or `shield · confirmed`). // reason: consumed by the three-pane shell rail bodies (E3, #183). -#[allow(dead_code)] pub(crate) fn meta_obj(mark: AnyElement, name: &str, sub: &str, theme: &Theme) -> AnyElement { let fg = theme.foreground; let muted = theme.muted_foreground; @@ -848,7 +849,12 @@ pub(crate) fn meta_obj(mark: AnyElement, name: &str, sub: &str, theme: &Theme) - .gap_3() .child(mark) .child( + // `flex_1` so the text column fills the row's remaining width: a `truncate` sub whose + // min-content is 0 would otherwise let the column shrink to the (shorter) name and clip + // the wider mono address to a second ellipsis (the E2 masthead bug, #192). Now a short + // address renders in full and only genuinely over-wide content clamps. v_flex() + .flex_1() .min_w_0() .gap_0p5() .child(