Vectrum
Sign In
933 lines · 42.7 KB · Rust
//! What docks on top of the composer, like T3 Code's banner stack: queued
//! messages, notices (background work, a usage limit), and the drawer that
//! takes over while the agent waits on you (an approval, a question, a plan).

use std::time::Duration;

use gpui_kit::component::{Icon, IconName, h_flex, menu::DropdownMenu as _, tooltip::Tooltip, v_flex};
use gpui_kit::prelude::FluentBuilder as _;
use gpui_kit::*;
use serde_json::Value;

use super::{ChatView, Notice};
use crate::clock;
use crate::session::{ChatItem, PermissionDecision, PermissionState, Question, Session, ToolCall, UserQuestions, tool_field};
use crate::ui::composer::{self, Density, Tone};
use crate::ui::icons::Lucide;
use crate::ui::metal::{self, MetalButton, Palette};

/// A step of the agent's task list (its latest TodoWrite).
pub struct TaskStep {
    pub text: SharedString,
    pub status: StepStatus,
}

#[derive(Clone, Copy, PartialEq, Eq)]
pub enum StepStatus {
    Pending,
    InProgress,
    Completed,
}

/// The agent's task list, from the thread's latest TodoWrite call.
pub fn task_steps(session: &Session) -> Vec<TaskStep> {
    let todos = session.items.iter().rev().find_map(|item| match item {
        ChatItem::Tool(tool) if tool.name == "TodoWrite" => tool.input.get("todos").and_then(Value::as_array).cloned(),
        _ => None,
    });
    todos
        .unwrap_or_default()
        .iter()
        .filter_map(|todo| {
            let status = match todo.get("status").and_then(Value::as_str).unwrap_or_default() {
                "completed" => StepStatus::Completed,
                "in_progress" => StepStatus::InProgress,
                _ => StepStatus::Pending,
            };
            // The form the agent shows while it works on a step reads better.
            let text = match status {
                StepStatus::InProgress => todo.get("activeForm").or_else(|| todo.get("content")),
                _ => todo.get("content"),
            };
            Some(TaskStep { text: text?.as_str()?.to_string().into(), status })
        })
        .collect()
}

/// The first tool call waiting on an approval (not a question form), with
/// how many wait in all.
pub fn pending_approval(session: &Session) -> Option<(usize, &ToolCall, usize)> {
    let mut pending = session.items.iter().enumerate().filter_map(|(ix, item)| match item {
        ChatItem::Tool(tool)
            if tool.ask.is_none() && tool.permission.as_ref().is_some_and(|prompt| prompt.state == PermissionState::Pending) =>
        {
            Some((ix, tool))
        }
        _ => None,
    });
    let (ix, tool) = pending.next()?;
    Some((ix, tool, 1 + pending.count()))
}

/// A question from a Pi extension waiting for an answer.
pub fn pending_extension_question(session: &Session) -> Option<(usize, &Question)> {
    session.items.iter().enumerate().find_map(|(ix, item)| match item {
        ChatItem::Question(question) if question.answer.is_none() => Some((ix, question)),
        _ => None,
    })
}

/// The plan Claude asks to start on, if ExitPlanMode waits for an answer.
pub fn pending_plan(session: &Session) -> Option<(usize, &ToolCall)> {
    pending_approval(session).filter(|(_, tool, _)| tool.name == "ExitPlanMode").map(|(ix, tool, _)| (ix, tool))
}

/// A plan's title: its first heading, or its first line.
pub fn plan_title(tool: &ToolCall) -> String {
    let plan = tool.input.get("plan").and_then(Value::as_str).unwrap_or_default();
    let line = plan
        .lines()
        .map(str::trim)
        .find(|line| line.starts_with('#'))
        .or_else(|| plan.lines().map(str::trim).find(|line| !line.is_empty()))
        .unwrap_or("Proposed plan");
    line.trim_start_matches('#').trim().to_string()
}

/// What kind of approval a tool call asks for, as T3 Code labels it.
fn approval_label(tool: &ToolCall) -> &'static str {
    match tool.name.as_str() {
        "Bash" | "BashOutput" | "KillShell" => "Command approval",
        "Read" | "Glob" | "Grep" | "LS" | "NotebookRead" => "File read approval",
        "Edit" | "MultiEdit" | "Write" | "NotebookEdit" => "File change approval",
        name if name.starts_with("mcp__") => "App permission approval",
        _ => "App access approval",
    }
}

/// The detail an approval shows: the command, the path, or the input.
fn approval_detail(tool: &ToolCall) -> String {
    if let Some(command) = tool_field(&tool.input, &["command"]) {
        return command;
    }
    if let Some(path) = tool_field(&tool.input, &["file_path", "notebook_path", "path", "pattern", "url"]) {
        return path;
    }
    match &tool.input {
        Value::Object(map) if map.is_empty() => tool.name.clone(),
        input => serde_json::to_string_pretty(input).unwrap_or_else(|_| tool.name.clone()),
    }
}

impl ChatView {
    /// The docked surfaces, top to bottom. Each tucks under the next, the
    /// last under the composer.
    pub(super) fn render_dock(&self, cx: &mut Context<Self>) -> Vec<AnyElement> {
        // The `/`, `$` and `@` menus, and the stash, cover everything else
        // while open.
        if let Some(menu) = self.command_menu(cx) {
            return vec![div().mx(px(composer::INSET)).child(self.render_command_menu(menu, cx)).into_any_element()];
        }
        if self.stash_open {
            return vec![div().mx(px(composer::INSET)).child(self.render_stash_menu(cx)).into_any_element()];
        }

        let session = self.session.read(cx);
        let approval = pending_approval(session).map(|(ix, _, count)| (ix, count));
        let plan = pending_plan(session).map(|(ix, tool)| (ix, plan_title(tool)));
        let ask = session.pending_ask().map(|(ix, _)| ix);
        let extension = pending_extension_question(session).map(|(ix, _)| ix);
        let steps = task_steps(session);
        let blocking = approval.is_some() || ask.is_some() || extension.is_some();
        let show_tasks =
            !blocking && !steps.is_empty() && (session.is_working() || steps.iter().any(|step| step.status != StepStatus::Completed));
        let queued = !session.queue.is_empty();
        let notices = self.notices(cx);

        let mut items: Vec<AnyElement> = Vec::new();
        if queued {
            items.push(self.render_queue(!items.is_empty(), cx));
        }
        if !notices.is_empty() {
            items.push(self.render_notices(notices, !items.is_empty(), cx));
        }
        let p = metal::palette(cx);
        match (plan, approval, ask) {
            (Some((ix, title)), _, _) => items.push(render_plan_ready(ix, title, !items.is_empty(), p)),
            (None, Some((ix, count)), _) => items.push(render_approval(&self.session, ix, count, !items.is_empty(), cx)),
            (None, None, Some(ix)) => items.push(self.render_question(ix, !items.is_empty(), cx)),
            (None, None, None) => {
                if let Some(ix) = extension {
                    items.push(render_extension_question(&self.session, ix, !items.is_empty(), cx));
                }
            }
        }
        if show_tasks {
            items.push(self.render_tasks(steps, !items.is_empty(), cx));
        }
        // The stash sits beside the others as a tab of its own.
        let stash = (!self.stash.is_empty() && approval.is_none()).then(|| self.render_stash_badge(cx));
        if items.is_empty() && stash.is_none() {
            return Vec::new();
        }
        vec![
            h_flex()
                .mx(px(composer::INSET))
                .items_end()
                .gap(px(4.))
                .child(v_flex().flex_1().min_w_0().children(items))
                .children(stash)
                .into_any_element(),
        ]
    }

    /// The tab that opens the stash: "Stash" and how many prompts it holds.
    fn render_stash_badge(&self, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let pulsing = self.stash_pulse.is_some_and(|at| at.elapsed() < Duration::from_millis(1200));
        composer::banner(Tone::Neutral, Density::Comfortable, false, p)
            .flex_none()
            .child(
                composer::row()
                    .id("stash-badge")
                    .pr(px(4.))
                    .cursor_pointer()
                    .rounded(px(6.))
                    .text_color(if pulsing { p.ink } else { p.ink3 })
                    .hover(|style| style.text_color(p.ink))
                    .tooltip({
                        let count = self.stash.len();
                        move |window, cx| Tooltip::new(format!("Stashed prompts: {count}. Open stash.")).build(window, cx)
                    })
                    .on_click(cx.listener(|this, _, _, cx| this.toggle_stash(cx)))
                    .child(composer::icon_cell(Some(Icon::new(Lucide::Bookmark).size(px(12.))), p))
                    .child("Stash")
                    .child(composer::count(self.stash.len().to_string(), p).when(pulsing, |this| this.text_color(p.green_ink))),
            )
            .into_any_element()
    }

    /// The stashed prompts: each restores into the composer, or goes away.
    fn render_stash_menu(&self, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let selected = self.stash_selected.min(self.stash.len().saturating_sub(1));
        let header = composer::row()
            .id("stash-header")
            .cursor_pointer()
            .rounded(px(6.))
            .tooltip(|window, cx| Tooltip::new("Close stash").build(window, cx))
            .on_click(cx.listener(|this, _, _, cx| this.toggle_stash(cx)))
            .child(composer::icon_cell(Some(Icon::new(Lucide::Bookmark).size(px(12.))), p))
            .child(composer::content().child(div().text_color(p.ink3).child("Stash")))
            .child(composer::count(self.stash.len().to_string(), p))
            .child(composer::toggle_icon(true, p));
        let rows: Vec<AnyElement> = self
            .stash
            .iter()
            .enumerate()
            .rev()
            .map(|(ix, stashed)| {
                composer::row()
                    .id(("stashed", ix))
                    .rounded(px(4.))
                    .cursor_pointer()
                    .when(ix == selected, |this| this.bg(p.menu_row_hover))
                    .hover(|style| style.bg(p.menu_row_hover))
                    .on_click(cx.listener(move |this, _, window, cx| this.restore_stash(ix, window, cx)))
                    .child(composer::icon_cell(Some(Icon::new(IconName::FileText).size(px(12.))), p))
                    .child(composer::content().text_color(p.ink2).child(div().flex_1().min_w_0().truncate().child(stashed.snippet())))
                    .when(!stashed.attachments.is_empty(), |this| {
                        this.child(
                            h_flex()
                                .gap(px(2.))
                                .text_color(p.ink3)
                                .child(Icon::new(IconName::File).size(px(12.)))
                                .child(stashed.attachments.len().to_string()),
                        )
                    })
                    .child(div().text_color(p.ink3).child(clock::when(stashed.at)))
                    .child(
                        composer::icon_button(("stashed-delete", ix), IconName::Close, "Delete stashed prompt")
                            .on_click(cx.listener(move |this, _, _, cx| {
                                cx.stop_propagation();
                                this.delete_stash(ix, cx);
                            })),
                    )
                    .into_any_element()
            })
            .collect();
        let empty = rows.is_empty();
        composer::banner(Tone::Neutral, Density::Default, false, p)
            .child(header)
            .child(v_flex().id("stash-list").max_h(px(384.)).overflow_y_scroll().children(rows))
            .when(empty, |this| {
                this.child(
                    div()
                        .px(px(28.))
                        .py(px(8.))
                        .text_color(p.ink3)
                        .child("Nothing stashed yet. Press Ctrl+S with a prompt in the composer to stash it."),
                )
            })
            .into_any_element()
    }

    /// Messages waiting for the turn to end: each can be dragged into
    /// another place, edited in the composer, sent now as a steer, or
    /// removed.
    fn render_queue(&self, joined: bool, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let session = self.session.clone();
        let state = session.read(cx);
        let queue_len = state.queue.len();
        let expanded = self.queue_expanded;
        let editing = self.editing_queued;
        let remote = state.is_remote();
        let working = state.is_working();
        let header = composer::row()
            .id("queue-header")
            .cursor_pointer()
            .rounded(px(6.))
            .on_click(cx.listener(|this, _, _, cx| {
                this.queue_expanded = !this.queue_expanded;
                cx.notify();
            }))
            .child(composer::icon_cell(Some(Icon::new(Lucide::ListOrdered).size(px(12.))), p))
            .child(composer::content().child(div().text_color(p.ink3).child("Queued")))
            .child(composer::count(queue_len.to_string(), p))
            .child(composer::toggle_icon(expanded, p));
        let rows: Vec<AnyElement> = state
            .queue
            .iter()
            .enumerate()
            .map(|(ix, message)| {
                let text = message.text.lines().next().unwrap_or_default().to_string();
                let full: SharedString = message.text.clone().into();
                let is_editing = editing == Some(ix);
                let last = ix + 1 == queue_len;
                let (steer, remove, drop) = (session.clone(), session.clone(), session.clone());
                composer::row()
                    .id(("queued", ix))
                    .rounded(px(4.))
                    .when(is_editing, |this| this.bg(p.menu_row_hover))
                    .drag_over::<QueuedDrag>(move |style, _, _, _| style.border_t_2().border_color(p.green_deep.opacity(0.7)))
                    .on_drop(move |drag: &QueuedDrag, _, cx| drop.update(cx, |session, cx| session.move_queued(drag.0, ix, cx)))
                    .child(composer::icon_cell(
                        (!remote).then(|| {
                            div()
                                .id(("queued-grip", ix))
                                .cursor_grab()
                                .tooltip(|window, cx| Tooltip::new("Reorder queued message (drag)").build(window, cx))
                                .on_drag(QueuedDrag(ix), move |_, _, _, cx| {
                                    let text: SharedString = text_preview(&full).into();
                                    cx.new(|_| QueuedGhost(text))
                                })
                                .child(Icon::new(Lucide::GripVertical).size(px(12.)))
                        }),
                        p,
                    ))
                    .child(
                        composer::content()
                            .id(("queued-text", ix))
                            .text_color(p.ink2)
                            .when(!message.attachments.is_empty(), |this| {
                                this.child(Icon::new(Lucide::Paperclip).size(px(12.)).text_color(p.ink3))
                            })
                            .child(div().flex_1().min_w_0().truncate().child(text))
                            .tooltip({
                                let full: SharedString = message.text.clone().into();
                                move |window, cx| Tooltip::new(full.clone()).build(window, cx)
                            }),
                    )
                    .map(|row| {
                        if is_editing {
                            row.child(
                                MetalButton::new(("queued-cancel", ix))
                                    .ghost()
                                    .mini()
                                    .h(px(24.))
                                    .label("Cancel")
                                    .on_click(cx.listener(|this, _, window, cx| this.cancel_queued_edit(window, cx))),
                            )
                        } else {
                            row.when(!remote, |row| {
                                row.child(
                                    composer::icon_button(
                                        ("queued-edit", ix),
                                        Lucide::Pencil,
                                        if last { "Edit in the composer (Alt+↑)" } else { "Edit in the composer" },
                                    )
                                    .on_click(cx.listener(move |this, _, window, cx| this.edit_queued(ix, window, cx))),
                                )
                            })
                            .child(
                                MetalButton::new(("queued-steer", ix))
                                    .ghost()
                                    .mini()
                                    .h(px(24.))
                                    .icon(Lucide::CornerUpRight)
                                    .label("Steer")
                                    .disabled(!working)
                                    .tooltip(if ix == 0 { "Send as a steer instead (Ctrl+Shift+Enter)" } else { "Send as a steer instead" })
                                    .on_click(move |_, _, cx| steer.update(cx, |session, cx| session.steer_queued(ix, cx))),
                            )
                            .child(
                                composer::icon_button(("queued-remove", ix), IconName::Close, "Remove from queue")
                                    .on_click(move |_, _, cx| remove.update(cx, |session, cx| session.remove_queued(ix, cx))),
                            )
                        }
                    })
                    .into_any_element()
            })
            .collect();
        composer::banner(Tone::Neutral, Density::Default, joined, p)
            .child(header)
            .when(expanded, |this| {
                this.child(v_flex().id("queue-list").max_h(px(128.)).overflow_y_scroll().gap(px(1.)).children(rows))
            })
            .into_any_element()
    }

    /// The notices above the composer: the front one docked, the others
    /// behind a peek tab that unfolds them.
    fn render_notices(&self, notices: Vec<Notice>, joined: bool, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let mut notices = notices.into_iter();
        let Some(front) = notices.next() else { return div().into_any_element() };
        let rest: Vec<Notice> = notices.collect();
        let expanded = self.notices_expanded && !rest.is_empty();
        let chat = cx.entity().downgrade();
        let peek = (!rest.is_empty()).then(|| {
            let chat = chat.clone();
            div()
                .id("notice-peek")
                .mx(px(8.))
                .h(px(12.))
                .rounded_t(px(16.))
                .bg(metal::vertical((p.s_hi, p.s)))
                .shadow(p.raise_lite.clone())
                .cursor_pointer()
                .mb(px(-4.))
                .tooltip(|window, cx| Tooltip::new("Show other notices").build(window, cx))
                .on_click(move |_, _, cx| {
                    let _ = chat.update(cx, |this, cx| {
                        this.notices_expanded = !this.notices_expanded;
                        cx.notify();
                    });
                })
        });
        let stacked = expanded.then(|| {
            v_flex().gap(px(8.)).pb(px(8.)).children(rest.iter().enumerate().map(|(ix, notice)| {
                render_notice(&self.session, ("notice-stacked", ix), notice, p)
                    .rounded(px(16.))
                    .px(px(4.))
                    .py(px(5.))
                    .bg(metal::vertical((p.s_hi, p.s)))
                    .shadow(p.raise_lite.clone())
                    .into_any_element()
            }))
        });
        v_flex()
            .children(stacked)
            .children(peek.filter(|_| !expanded))
            .child(
                composer::banner(front.tone(), Density::Comfortable, joined, p)
                    .child(render_notice(&self.session, ("notice-front", 0usize), &front, p)),
            )
            .into_any_element()
    }

    /// Claude's AskUserQuestion form, one question at a time. The header
    /// folds it away; options pick with a click or 1–9; the composer below
    /// takes an answer in the user's own words.
    fn render_question(&self, ix: usize, joined: bool, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let session = self.session.read(cx);
        let Some((_, ask)) = session.pending_ask().filter(|(found, _)| *found == ix) else {
            return div().into_any_element();
        };
        let current = ask.current;
        let question = &ask.questions[current];
        let collapsed = self.question_collapsed == Some((ix, current));
        let count = ask.questions.len();
        let custom_active = !ask.custom[current].trim().is_empty();
        let chat = cx.entity().downgrade();

        let header = {
            let chat = chat.clone();
            let dismiss = self.session.clone();
            composer::row()
                .id("question-header")
                .cursor_pointer()
                .rounded(px(6.))
                .tooltip(move |window, cx| {
                    Tooltip::new(if collapsed {
                        "Show the question and its options"
                    } else {
                        "Hide the question and its options"
                    })
                    .build(window, cx)
                })
                .on_click(move |_, _, cx| {
                    let _ = chat.update(cx, |this, cx| {
                        this.question_collapsed = if collapsed { None } else { Some((ix, current)) };
                        cx.notify();
                    });
                })
                .child(composer::icon_cell(None::<Div>, p))
                .child(
                    composer::content()
                        .child(div().flex_none().font_weight(FontWeight::MEDIUM).text_color(p.ink3).child(question.header.clone()))
                        .when(collapsed, |this| {
                            this.child(div().flex_1().min_w_0().truncate().text_color(p.ink2).child(question.question.clone()))
                        }),
                )
                .when(count > 1, |this| {
                    this.child(
                        div()
                            .text_size(px(10.))
                            .font_weight(FontWeight::MEDIUM)
                            .text_color(p.ink3)
                            .child(format!("{}/{count}", current + 1)),
                    )
                })
                .child(composer::toggle_icon(!collapsed, p))
                .child(
                    composer::icon_button("question-dismiss", IconName::Close, "Dismiss question without answering")
                        .on_click(move |_, _, cx| {
                            cx.stop_propagation();
                            dismiss.update(cx, |session, cx| session.ask_skip(ix, cx));
                        }),
                )
        };

        let options = question.options.iter().enumerate().map(|(option, choice)| {
            let selected = !custom_active && ask.picked[current].contains(&option)
                || self.optimistic_pick == Some((ix, current, option));
            let chat = chat.clone();
            h_flex()
                .id(("question-option", option))
                .w_full()
                .gap(px(8.))
                .px(px(10.))
                .py(px(8.))
                .rounded(px(8.))
                .cursor_pointer()
                .map(|this| {
                    if selected {
                        this.bg(p.ink.opacity(0.07)).text_color(p.ink)
                    } else {
                        this.text_color(p.ink.opacity(0.85)).hover(|style| style.bg(p.row_hover))
                    }
                })
                .on_click(move |_, _, cx| {
                    let _ = chat.update(cx, |this, cx| this.pick_option(ix, option, cx));
                })
                .child(
                    v_flex()
                        .flex_1()
                        .min_w_0()
                        .gap(px(2.))
                        .child(div().text_size(px(14.)).font_weight(FontWeight::MEDIUM).child(choice.label.clone()))
                        .when_some(
                            choice.description.clone().filter(|description| *description != choice.label),
                            |this, description| this.child(div().text_size(px(11.)).text_color(p.ink3).child(description)),
                        ),
                )
                .map(|this| {
                    if selected {
                        this.child(Icon::new(IconName::Check).size(px(14.)).text_color(p.green_ink))
                    } else if option < 9 {
                        this.child(
                            div()
                                .flex()
                                .flex_none()
                                .items_center()
                                .justify_center()
                                .size(px(20.))
                                .text_size(px(10.))
                                .font_weight(FontWeight::MEDIUM)
                                .text_color(p.ink3)
                                .child((option + 1).to_string()),
                        )
                    } else {
                        this
                    }
                })
        });

        composer::banner(Tone::Neutral, Density::Default, joined, p)
            .child(header)
            .when(!collapsed, |this| {
                this.child(
                    v_flex()
                        .id("question-body")
                        .max_h(px(384.))
                        .overflow_y_scroll()
                        .pl(px(28.))
                        .pr(px(4.))
                        .pb(px(4.))
                        .child(div().text_size(px(14.)).text_color(p.ink.opacity(0.85)).child(question.question.clone()))
                        .when(question.multi_select, |this| {
                            this.child(div().mt(px(4.)).text_size(px(12.)).text_color(p.ink3).child("Select one or more options."))
                        })
                        .child(v_flex().mt(px(8.)).gap(px(2.)).children(options)),
                )
            })
            .into_any_element()
    }

    /// Selects an option of the open question; a single-select question
    /// moves on by itself a moment later, as in T3 Code.
    pub(super) fn pick_option(&mut self, ix: usize, option: usize, cx: &mut Context<Self>) {
        let current = self.session.read(cx).pending_ask().map(|(_, ask)| ask.current).unwrap_or(0);
        let single = self.session.update(cx, |session, cx| session.ask_toggle(ix, option, cx));
        if !single {
            return;
        }
        self.optimistic_pick = Some((ix, current, option));
        self.ask_timer = cx.spawn(async move |this, cx| {
            cx.background_executor().timer(Duration::from_millis(200)).await;
            let _ = this.update(cx, |this, cx| {
                this.optimistic_pick = None;
                this.session.update(cx, |session, cx| session.ask_advance(ix, cx));
            });
        });
    }

    /// The agent's task list: one row with the current step and progress,
    /// unfolding into every step.
    fn render_tasks(&self, steps: Vec<TaskStep>, joined: bool, cx: &mut Context<Self>) -> AnyElement {
        let p = metal::palette(cx);
        let expanded = self.tasks_expanded;
        let total = steps.len();
        let done = steps.iter().filter(|step| step.status == StepStatus::Completed).count();
        let current = steps
            .iter()
            .find(|step| step.status == StepStatus::InProgress)
            .or_else(|| steps.iter().find(|step| step.status == StepStatus::Pending))
            .or(steps.last())
            .map(|step| step.text.clone())
            .unwrap_or_default();
        let chat = cx.entity().downgrade();
        let segments = (2..=10).contains(&total).then(|| {
            h_flex().w(px(80.)).flex_none().gap(px(2.)).children(steps.iter().map(|step| {
                div().flex_1().h(px(3.)).rounded_full().bg(match step.status {
                    StepStatus::Completed => p.green_ink,
                    StepStatus::InProgress => p.blue_ink,
                    StepStatus::Pending => p.ink3.opacity(0.25),
                })
            }))
        });
        let summary = composer::row()
            .id("tasks-summary")
            .cursor_pointer()
            .rounded(px(6.))
            .on_click(move |_, _, cx| {
                let _ = chat.update(cx, |this, cx| {
                    this.tasks_expanded = !this.tasks_expanded;
                    cx.notify();
                });
            })
            .child(composer::icon_cell(Some(Icon::new(Lucide::ListTodo).size(px(12.))), p))
            .child(
                composer::content()
                    .child(div().flex_none().text_color(p.ink3).child("Tasks"))
                    .child(div().flex_1().min_w_0().truncate().font_weight(FontWeight::MEDIUM).text_color(p.ink2).child(current)),
            )
            .child(composer::count(format!("{done}/{total}"), p).when(done >= total, |this| this.text_color(p.green_ink)))
            .children(segments)
            .child(composer::toggle_icon(expanded, p));
        let list = expanded.then(|| {
            v_flex().id("tasks-list").max_h(px(384.)).overflow_y_scroll().children(steps.iter().enumerate().map(|(ix, step)| {
                let (icon, color, text) = match step.status {
                    StepStatus::Completed => (Icon::new(IconName::Check), p.green_ink, p.ink3.opacity(0.7)),
                    StepStatus::InProgress => (Icon::new(Lucide::CircleDot), p.blue_ink, p.ink),
                    StepStatus::Pending => (Icon::new(Lucide::Circle), p.ink3.opacity(0.4), p.ink3),
                };
                composer::row()
                    .id(("task-step", ix))
                    .items_start()
                    .py(px(4.))
                    .pr(px(8.))
                    .child(composer::icon_cell(Some(icon.size(px(12.)).text_color(color)), p).h(px(16.)))
                    .child(div().flex_1().min_w_0().text_color(text).child(step.text.clone()))
                    .when(step.status == StepStatus::InProgress, |this| {
                        this.child(div().w(px(48.)).text_right().text_size(px(10.)).text_color(p.ink3.opacity(0.6)).child("now"))
                    })
            }))
        });
        composer::banner(Tone::Neutral, Density::Comfortable, joined, p)
            .child(summary)
            .children(list)
            .into_any_element()
    }
}

/// One notice's row.
fn render_notice(session: &Entity<Session>, id: impl Into<ElementId>, notice: &Notice, p: &Palette) -> Stateful<Div> {
    let session = session.clone();
    match notice {
        Notice::BackgroundWork { title, detail, waiting, task_ids } => {
            let task_ids = task_ids.clone();
            composer::row()
                .id(id)
                .child(composer::icon_cell(
                    Some(div().size(px(6.)).rounded_full().bg(if *waiting { p.green_ink } else { p.ink })),
                    p,
                ))
                .child(
                    composer::content()
                        .child(div().flex_none().font_weight(FontWeight::MEDIUM).truncate().child(title.clone()))
                        .when_some(detail.clone(), |this, detail| {
                            this.child(div().flex_1().min_w_0().truncate().text_color(p.ink3).child(detail))
                        }),
                )
                .child(MetalButton::new("background-stop").ghost().mini().h(px(24.)).label("Stop").on_click(move |_, _, cx| {
                    session.update(cx, |session, cx| {
                        for task_id in &task_ids {
                            session.stop_task(task_id, cx);
                        }
                    })
                }))
        }
        Notice::Limit { resets, resume_at } => {
            let resets = *resets;
            composer::row()
                .id(id)
                .child(composer::icon_cell(Some(Icon::new(Lucide::Gauge).size(px(12.))), p))
                .child(
                    composer::content()
                        .child(div().flex_none().font_weight(FontWeight::MEDIUM).child("Usage limit reached"))
                        .child(div().flex_1().min_w_0().truncate().text_color(p.ink3).child(match resume_at {
                            Some(at) => format!("Continues at {}", clock::when(*at)),
                            None => format!("Resets at {}", clock::when(resets)),
                        })),
                )
                .child(match resume_at {
                    Some(_) => MetalButton::new("cancel-resume")
                        .ghost()
                        .mini()
                        .h(px(24.))
                        .label("Cancel")
                        .tooltip("Don't continue on its own")
                        .on_click(move |_, _, cx| session.update(cx, |session, cx| session.cancel_resume(cx))),
                    None => MetalButton::new("schedule-resume")
                        .ghost()
                        .mini()
                        .h(px(24.))
                        .label("Continue then")
                        .on_click(move |_, _, cx| {
                            session.update(cx, |session, cx| session.schedule_resume(resets + Duration::from_secs(60), cx))
                        }),
                })
        }
    }
}

/// An approval the agent waits on: what it wants to run, Decline and
/// Approve, and the rarer answers behind "…".
fn render_approval(session: &Entity<Session>, ix: usize, count: usize, joined: bool, cx: &App) -> AnyElement {
    let p = metal::palette(cx);
    let state = session.read(cx);
    let Some(ChatItem::Tool(tool)) = state.items.get(ix) else { return div().into_any_element() };
    let label = approval_label(tool);
    let detail = approval_detail(tool);
    let mono = !tool.name.starts_with("mcp__");
    let always = tool.permission.as_ref().and_then(|prompt| prompt.always_label()).is_some();
    let respond = |decision: PermissionDecision| {
        let session = session.clone();
        move |_: &ClickEvent, _: &mut Window, cx: &mut App| {
            session.update(cx, |session, cx| session.respond_permission(ix, decision, cx));
        }
    };
    let menu_session = session.clone();
    composer::banner(Tone::Warning, Density::Spacious, joined, p)
        .child(
            h_flex()
                .items_start()
                .gap(px(8.))
                .child(div().pt(px(2.)).child(Icon::new(Lucide::Shield).size(px(16.)).text_color(p.amber_ink)))
                .child(
                    v_flex()
                        .flex_1()
                        .min_w_0()
                        .gap(px(4.))
                        .child(
                            h_flex()
                                .w_full()
                                .gap(px(8.))
                                .text_size(px(11.))
                                .text_color(p.ink3)
                                .child(div().font_weight(FontWeight::MEDIUM).text_color(p.amber_ink).child(label))
                                .when(tool.name.starts_with("mcp__"), |this| {
                                    this.child(div().truncate().child(tool.name.trim_start_matches("mcp__").replace("__", " · ")))
                                })
                                .when(count > 1, |this| this.child(div().ml_auto().child(format!("1/{count}")))),
                        )
                        .child(
                            div()
                                .id("approval-detail")
                                .max_h(px(80.))
                                .overflow_y_scroll()
                                .text_size(px(12.))
                                .text_color(p.ink)
                                .when(mono, |this| this.font_family(metal::MONO))
                                .child(detail),
                        ),
                )
                .child(
                    h_flex()
                        .flex_none()
                        .gap(px(6.))
                        .child(MetalButton::new(("approval-decline", ix)).compact().label("Decline").on_click(respond(PermissionDecision::Deny)))
                        .child(
                            MetalButton::new(("approval-approve", ix))
                                .primary()
                                .compact()
                                .label("Approve")
                                .on_click(respond(PermissionDecision::Allow)),
                        )
                        .child(
                            MetalButton::new(("approval-more", ix))
                                .compact()
                                .icon(IconName::Ellipsis)
                                .tooltip("More approval options")
                                .dropdown_menu_with_anchor(Anchor::BottomRight, move |menu, _, _| {
                                    let cancel = menu_session.clone();
                                    let allow = menu_session.clone();
                                    let menu = menu.item(gpui_kit::component::menu::PopupMenuItem::new("Cancel").on_click(
                                        move |_, _, cx| {
                                            cancel.update(cx, |session, cx| {
                                                session.respond_permission(ix, PermissionDecision::Deny, cx);
                                                session.interrupt(cx);
                                            })
                                        },
                                    ));
                                    if always {
                                        menu.item(gpui_kit::component::menu::PopupMenuItem::new("Always allow this session").on_click(
                                            move |_, _, cx| {
                                                allow.update(cx, |session, cx| {
                                                    session.respond_permission(ix, PermissionDecision::AllowAlways, cx)
                                                })
                                            },
                                        ))
                                    } else {
                                        menu
                                    }
                                }),
                        ),
                ),
        )
        .into_any_element()
}

/// A Pi extension's question: its title and message, and its options to
/// pick with a click or 1–9.
fn render_extension_question(session: &Entity<Session>, ix: usize, joined: bool, cx: &App) -> AnyElement {
    let p = metal::palette(cx);
    let Some(ChatItem::Question(question)) = session.read(cx).items.get(ix) else { return div().into_any_element() };
    let options = question.options.iter().enumerate().map(|(option, label)| {
        let session = session.clone();
        h_flex()
            .id(("extension-option", option))
            .w_full()
            .gap(px(8.))
            .px(px(10.))
            .py(px(8.))
            .rounded(px(8.))
            .cursor_pointer()
            .text_color(p.ink.opacity(0.85))
            .hover(|style| style.bg(p.row_hover))
            .on_click(move |_, _, cx| session.update(cx, |session, cx| session.answer_question(ix, option, cx)))
            .child(div().flex_1().min_w_0().text_size(px(14.)).font_weight(FontWeight::MEDIUM).child(label.clone()))
            .when(option < 9, |this| {
                this.child(
                    div()
                        .flex()
                        .flex_none()
                        .items_center()
                        .justify_center()
                        .size(px(20.))
                        .text_size(px(10.))
                        .font_weight(FontWeight::MEDIUM)
                        .text_color(p.ink3)
                        .child((option + 1).to_string()),
                )
            })
    });
    composer::banner(Tone::Neutral, Density::Default, joined, p)
        .child(
            composer::row()
                .child(composer::icon_cell(None::<Div>, p))
                .child(composer::content().child(div().font_weight(FontWeight::MEDIUM).text_color(p.ink3).child("Question"))),
        )
        .child(
            v_flex()
                .pl(px(28.))
                .pr(px(4.))
                .pb(px(4.))
                .child(div().text_size(px(14.)).text_color(p.ink.opacity(0.85)).child(question.title.clone()))
                .when_some(question.message.clone(), |this, message| {
                    this.child(div().mt(px(4.)).text_size(px(12.)).text_color(p.ink3).child(message))
                })
                .child(v_flex().mt(px(8.)).gap(px(2.)).children(options)),
        )
        .into_any_element()
}

/// Claude's plan, ready to implement: the footer below offers Implement or
/// Refine.
fn render_plan_ready(ix: usize, title: String, joined: bool, p: &Palette) -> AnyElement {
    composer::banner(Tone::Neutral, Density::Default, joined, p)
        .child(
            composer::row()
                .id(("plan-ready", ix))
                .child(composer::icon_cell(None::<Div>, p))
                .child(
                    composer::content()
                        .child(div().flex_none().font_weight(FontWeight::MEDIUM).text_color(p.ink3).child("Plan ready"))
                        .child(div().flex_1().min_w_0().truncate().text_color(p.ink.opacity(0.85)).child(title)),
                ),
        )
        .into_any_element()
}

/// The answers so far of an open form, for the primary actions.
pub fn ask_progress(ask: &UserQuestions) -> (bool, bool) {
    let can_advance = ask.resolved(ask.current).is_some();
    (can_advance, ask.is_complete())
}

/// A queued message being dragged into another place.
#[derive(Clone)]
struct QueuedDrag(usize);

/// What follows the pointer while a queued message is dragged.
struct QueuedGhost(SharedString);

impl Render for QueuedGhost {
    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
        let p = metal::palette(cx);
        div()
            .max_w(px(320.))
            .px(px(10.))
            .py(px(4.))
            .rounded(px(8.))
            .bg(p.menu_bg)
            .shadow(p.raise_sm.clone())
            .text_size(px(12.))
            .text_color(p.ink2)
            .truncate()
            .child(self.0.clone())
    }
}

fn text_preview(text: &str) -> String {
    let line = text.lines().next().unwrap_or_default();
    let mut preview: String = line.chars().take(80).collect();
    if line.chars().count() > 80 {
        preview.push('…');
    }
    preview
}