diff --git a/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs b/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs
index 116f98157..b85d27faa 100644
--- a/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs
+++ b/crates/op-editor-ui/src/widgets/ai_chat_transcript.rs
@@ -31,6 +31,9 @@ const LINE_H: f32 = 16.0;
const BUBBLE_PAD: f32 = 8.0;
/// Text shown in the TS-style empty streaming assistant pill.
const TYPING_LABEL: &str = "Thinking";
+/// Text shown when a completed assistant action only contained hidden
+/// tool-call/result XML, matching the TS transcript fallback.
+const AUTOMATED_ACTION_LABEL: &str = "(Automated action completed)";
/// Horizontal padding inside the empty streaming assistant pill.
const TYPING_PAD_X: f32 = 10.0;
/// Vertical padding inside the empty streaming assistant pill.
@@ -287,6 +290,13 @@ fn build_item(
&& steps.is_empty()
&& thinking.is_none()
&& tools.is_none();
+ let automated_placeholder = !is_user
+ && !msg.streaming
+ && !msg.content.trim().is_empty()
+ && visible_content.is_empty()
+ && steps.is_empty()
+ && thinking.is_none()
+ && tools.is_none();
let bubble = if typing {
let r = Rect::xywh(
x,
@@ -300,6 +310,15 @@ fn build_item(
lines: Vec::new(),
typing: true,
})
+ } else if automated_placeholder {
+ let lines = vec![AUTOMATED_ACTION_LABEL.to_string()];
+ let r = Rect::xywh(x, y, bubble_w, LINE_H);
+ y += r.size.y;
+ Some(TextBubble {
+ rect: r,
+ lines,
+ typing: false,
+ })
} else if !visible_content.is_empty() {
let lines = wrap_units(&visible_content, budget);
let h = if is_user {
diff --git a/crates/op-editor-ui/src/widgets/ai_chat_transcript_tests.rs b/crates/op-editor-ui/src/widgets/ai_chat_transcript_tests.rs
index 81f76057b..a287bb3ca 100644
--- a/crates/op-editor-ui/src/widgets/ai_chat_transcript_tests.rs
+++ b/crates/op-editor-ui/src/widgets/ai_chat_transcript_tests.rs
@@ -365,6 +365,24 @@ after"#,
assert!(!text.contains("APPLIED"));
}
+#[test]
+fn hidden_completed_assistant_action_shows_completion_placeholder() {
+ let message = ChatMessage::assistant(
+ r#"secret
+{"ok":true}
+"#,
+ );
+
+ let items = build_transcript(
+ std::slice::from_ref(&message),
+ body(),
+ op_editor_core::Locale::EnUs,
+ );
+ let text = items[0].bubble.as_ref().unwrap().lines.join("\n");
+
+ assert_eq!(text, "(Automated action completed)");
+}
+
#[test]
fn streaming_unclosed_invoke_is_hidden_from_answer_bubble() {
let mut message = ChatMessage::assistant_streaming();