Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 27 additions & 62 deletions crates/deckard-app/src/activity_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Self>) -> impl IntoElement {
let theme = cx.theme();
let fg = theme.foreground;
let muted = theme.muted_foreground;
let raise = theme.secondary;
let danger = theme.danger;
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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<Self>,
) -> 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<Self>) -> 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<Self>) -> 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()
}
}

Expand Down
9 changes: 9 additions & 0 deletions crates/deckard-app/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
77 changes: 75 additions & 2 deletions crates/deckard-app/src/welcome.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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<Self>) -> 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(
Expand Down
35 changes: 29 additions & 6 deletions crates/deckard-app/src/widgets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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)),
Expand Down Expand Up @@ -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");
Expand Down
Loading