diff --git a/crates/deckard-app/src/activity_view.rs b/crates/deckard-app/src/activity_view.rs index f6e7515..2efd4bb 100644 --- a/crates/deckard-app/src/activity_view.rs +++ b/crates/deckard-app/src/activity_view.rs @@ -319,7 +319,6 @@ impl Shell { /// The feed: heading + STOP control + (loading skeleton / error / empty / day-grouped rows). fn render_activity_feed(&self, cx: &mut Context) -> impl IntoElement { let theme = cx.theme(); - let fg = theme.foreground; let muted = theme.muted_foreground; let raise = theme.secondary; let danger = theme.danger; @@ -382,7 +381,7 @@ impl Shell { // the panic brake is always on screen no matter how far the log scrolls. Only the post-STOP // banner + the feed body scroll beneath it. (Use the Copy color locals, not `theme`, so its // cx borrow doesn't outlive the cx-mutable `activity_heading`/`activity_group` calls above.) - let heading = self.activity_heading(fg, muted, cx); + let heading = self.activity_heading(muted, cx); let mut scroll_body = v_flex().w_full().gap_4(); if self.activity_stopped { scroll_body = scroll_body.child(stopped_banner(danger, raise)); @@ -414,80 +413,46 @@ impl Shell { ) } - /// The page heading row: H1 + a persistent session-scope sub-line on the left, the STOP - /// control on the right (amber when idle, escalating to red when armed). - fn activity_heading( - &self, - fg: gpui::Hsla, - muted: gpui::Hsla, - cx: &mut Context, - ) -> impl IntoElement { + /// The page heading row (golden ref `.acthead`): a quiet uppercase `ACTIVITY` label on one + /// baseline with the STOP control pushed to the right — no hero title, no explainer subtitle. + /// The kill-switch carries the state (grey when no agent runs, amber when one does, red armed). + fn activity_heading(&self, muted: gpui::Hsla, cx: &mut Context) -> impl IntoElement { h_flex() .w_full() - .items_start() + .items_center() .justify_between() .gap_4() - .child( - v_flex() - .min_w_0() - .gap_1() - .child( - div() - .text_xl() - .font_weight(FontWeight::SEMIBOLD) - .text_color(fg) - .child("Activity"), - ) - .child(div().text_sm().text_color(muted).child(format!( - "What {} and you did this session. Watch it, and stop it.", - self.agent_handle() - ))), - ) + .child(crate::widgets::section_label("Activity", muted)) .child(self.activity_stop_control(cx)) } - /// The STOP control — the always-reachable panic brake. Amber outline "STOP" when idle (a - /// human "where you are" action); a red outline "Confirm STOP — revoke & lock signing · Esc to - /// cancel" once armed. **Click-to-arm** (NOT hold): two deliberate clicks (or ⌘K → "STOP") fire - /// it — never a single click, since it zeroizes the key. Esc disarms (handled in - /// `on_activity_key`). + /// The STOP control — the shared kill-switch treatment (`widgets::stop_brake`), driven by the + /// agent-active + arm state. Three states: **Idle** ("No agents running", grey, disabled) when + /// nothing runs; **Ready** ("Stop all agents", amber) while an agent is live — a human "your + /// call" action; **Armed** ("Confirm STOP…", red) after the first press. **Click-to-arm** (NOT + /// hold): two deliberate clicks (or ⌘K → "STOP") fire it — never a single click, since it + /// zeroizes the key. Esc disarms (handled in `on_activity_key`). "Agent live" = a policy exists + /// and hasn't been revoked — the same signal the rail reads. STOP stays reachable via ⌘K even + /// when this header marker is the disabled idle variant. fn activity_stop_control(&self, cx: &mut Context) -> impl IntoElement { - let theme = cx.theme(); - let amber = theme::amber(theme.is_dark()); - let danger = theme.danger; - - if self.activity_stop_arming { - // Armed: escalate from the amber idle outline to a red outline + label — a clear - // "this is the irreversible one" signal, using only the theme's `danger` color. - div() - .id("activity-stop") - .flex_shrink_0() - .px_3() - .py_1p5() - .rounded(crate::tokens::RADIUS_ROW) - .border_1() - .border_color(danger) - .text_color(danger) - .text_sm() - .font_weight(FontWeight::SEMIBOLD) - .cursor_pointer() - .child("Confirm STOP: revoke & lock signing · Esc to cancel") - .on_click(cx.listener(|this, _, _, cx| this.stop_button_clicked(cx))) + use crate::widgets::BrakeState; + // `has_active_agent` is the ONE shared predicate (also read by the wallet-home presence + // row), so the two surfaces never disagree; `brake_state` folds it with the arm flag. + let state = crate::widgets::brake_state(self.activity_stop_arming, self.has_active_agent()); + let brake = crate::widgets::stop_brake(state, cx.theme()); + + if matches!(state, BrakeState::Idle) { + // Idle is a disabled marker (golden ref `.killswitch.idle`: `cursor:default`, no + // handler) — the brake only arms and zeroizes while an agent is actually live. + div().flex_shrink_0().child(brake).into_any_element() } else { div() .id("activity-stop") .flex_shrink_0() - .px_3() - .py_1p5() - .rounded(crate::tokens::RADIUS_ROW) - .border_1() - .border_color(amber) - .text_color(amber) - .text_sm() - .font_weight(FontWeight::SEMIBOLD) .cursor_pointer() - .child("STOP") + .child(brake) .on_click(cx.listener(|this, _, _, cx| this.stop_button_clicked(cx))) + .into_any_element() } } diff --git a/crates/deckard-app/src/shell.rs b/crates/deckard-app/src/shell.rs index 635897b..c2ceb3e 100644 --- a/crates/deckard-app/src/shell.rs +++ b/crates/deckard-app/src/shell.rs @@ -1422,6 +1422,15 @@ impl Shell { crate::names::default_agent_handle(0).to_string() } + /// Whether an agent is live right now: a policy exists and hasn't been revoked. The ONE source + /// for "is the agent acting" — the wallet-home presence row ("acting" vs "idle") and the + /// Activity kill-switch (amber "Stop all agents" vs the disabled "No agents running") both read + /// it, so the two surfaces can never contradict each other. A revoked policy reads as not-live, + /// matching the rail's "stopped" and the daemon's `revoked` flag. + pub(crate) fn has_active_agent(&self) -> bool { + matches!(self.agent_policy.as_ref(), Some(p) if !p.revoked) + } + /// The chain the daemon signs for (resolved once at startup). The swap surface reads it to /// pick the curated token list, the orderbook base, and the per-chain swatch. pub fn chain_id(&self) -> u64 { diff --git a/crates/deckard-app/src/welcome.rs b/crates/deckard-app/src/welcome.rs index ec8d235..83ac7b7 100644 --- a/crates/deckard-app/src/welcome.rs +++ b/crates/deckard-app/src/welcome.rs @@ -262,6 +262,9 @@ impl Shell { .on_click(cx.listener(|this, _, _, cx| this.open_swap(cx))), ), ) + // The one-line "Waiting on you" strip (E6, #186): a calm caught-up line, or + // an amber "N waiting for you · Review →" that jumps to the Activity queue. + .child(self.render_waiting_strip(cx)) // Holdings, or a state. .child(self.render_holdings(first_sync, has_tokens, holdings, cx)) // Compact agent presence — ONE clickable agent row that opens the agent @@ -312,7 +315,11 @@ impl Shell { format!("{pct}%"), )) }); + // `has_policy` drives the GAUGE/chevron layout (a gauge exists whenever a policy does, even + // a revoked one). `has_active_agent` drives the STATUS word — a revoked agent reads "idle", + // not "acting", so this row agrees with the Activity kill-switch (both read one predicate). let has_policy = self.agent_policy.is_some(); + let has_active_agent = self.has_active_agent(); // The agent presence is a SECTION, not a card (DESIGN editorial rule: no bordered/filled // box to group content). A top hairline + margin sets it off from the holdings above. @@ -349,8 +356,9 @@ impl Shell { .text_color(fg) .child(agent_handle), ) - // Status: "acting" (cyan) when the policy is live, a muted "idle" otherwise. - .child(if has_policy { + // Status: "acting" (cyan) while the agent is live, a muted "idle" once it's + // revoked or absent — the same predicate the Activity kill-switch reads. + .child(if has_active_agent { div().text_xs().text_color(agent).child("acting") } else { div().text_xs().text_color(muted).child("idle") @@ -516,6 +524,71 @@ impl Shell { .into_any_element() } + /// The one-line "Waiting on you" strip (golden ref `.waitstrip`, E6 #186): a calm caught-up + /// line when nothing is pending, or an amber "N waiting for you · Review →" that jumps to the + /// Activity queue when the agent (or a dapp) has left requests for you. ONE line, hairline top + /// and bottom — never a stacked band (the full triage band lives in Activity as NEEDS YOU). The + /// count is `activity_pending`, the exact set the Activity NEEDS YOU band shows. + fn render_waiting_strip(&self, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let amber = theme::amber(theme.is_dark()); + let muted = theme.muted_foreground; + let success = theme.success; + let hairline = theme.border; + let pending = crate::activity_view::activity_pending(&self.activity).len(); + + let row = h_flex() + .w_full() + .items_center() + .gap_2p5() + .py_3() + .border_t_1() + .border_b_1(); + + if pending == 0 { + // Caught up: a quiet success dot + one muted line, bordered by the plain hairline. + row.border_color(hairline) + .child(div().size(px(6.0)).rounded_full().bg(success)) + .child( + div() + .text_sm() + .text_color(muted) + .child("Nothing waiting for you."), + ) + .into_any_element() + } else { + // Pending: amber dot + "N waiting for you", a right-anchored "Review →" that opens the + // Activity queue, and amber-tinted top/bottom edges (the human-attention signal). + let lead = if pending == 1 { + "1 waiting for you".to_string() + } else { + format!("{pending} waiting for you") + }; + row.border_color(amber.opacity(0.45)) + .child(div().size(px(6.0)).rounded_full().bg(amber)) + .child( + div() + .flex_1() + .text_sm() + .font_weight(FontWeight::SEMIBOLD) + .text_color(amber) + .child(lead), + ) + .child( + div() + .id("home-waiting-review") + .flex_shrink_0() + .cursor_pointer() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(amber) + .child("Review →") + .on_click(cx.listener(|this, _, _, cx| this.open(Surface::Activity, cx))), + ) + .into_any_element() + } + } + /// The holdings region: skeleton on first sync, empty-state when nothing held, /// otherwise the live rows plus the listed-tokens-only caveat. fn render_holdings( diff --git a/crates/deckard-app/src/widgets.rs b/crates/deckard-app/src/widgets.rs index 19b65ea..5779cc4 100644 --- a/crates/deckard-app/src/widgets.rs +++ b/crates/deckard-app/src/widgets.rs @@ -878,8 +878,7 @@ pub(crate) fn meta_obj(mark: AnyElement, name: &str, sub: &str, theme: &Theme) - } /// The STOP brake state (DESIGN §Widget vocabulary + §Agent model). -#[derive(Clone, Copy, PartialEq, Eq)] -#[allow(dead_code)] // reason: driven by agent-active + arm state in the Activity header (E6, #186). +#[derive(Clone, Copy, PartialEq, Eq, Debug)] pub(crate) enum BrakeState { /// An agent is active, not yet armed — amber, `Stop all agents`. Ready, @@ -893,9 +892,22 @@ pub(crate) enum BrakeState { /// across Activity + the agent surface. Text-only (no `power` icon ships, and the app's existing /// STOP is text), the always-reachable panic brake; the view owns the `⌘↵`/Esc arm handlers and /// the STOP-zeroizes-the-key logic — this renders only its state. -// reason: consumed by the v4 Activity header + agent surface (E6, #186) to replace the two -// hand-rolled STOP controls with one widget. -#[allow(dead_code)] +/// Map the two view flags to a brake state. **Arming wins over everything** — once you've started +/// a STOP you can always confirm it, even if the agent goes away underneath you — then Ready only +/// while an agent is actually live, else the disabled Idle marker. Pure so the precedence is +/// unit-testable and can't silently drift under a refactor. +pub(crate) fn brake_state(arming: bool, has_active_agent: bool) -> BrakeState { + if arming { + BrakeState::Armed + } else if has_active_agent { + BrakeState::Ready + } else { + BrakeState::Idle + } +} + +// reason: consumed by the v4 Activity header (E6, #186); the agent surface keeps its own +// hand-rolled STOP until #167 folds it onto this widget too. pub(crate) fn stop_brake(state: BrakeState, theme: &Theme) -> AnyElement { let is_dark = theme.is_dark(); let amber = crate::theme::amber(is_dark); @@ -906,7 +918,7 @@ pub(crate) fn stop_brake(state: BrakeState, theme: &Theme) -> AnyElement { let (label, edge, text, fill) = match state { BrakeState::Ready => ("Stop all agents", amber, amber, Some(amber_tint)), BrakeState::Armed => ( - "Confirm STOP", + "Confirm STOP: revoke & lock signing · Esc to cancel", danger, danger, Some(danger.opacity(crate::tokens::ALPHA_TINT)), @@ -953,6 +965,17 @@ mod tests { assert_eq!(key_cap_label(KeyCap::Key("X"), "linux"), "X"); } + #[test] + fn brake_state_arm_beats_agent_state() { + // Arming wins even if the agent went away underneath you — you can always confirm a STOP + // you started (the irreversible one must never get stuck half-armed). + assert_eq!(brake_state(true, true), BrakeState::Armed); + assert_eq!(brake_state(true, false), BrakeState::Armed); + // Not arming: amber Ready only while an agent is live, else the disabled Idle marker. + assert_eq!(brake_state(false, true), BrakeState::Ready); + assert_eq!(brake_state(false, false), BrakeState::Idle); + } + #[test] fn action_tags_are_uppercase_verbs() { assert_eq!(action_label(ActionKind::Swap), "SWAP");