From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
From: lucascouts <lucascs@protonmail.com>
Date: Mon, 9 Nov 2026 17:34:06 -0300
Subject: [PATCH] agent_ui: the pending question card minimizes to a one-line
 bar
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Wave 2 of the Zeo next proposal (docs/ZEO-NEXT-PROPOSAL.md §5.5, ask 9).
Patch 0027 pins the agent's pending question above the composer, which
is right while it is being answered and costs a third of the panel while
the operator reads the conversation to decide what to answer.

A chevron on the card collapses it into one line above the composer —
"Question pending", or "Question pending · N questions" when several
wait — and the bar's chevron restores it. `agent::ToggleQuestionCard`
does the same from the keyboard (no default binding), and both controls
carry an accessible label and a tooltip naming the action.

The state lives on the ThreadView, so it is per thread, and it returns
to open as soon as no question is pending: the next question must never
arrive hidden. The questions themselves are untouched while minimized —
still pending, still blocking the turn — and 0043's Enter rule is
unchanged.

Tests: thread_view tests/question_card_minimize_tests.rs — the card
minimizes to the bar and comes back; a minimized card is open again once
the question is answered. Verified 2026-11-09 in the prepared d2a4fb6
tree: cargo check --tests, cargo clippy --tests and cargo fmt --check
clean on agent_ui; the 79 elicitation tests still pass. The bar's look
is for an Xvfb capture.

Fixed in Zeo 0.9.1 (operator report, 2026-11-09): the minimize control was
drawn absolutely over the pinned wrapper's top-right corner, outside the
card's border (the card has a horizontal margin). It is now the card's own
header control, after the "Waiting for input" label, through
ElicitationCard::header_end; the label moves left to make room. Cards in
the conversation pass no control and render as before.

Test the_minimize_control_sits_inside_the_card_header (seen failing first)
checks the control's bounds lie within the header's.
---

diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index c589882..f1f0799 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/crates/agent_ui/src/agent_ui.rs
@@ -453,6 +453,8 @@ actions!(
         ImportThreadsFromOtherChannels,
         /// Starts a new terminal thread.
         NewTerminalThread,
+        /// Minimizes the pending question card to a one-line bar, or restores it.
+        ToggleQuestionCard,
     ]
 );
 
diff --git a/crates/agent_ui/src/conversation_view/elicitation.rs b/crates/agent_ui/src/conversation_view/elicitation.rs
index 1c0716e..6afaab1 100644
--- a/crates/agent_ui/src/conversation_view/elicitation.rs
+++ b/crates/agent_ui/src/conversation_view/elicitation.rs
@@ -2741,6 +2741,7 @@ pub(crate) struct ElicitationCard<'a> {
     handlers: ElicitationCardHandlers,
     paginated: bool,
     queue_position: Option<(usize, usize)>,
+    header_end: Option<AnyElement>,
 }
 
 impl<'a> ElicitationCard<'a> {
@@ -2759,9 +2760,17 @@ impl<'a> ElicitationCard<'a> {
             handlers,
             paginated: false,
             queue_position: None,
+            header_end: None,
         }
     }
 
+    /// A control drawn at the end of the header, after the status label, inside the card's
+    /// border: the pinned card's minimize (Zeo next proposal §5.5).
+    pub(crate) fn header_end(mut self, control: AnyElement) -> Self {
+        self.header_end = Some(control);
+        self
+    }
+
     /// `(position, total)` of this card among the questions queued behind one
     /// another. Shown only when there is more than one: a lone question has
     /// nothing to count.
@@ -2799,7 +2808,8 @@ impl<'a> ElicitationCard<'a> {
         (pages.len() > 1).then(|| (state.page(), pages))
     }
 
-    pub(crate) fn render(self, cx: &App) -> Div {
+    pub(crate) fn render(mut self, cx: &App) -> Div {
+        let header_end = self.header_end.take();
         let border_color = cx.theme().colors().border.opacity(0.8);
         let header_background = cx
             .theme()
@@ -2850,6 +2860,7 @@ impl<'a> ElicitationCard<'a> {
             .overflow_hidden()
             .child(
                 h_flex()
+                    .debug_selector(|| "elicitation-card-header".into())
                     .h_8()
                     .p_1()
                     .w_full()
@@ -2885,7 +2896,8 @@ impl<'a> ElicitationCard<'a> {
                                 Label::new(status_label)
                                     .size(LabelSize::Small)
                                     .color(Color::Muted),
-                            ),
+                            )
+                            .children(header_end),
                     ),
             )
             .child(body)
diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs
index cdc6dc0..1ee2dce 100644
--- a/crates/agent_ui/src/conversation_view/thread_view.rs
+++ b/crates/agent_ui/src/conversation_view/thread_view.rs
@@ -804,6 +804,9 @@ pub struct ThreadView {
     /// Decline on the same card opens no second prompt.
     open_decline_confirmations: HashSet<ElicitationEntryId>,
     question_queue: QuestionQueue,
+    /// The pending question card is minimized to its bar (Zeo next proposal §5.5). Per
+    /// thread, and back to open once no question is pending, so the next one shows.
+    pub(crate) question_card_minimized: bool,
     /// The entry count when auto-scroll was paused (Zeo next proposal §5.8), or `None` while
     /// the view follows the tail. Per thread: each thread view holds its own.
     pub(crate) auto_scroll_paused_at: Option<usize>,
@@ -1348,6 +1351,7 @@ impl ThreadView {
             elicitation_form_states: HashMap::default(),
             open_decline_confirmations: HashSet::default(),
             question_queue: QuestionQueue::default(),
+            question_card_minimized: false,
             auto_scroll_paused_at: None,
             _cancel_task: None,
             _save_task: None,
@@ -9347,12 +9351,37 @@ impl ThreadView {
             .map(|elicitation| &elicitation.id)
             .collect::<Vec<_>>();
         let queue_position = self.question_queue.advance(&pending);
+        let pending_count = pending.len();
+        if pending_count == 0 {
+            self.question_card_minimized = false;
+        }
+        if self.question_card_minimized {
+            return Some(Self::render_question_card_bar(pending_count, cx));
+        }
 
         let (ix, elicitation) = thread
             .elicitations()
             .iter()
             .enumerate()
             .find(|(_, elicitation)| Self::renders_elicitation_as_card(elicitation, cx))?;
+        let minimize = div()
+            .debug_selector(|| "question-card-minimize".into())
+            .child(
+                IconButton::new("minimize-question-card", IconName::ChevronDown)
+                    .icon_size(IconSize::Small)
+                    .icon_color(Color::Muted)
+                    .aria_label("Minimize the question card")
+                    .tooltip(Tooltip::for_action_title(
+                        "Minimize Question",
+                        &crate::ToggleQuestionCard,
+                    ))
+                    .on_click(cx.listener(|this, _, _, cx| {
+                        this.toggle_question_card(&crate::ToggleQuestionCard, cx)
+                    })),
+            )
+            .into_any_element();
+        // Inside the card's header, after its status label -- not over the card's corner,
+        // where it sat outside the border (operator, 2026-11-09).
         let card = ElicitationCard::new(
             ix,
             elicitation,
@@ -9362,6 +9391,7 @@ impl ThreadView {
         )
         .paginated(true)
         .queue_position(queue_position)
+        .header_end(minimize)
         .render(cx);
 
         Some(
@@ -9372,6 +9402,60 @@ impl ThreadView {
         )
     }
 
+    /// The minimized question card: one line above the composer (§5.5). The questions stay
+    /// pending and 0043's Enter rule is unchanged; this only frees the space they took.
+    fn render_question_card_bar(pending_count: usize, cx: &Context<Self>) -> AnyElement {
+        let label = if pending_count == 1 {
+            "Question pending".to_string()
+        } else {
+            format!("Question pending · {pending_count} questions")
+        };
+        h_flex()
+            .id("question-card-bar")
+            .debug_selector(|| "question-card-bar".into())
+            .w_full()
+            .px_2()
+            .py_1()
+            .gap_2()
+            .justify_between()
+            .border_t_1()
+            .border_color(cx.theme().colors().border)
+            .bg(cx.theme().colors().editor_background)
+            .child(
+                h_flex()
+                    .gap_1p5()
+                    .child(
+                        Icon::new(IconName::Info)
+                            .size(IconSize::Small)
+                            .color(Color::Accent),
+                    )
+                    .child(Label::new(label).size(LabelSize::Small)),
+            )
+            .child(
+                IconButton::new("expand-question-card", IconName::ChevronUp)
+                    .icon_size(IconSize::Small)
+                    .icon_color(Color::Muted)
+                    .aria_label("Show the pending question")
+                    .tooltip(Tooltip::for_action_title(
+                        "Show Question",
+                        &crate::ToggleQuestionCard,
+                    ))
+                    .on_click(cx.listener(|this, _, _, cx| {
+                        this.toggle_question_card(&crate::ToggleQuestionCard, cx)
+                    })),
+            )
+            .into_any_element()
+    }
+
+    pub(crate) fn toggle_question_card(
+        &mut self,
+        _: &crate::ToggleQuestionCard,
+        cx: &mut Context<Self>,
+    ) {
+        self.question_card_minimized = !self.question_card_minimized;
+        cx.notify();
+    }
+
     fn render_request_elicitations(&self, cx: &Context<Self>) -> Vec<AnyElement> {
         let server_view = self.server_view.clone();
         let handlers_view = server_view.clone();
@@ -15315,6 +15399,9 @@ impl Render for ThreadView {
             .on_action(cx.listener(Self::open_permission_dropdown))
             .on_action(cx.listener(Self::open_add_context_menu))
             .on_action(cx.listener(Self::scroll_output_page_up))
+            .on_action(
+                cx.listener(|this, action, _window, cx| this.toggle_question_card(action, cx)),
+            )
             .on_action(cx.listener(Self::scroll_output_page_down))
             .on_action(cx.listener(Self::scroll_output_line_up))
             .on_action(cx.listener(Self::scroll_output_line_down))
@@ -15936,6 +16023,8 @@ mod tests {
 
     mod decline_contract_tests;
 
+    mod question_card_minimize_tests;
+
     use super::*;
     use gpui::UpdateGlobal;
     use project::{FakeFs, Project};
diff --git a/crates/agent_ui/src/conversation_view/thread_view/tests/question_card_minimize_tests.rs b/crates/agent_ui/src/conversation_view/thread_view/tests/question_card_minimize_tests.rs
new file mode 100644
index 0000000..182d843
--- /dev/null
+++ b/crates/agent_ui/src/conversation_view/thread_view/tests/question_card_minimize_tests.rs
@@ -0,0 +1,76 @@
+// Zeo next proposal §5.5 (ask 9): the pending question card minimizes to a one-line bar and
+// comes back; the state is per thread and returns to open once no question is pending.
+
+use gpui::TestAppContext;
+
+use super::elicitation_decline_harness::*;
+
+const BAR: &str = "question-card-bar";
+
+#[gpui::test]
+async fn the_question_card_minimizes_to_a_bar_and_comes_back(cx: &mut TestAppContext) {
+    let (view, thread, mut cx) = setup(cx).await;
+    ask(&thread, one_question(), "First?", &mut cx);
+    ask(&thread, one_question(), "Second?", &mut cx);
+    draw(&mut cx);
+    assert!(cx.debug_bounds(BAR).is_none(), "the card starts open");
+
+    view.update(&mut cx, |view, cx| {
+        view.toggle_question_card(&crate::ToggleQuestionCard, cx)
+    });
+    draw(&mut cx);
+    assert!(cx.debug_bounds(BAR).is_some(), "minimized: the bar shows");
+
+    view.update(&mut cx, |view, cx| {
+        view.toggle_question_card(&crate::ToggleQuestionCard, cx)
+    });
+    draw(&mut cx);
+    assert!(
+        cx.debug_bounds(BAR).is_none(),
+        "restored: the card shows again"
+    );
+}
+
+#[gpui::test]
+async fn a_minimized_card_opens_again_once_nothing_is_pending(cx: &mut TestAppContext) {
+    let (view, thread, mut cx) = setup(cx).await;
+    let question = ask(&thread, one_question(), "Only?", &mut cx);
+    draw(&mut cx);
+    view.update(&mut cx, |view, cx| {
+        view.toggle_question_card(&crate::ToggleQuestionCard, cx)
+    });
+    draw(&mut cx);
+
+    let id = question.id;
+    view.update_in(&mut cx, |view, window, cx| {
+        view.cancel_elicitation(id, window, cx)
+    });
+    settle(&mut cx);
+    draw(&mut cx);
+
+    assert!(
+        !view.read_with(&cx, |view, _| view.question_card_minimized),
+        "the next question must not arrive hidden"
+    );
+}
+
+/// The minimize control sits inside the card's border, in its header after "Waiting for
+/// input" -- not over the card's corner, outside the header (operator, 2026-11-09).
+#[gpui::test]
+async fn the_minimize_control_sits_inside_the_card_header(cx: &mut TestAppContext) {
+    let (_view, thread, mut cx) = setup(cx).await;
+    ask(&thread, one_question(), "Only?", &mut cx);
+    draw(&mut cx);
+
+    let header = cx
+        .debug_bounds("elicitation-card-header")
+        .expect("fixture: the card header is painted");
+    let minimize = cx
+        .debug_bounds("question-card-minimize")
+        .expect("the minimize control is painted");
+
+    assert!(
+        header.contains(&minimize.origin) && header.contains(&minimize.bottom_right()),
+        "the control lies within the header: header {header:?}, control {minimize:?}"
+    );
+}
