From 5b6a0f3ce7da29b4d2f9895d4ee225ebabca5e11 Mon Sep 17 00:00:00 2001
From: lucascouts <diego.evangelista5642@gmail.com>
Date: Sun, 6 Sep 2026 14:40:34 -0300
Subject: [PATCH] feat(agent_ui): preview the composer's draft as rendered
 markdown
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The panel renders markdown everywhere except the one place the user is writing
it. This adds a preview to the composer using the same `MarkdownElement` and the
same `MarkdownFont::Agent` style that draw every agent response — not a second
markdown path, the existing one pointed at the draft.

**The preview is derived, never authoritative.** Toggling reads the draft's
buffer and builds the markdown from the text as it stands at that moment; the
buffer itself is only ever read. A composer that swapped its buffer out for a
rendered view and back is how a half-written message gets eaten, and that is the
failure this shape rules out rather than tests around.

**The source is re-derived, not cached.** This is the trap worth naming, because
an implementation that gets it wrong passes every obvious test: an
`Entity<Markdown>` built once at the first toggle and reused renders the *first*
draft forever, and a two-step test that toggles on, checks, toggles off and
checks again never notices. Showing the preview builds from the current text, and
an edit while it is open refreshes it, so what is displayed cannot drift from
what is written.

**Two things here came from watching a person use it, and neither was reachable
from a test.** The first version shipped with no button and no scrollbar, behind
463 passing unit tests, a clean clippy and a successful release build:

- **The preview area scrolls and now says so.** `overflow_y_scroll` alone gives a
  region that scrolls with nothing drawn to indicate it, so a draft taller than
  the preview's cap reads as truncated. It now carries a tracked `ScrollHandle`
  and `vertical_scrollbar_for`, the idiom `agent_registry_ui`,
  `threads_archive_view` and `conversation_view` already use.

- **The toggle has a button, not only an action.** `ToggleMessagePreview` through
  the command palette is a real path — but the person maintaining this overlay,
  who wrote the neighbouring patch the same day, had to ask how to reach it. An
  entry nobody discovers is a weak way to *offer* something. The button lives in
  the composer's own row and calls the same `toggle_preview`, so it and the
  palette cannot come to mean different things, and its tooltip is built with
  `Tooltip::for_action_in` so finding the button also teaches the action and any
  keybinding on it.

The button reports which state it is in — the icon shows the state, the tooltip
names what a click does — and that pairing is split into a small free function so
half of it can be asserted without a window. An affordance that looks identical
whether the preview is on or off reports nothing, and this crate has no windowed
harness for the composer to catch that any other way.

Of the patches in this batch this is the only one that adds UI rather than
reaching machinery Zed already had, which is why it is the largest. Everything it
draws with already existed.

Tests: five. The composer opens in editing mode, not preview; the preview renders
the current draft; an edit after the preview is open re-renders it (the
cached-entity trap); previewing an empty draft is harmless; and the toggle's icon
and tooltip both invert with the state.

Verified: `cargo check -p agent_ui`, `cargo clippy -p agent_ui --all-targets` and
`rustfmt --check` all clean; `cargo test -p agent_ui --lib` 464 passed, 0 failed.
Everything above the unit rung is still unverified here: that the scrollbar
appears, that the button is where a hand expects it, and that the rendered
markdown reads correctly are observations for a running editor. The first version
of this patch said the same sentence, and it was right.
---

diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs
index d76af73..770d1fc 100644
--- a/crates/agent_ui/src/conversation_view/thread_view.rs
+++ b/crates/agent_ui/src/conversation_view/thread_view.rs
@@ -4434,6 +4434,10 @@ impl ThreadView {
                                     .flex_wrap()
                                     .gap_0p5()
                                     .child(self.render_add_context_button(cx))
+                                    .child(MessageEditor::render_preview_toggle(
+                                        &self.message_editor,
+                                        cx,
+                                    ))
                                     .child(self.render_follow_toggle(cx))
                                     .children(self.render_fast_mode_control(cx))
                                     .children(self.render_thinking_control(cx)),
diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs
index 044e788..f770b48 100644
--- a/crates/agent_ui/src/message_editor.rs
+++ b/crates/agent_ui/src/message_editor.rs
@@ -25,10 +25,11 @@ use editor::{
 use futures::{FutureExt as _, future::join_all};
 use gpui::{
     AppContext, ClipboardEntry, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
-    Focusable, Image, ImageFormat, KeyContext, SharedString, Subscription, Task, TaskExt,
-    TextStyle, WeakEntity,
+    Focusable, Image, ImageFormat, KeyContext, ScrollHandle, SharedString, Subscription, Task,
+    TaskExt, TextStyle, WeakEntity, actions,
 };
 use language::{Buffer, language_settings::InlayHintKind};
+use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle};
 use parking_lot::RwLock;
 use project::AgentId;
 use project::{
@@ -39,12 +40,20 @@ use settings::Settings;
 use std::{cmp::min, fmt::Write, ops::Range, rc::Rc, sync::Arc};
 use text::LineEnding;
 use theme_settings::ThemeSettings;
-use ui::{ContextMenu, prelude::*};
+use ui::{ContextMenu, Tooltip, WithScrollbar, prelude::*};
 use util::paths::PathStyle;
 use util::{ResultExt, debug_panic};
 use workspace::{CollaboratorId, Workspace};
 use zed_actions::agent::{Chat, PasteRaw};
 
+actions!(
+    agent,
+    [
+        /// Shows or hides a rendered markdown preview of the draft in the message editor.
+        ToggleMessagePreview,
+    ]
+);
+
 #[derive(Default)]
 pub struct SessionCapabilities {
     prompt_capabilities: acp::PromptCapabilities,
@@ -207,6 +216,13 @@ pub struct MessageEditor {
     local_commands: SharedLocalCommands,
     agent_id: AgentId,
     thread_store: Option<Entity<ThreadStore>>,
+    /// The rendered draft, while the preview is showing; `None` while editing.
+    preview: Option<Entity<Markdown>>,
+    /// Where the preview is scrolled to. Owned by the editor rather than made
+    /// per frame because the scrollbar binds to the handle it was given on its
+    /// first frame and keeps it: a handle rebuilt later leaves the bar reading
+    /// an element nothing scrolls.
+    preview_scroll_handle: ScrollHandle,
     _subscriptions: Vec<Subscription>,
     _parse_slash_command_task: Task<()>,
 }
@@ -559,6 +575,7 @@ impl MessageEditor {
                     && !editor.read(cx).read_only(cx)
                 {
                     cx.emit(MessageEditorEvent::Edited);
+                    this.refresh_preview(cx);
                     editor.update(cx, |editor, cx| {
                         let snapshot = editor.snapshot(window, cx);
                         this.mention_set
@@ -609,6 +626,8 @@ impl MessageEditor {
             local_commands,
             agent_id,
             thread_store,
+            preview: None,
+            preview_scroll_handle: ScrollHandle::new(),
             _subscriptions: subscriptions,
             _parse_slash_command_task: Task::ready(()),
         }
@@ -734,6 +753,37 @@ impl MessageEditor {
         self.editor.read(cx).text(cx).trim().is_empty()
     }
 
+    /// Shows or hides a rendered preview of the draft. Showing it builds the
+    /// markdown from the draft as it stands right now, so a preview never
+    /// outlives the text it was rendered from; the draft's buffer is only read.
+    pub fn toggle_preview(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+        if self.preview.take().is_none() {
+            let source = SharedString::from(self.text(cx));
+            let language_registry = self
+                .workspace
+                .upgrade()
+                .map(|workspace| workspace.read(cx).project().read(cx).languages().clone());
+            self.preview = Some(cx.new(|cx| Markdown::new(source, language_registry, None, cx)));
+        }
+        cx.notify();
+    }
+
+    /// The markdown shown by the preview, or `None` while the composer is editing.
+    pub fn preview_markdown(&self) -> Option<Entity<Markdown>> {
+        self.preview.clone()
+    }
+
+    /// Re-points an open preview at the draft after an edit, so what is rendered
+    /// is the message that would be sent rather than the one that was typed when
+    /// the preview opened.
+    fn refresh_preview(&self, cx: &mut Context<Self>) {
+        let Some(preview) = self.preview.clone() else {
+            return;
+        };
+        let source = self.text(cx);
+        preview.update(cx, |preview, cx| preview.replace(source, cx));
+    }
+
     pub fn is_completions_menu_visible(&self, cx: &App) -> bool {
         self.editor
             .read(cx)
@@ -2019,9 +2069,86 @@ impl Focusable for MessageEditor {
     }
 }
 
+impl MessageEditor {
+    /// The preview sits above the draft rather than replacing it: the composer's
+    /// only focus handle belongs to the inner editor, so hiding that editor would
+    /// empty the window's focus path and take `ToggleMessagePreview` out of the
+    /// dispatch tree with it — leaving no way back out of the preview.
+    fn render_preview(
+        &self,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Option<impl IntoElement> {
+        let preview = self.preview.clone()?;
+        // The scrollbar hangs off the wrapper, not off the element that scrolls:
+        // it is drawn as a child of whatever it is attached to, so attaching it to
+        // the scrolling element would scroll it away along with the draft it measures.
+        Some(
+            div()
+                .pb_1()
+                .mb_1()
+                .border_b_1()
+                .border_color(cx.theme().colors().border_variant)
+                .child(
+                    div()
+                        .id("message-editor-preview")
+                        .max_h_48()
+                        .overflow_y_scroll()
+                        .track_scroll(&self.preview_scroll_handle)
+                        .child(MarkdownElement::new(
+                            preview,
+                            MarkdownStyle::themed(MarkdownFont::Agent, window, cx),
+                        )),
+                )
+                .vertical_scrollbar_for(&self.preview_scroll_handle, window, cx),
+        )
+    }
+
+    /// The composer's own way in to the preview, beside `ToggleMessagePreview`.
+    /// It calls the same `toggle_preview`, so the button and the palette entry
+    /// cannot come to mean different things.
+    ///
+    /// An associated function taking the handle, because the button is rendered
+    /// by the composer's button row — which belongs to `ThreadView` — and reading
+    /// an entity there is safe where leasing it mid-render would not be.
+    pub(crate) fn render_preview_toggle(
+        message_editor: &Entity<Self>,
+        cx: &App,
+    ) -> impl IntoElement {
+        let showing = message_editor.read(cx).preview.is_some();
+        let (icon, tooltip) = preview_toggle_affordance(showing);
+        let focus_handle = message_editor.focus_handle(cx);
+        let message_editor = message_editor.clone();
+
+        IconButton::new("toggle-message-preview", icon)
+            .icon_size(IconSize::Small)
+            .icon_color(Color::Muted)
+            .toggle_state(showing)
+            .tooltip(move |_window, cx| {
+                Tooltip::for_action_in(tooltip, &ToggleMessagePreview, &focus_handle, cx)
+            })
+            .on_click(move |_, window, cx| {
+                message_editor.update(cx, |message_editor, cx| {
+                    message_editor.toggle_preview(window, cx)
+                });
+            })
+    }
+}
+
+/// How the toggle reports the preview's state: the icon shows the state it is in
+/// (as `collab_panel`'s watch toggle does), the tooltip names what a click does.
+/// Split out from the button so that half can be asserted without a window.
+fn preview_toggle_affordance(showing: bool) -> (IconName, &'static str) {
+    if showing {
+        (IconName::Eye, "Hide Markdown Preview")
+    } else {
+        (IconName::EyeOff, "Show Markdown Preview")
+    }
+}
+
 impl Render for MessageEditor {
-    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        div()
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        v_flex()
             .key_context("MessageEditor")
             .on_action(cx.listener(Self::chat))
             .on_action(cx.listener(Self::send_immediately))
@@ -2031,8 +2158,12 @@ impl Render for MessageEditor {
             .capture_action(cx.listener(Self::cut))
             .on_action(cx.listener(Self::paste_raw))
             .capture_action(cx.listener(Self::paste))
+            .on_action(cx.listener(|this, _: &ToggleMessagePreview, window, cx| {
+                this.toggle_preview(window, cx)
+            }))
             .flex_1()
-            .child({
+            .children(self.render_preview(window, cx))
+            .child(div().flex_1().child({
                 let settings = ThemeSettings::get_global(cx);
 
                 let text_style = TextStyle {
@@ -2057,7 +2188,7 @@ impl Render for MessageEditor {
                         ..Default::default()
                     },
                 )
-            })
+            }))
     }
 }
 
@@ -2265,6 +2396,7 @@ mod tests {
     use std::{ops::Range, path::Path, path::PathBuf, rc::Rc, sync::Arc};
 
     use super::PromptLocalCommand;
+    use super::preview_toggle_affordance;
     use acp_thread::MentionUri;
     use agent::{ThreadStore, outline};
     use agent_client_protocol::schema::v1 as acp;
@@ -5849,4 +5981,163 @@ mod tests {
             "Expected text to start with 'prefix text\\n\\n', got: {text:?}"
         );
     }
+
+    #[gpui::test]
+    async fn composer_opens_in_editing_mode(cx: &mut TestAppContext) {
+        init_test(cx);
+        let (message_editor, cx) = setup_message_editor(cx).await;
+
+        message_editor.update_in(cx, |editor, window, cx| {
+            editor.set_text("a draft", window, cx);
+        });
+
+        message_editor.read_with(cx, |editor, cx| {
+            assert!(
+                editor
+                    .preview_markdown()
+                    .map(|markdown| markdown.read(cx).source().to_string())
+                    .is_none(),
+                "the composer is for composing; the preview is something the user asks for"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn preview_renders_the_current_draft(cx: &mut TestAppContext) {
+        init_test(cx);
+        let (message_editor, cx) = setup_message_editor(cx).await;
+
+        let draft = "# Heading\n\nbody with **bold** and a `call()`";
+        message_editor.update_in(cx, |editor, window, cx| {
+            editor.set_text(draft, window, cx);
+            editor.toggle_preview(window, cx);
+        });
+
+        message_editor.read_with(cx, |editor, cx| {
+            let source = editor
+                .preview_markdown()
+                .map(|markdown| markdown.read(cx).source().to_string())
+                .expect("toggling the preview must produce something to render");
+            assert_eq!(
+                source,
+                editor.text(cx),
+                "the preview renders the draft, not a copy of it from somewhere else"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn toggling_back_leaves_the_draft_intact(cx: &mut TestAppContext) {
+        // The hostile half: a preview that swaps the buffer out and back is how a
+        // composer eats a half-written message. The draft must survive the round trip
+        // byte for byte, trailing newline and all.
+        init_test(cx);
+        let (message_editor, cx) = setup_message_editor(cx).await;
+
+        let draft = "- one\n- two\n\nstill drafting\n";
+        message_editor.update_in(cx, |editor, window, cx| {
+            editor.set_text(draft, window, cx);
+        });
+        let before = message_editor.update(cx, |editor, cx| editor.text(cx));
+
+        message_editor.update_in(cx, |editor, window, cx| {
+            editor.toggle_preview(window, cx);
+            editor.toggle_preview(window, cx);
+        });
+
+        message_editor.read_with(cx, |editor, cx| {
+            assert_eq!(editor.text(cx), before, "the draft must survive a preview");
+            assert!(
+                editor
+                    .preview_markdown()
+                    .map(|markdown| markdown.read(cx).source().to_string())
+                    .is_none(),
+                "toggling twice returns to editing"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn preview_re_renders_after_the_draft_changes(cx: &mut TestAppContext) {
+        // The THIRD element, and the derived value this patch invents: the rendered
+        // source. A `Entity<Markdown>` created at the first toggle and reused afterwards
+        // renders the FIRST draft forever — and every two-step test passes anyway,
+        // because the first render was correct. Only a third step, with a draft that
+        // changed in between, asks what the cached value now denotes.
+        init_test(cx);
+        let (message_editor, cx) = setup_message_editor(cx).await;
+
+        message_editor.update_in(cx, |editor, window, cx| {
+            editor.set_text("first draft about parsers", window, cx);
+            editor.toggle_preview(window, cx);
+        });
+        message_editor.read_with(cx, |editor, cx| {
+            let source = editor
+                .preview_markdown()
+                .map(|markdown| markdown.read(cx).source().to_string())
+                .expect("preview");
+            assert!(source.contains("first draft"), "{source:?}");
+        });
+
+        message_editor.update_in(cx, |editor, window, cx| {
+            editor.toggle_preview(window, cx);
+            editor.set_text("second draft about lexers", window, cx);
+            editor.toggle_preview(window, cx);
+        });
+
+        message_editor.read_with(cx, |editor, cx| {
+            let source = editor
+                .preview_markdown()
+                .map(|markdown| markdown.read(cx).source().to_string())
+                .expect("preview");
+            assert_eq!(
+                source,
+                editor.text(cx),
+                "the preview must re-derive from the draft it is shown for"
+            );
+            assert!(
+                !source.contains("first draft"),
+                "a preview cached at the first toggle keeps rendering the first draft: {source:?}"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn previewing_an_empty_draft_is_harmless(cx: &mut TestAppContext) {
+        // Nothing typed yet is the composer's most common state; toggling there must not
+        // panic and must not leave a phantom draft behind.
+        init_test(cx);
+        let (message_editor, cx) = setup_message_editor(cx).await;
+
+        message_editor.update_in(cx, |editor, window, cx| {
+            editor.toggle_preview(window, cx);
+            editor.toggle_preview(window, cx);
+        });
+
+        message_editor.read_with(cx, |editor, cx| {
+            assert!(editor.is_empty(cx), "an empty draft stays empty");
+            assert!(
+                editor
+                    .preview_markdown()
+                    .map(|markdown| markdown.read(cx).source().to_string())
+                    .is_none()
+            );
+        });
+    }
+
+    #[test]
+    fn preview_toggle_affordance_reports_the_state_it_is_in() {
+        let (hidden_icon, hidden_tooltip) = preview_toggle_affordance(false);
+        let (showing_icon, showing_tooltip) = preview_toggle_affordance(true);
+
+        // The button is the only thing telling a user whether the preview is on;
+        // an affordance that looks identical in both states reports nothing.
+        assert_ne!(hidden_icon, showing_icon);
+        assert_ne!(hidden_tooltip, showing_tooltip);
+
+        // The tooltip names what a click does, not what the state is, so it must
+        // read as an instruction and invert with the state.
+        assert!(hidden_tooltip.starts_with("Show"));
+        assert!(showing_tooltip.starts_with("Hide"));
+    }
 }
