Skip to content

fix: flatten rich message cards into text for webchat frontend#512

Open
TheDarkSkyXD wants to merge 2 commits intospacedriveapp:mainfrom
TheDarkSkyXD:fix/webchat-rich-message-flattening
Open

fix: flatten rich message cards into text for webchat frontend#512
TheDarkSkyXD wants to merge 2 commits intospacedriveapp:mainfrom
TheDarkSkyXD:fix/webchat-rich-message-flattening

Conversation

@TheDarkSkyXD
Copy link
Copy Markdown
Contributor

Summary

  • The webchat frontend doesn't support rich embeds (cards), so card content (titles, descriptions, fields) was silently lost when the bot replied with RichMessage variants
  • Flattens card content into plain text using OutboundResponse::text_from_cards() across all webchat paths: SSE event forwarding (main.rs), broadcast (webchat.rs), conversation logging (reply.rs), and cancelled-history extraction (channel_history.rs)
  • No behavioral change for Discord/Slack which render cards natively

Test plan

  • Send a message that triggers a card-based response via the webchat UI — verify the full card content appears as formatted text
  • Verify conversation history reload shows the flattened card content (not just the short text field)
  • Confirm Discord/Slack responses still render rich embeds normally (no regression)
  • Test edge cases: cards with no title, cards with no description, empty card list, message with text + cards, message with only cards

🤖 Generated with Claude Code

The webchat frontend doesn't support rich embeds (cards), so card
content was being lost when messages used the RichMessage variant.
This flattens card titles, descriptions, and fields into plain text
across SSE forwarding, broadcast, conversation logging, and
cancelled-history extraction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Mar 30, 2026

Walkthrough

Adds handling of cards in outbound rich messages: cards are deserialized/collected, converted to plain text via OutboundResponse::text_from_cards, and combined with message text using OutboundResponse::text_with_cards across message emission, logging, and history extraction paths.

Changes

Cohort / File(s) Summary
Channel history extraction
src/agent/channel_history.rs
extract_reply_content_from_cancelled_history now reads reply tool call cards, attempts to deserialize to Vec<crate::Card> (warns and defaults on failure), and returns OutboundResponse::text_with_cards(...) instead of plain content.
SSE / main event forwarding
src/main.rs
forward_sse_event adds a dedicated RichMessage { text, cards, .. } arm and computes full_text via OutboundResponse::text_with_cards(text, cards) before emitting ApiEvent::OutboundMessage.
WebChat broadcasting
src/messaging/webchat.rs
WebChatAdapter::broadcast now handles OutboundResponse::RichMessage { text, cards, .. } by flattening cards into the sent text using text_with_cards.
Reply tool response & logging
src/tools/reply.rs
ReplyTool::call extracts args.cards / args.interactive_elements into locals and uses OutboundResponse::text_with_cards to produce logged_content (so logs include flattened card text when present).
OutboundResponse helper
src/lib.rs
Added public OutboundResponse::text_with_cards(text: &str, cards: &[Card]) -> String which derives text from cards and merges it with provided text using defined precedence/formatting.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: flattening rich message cards into text for the webchat frontend, which is the core objective across all modified files.
Description check ✅ Passed The description is well-structured and directly relates to the changeset, explaining the problem (lost card content), the solution (flattening cards), affected code paths, and test coverage.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/tools/reply.rs (1)

476-490: Centralize text + cards merge policy to avoid drift.

This merge logic is now duplicated across multiple paths (src/tools/reply.rs, src/main.rs, src/messaging/webchat.rs, and cancelled-history extraction). Consider one shared helper on OutboundResponse for consistent behavior everywhere.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tools/reply.rs` around lines 476 - 490, Create a single helper on
OutboundResponse (e.g., a method named flattened_text_or_merge or
merge_text_and_cards) that encapsulates the current logic: call
OutboundResponse::text_from_cards(cards) when self is RichMessage, then return
converted_content if card text is empty, card text if converted_content is
empty/whitespace, or the two joined with "\n\n" otherwise; use that method in
reply.rs (replace the inline block around OutboundResponse::RichMessage usage),
and replace the duplicated logic in main.rs, messaging/webchat.rs and the
cancelled-history extraction with calls to this new OutboundResponse helper so
the merge policy is centralized and consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/agent/channel_history.rs`:
- Around line 172-193: The current extraction is gated on finding "content" as a
string; instead, always attempt to flatten cards from
tool_call.function.arguments.get("cards") via
serde_json::from_value::<Vec<crate::Card>>() and
crate::OutboundResponse::text_from_cards(&cards), then separately check for an
optional content string from
tool_call.function.arguments.get("content").as_str(); finally, return the
combined result: if both text and card_text are non-empty return
format!("{}\n\n{}", text, card_text), if only text return text, if only
card_text return card_text, otherwise None—update the logic around
tool_call.function.arguments, content, cards, and
crate::OutboundResponse::text_from_cards to implement this.

---

Nitpick comments:
In `@src/tools/reply.rs`:
- Around line 476-490: Create a single helper on OutboundResponse (e.g., a
method named flattened_text_or_merge or merge_text_and_cards) that encapsulates
the current logic: call OutboundResponse::text_from_cards(cards) when self is
RichMessage, then return converted_content if card text is empty, card text if
converted_content is empty/whitespace, or the two joined with "\n\n" otherwise;
use that method in reply.rs (replace the inline block around
OutboundResponse::RichMessage usage), and replace the duplicated logic in
main.rs, messaging/webchat.rs and the cancelled-history extraction with calls to
this new OutboundResponse helper so the merge policy is centralized and
consistent.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7d7a2827-b5eb-4fbc-8e84-cce01808ca6b

📥 Commits

Reviewing files that changed from the base of the PR and between b7d5dd2 and dc3cf71.

📒 Files selected for processing (4)
  • src/agent/channel_history.rs
  • src/main.rs
  • src/messaging/webchat.rs
  • src/tools/reply.rs

- Extract duplicated card-flattening logic into
  OutboundResponse::text_with_cards() in src/lib.rs
- Add warn logging for card deserialization failures in
  channel_history.rs (was silently swallowed by .ok())
- Fix inaccurate comments: main.rs referenced "webchat frontend"
  but serves all SSE consumers; channel_history.rs referenced
  "webchat frontend" but feeds in-memory LLM history

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
src/agent/channel_history.rs (1)

172-194: ⚠️ Potential issue | 🟠 Major

Card extraction is still gated on content being a string.

On Line 172–174, the function still won’t preserve card-only reply payloads when content is absent or non-string. Parse cards independently, then merge whichever parts exist.

Suggested fix
-                    if let Some(content_value) = tool_call.function.arguments.get("content")
-                        && let Some(text) = content_value.as_str()
-                    {
-                        // Also extract card descriptions so the full response
-                        // (not just the short content text) is preserved in
-                        // conversation history for subsequent LLM turns.
-                        let cards = match tool_call.function.arguments.get("cards") {
-                            Some(v) => {
-                                serde_json::from_value::<Vec<crate::Card>>(v.clone())
-                                    .unwrap_or_else(|e| {
-                                        tracing::warn!(
-                                            error = %e,
-                                            "failed to deserialize cards from cancelled reply tool call; \
-                                             card content will be omitted from history"
-                                        );
-                                        Vec::new()
-                                    })
-                            }
-                            None => Vec::new(),
-                        };
-
-                        return Some(crate::OutboundResponse::text_with_cards(text, &cards));
-                    }
+                    let text = tool_call
+                        .function
+                        .arguments
+                        .get("content")
+                        .and_then(|value| value.as_str())
+                        .unwrap_or("");
+
+                    let cards = match tool_call.function.arguments.get("cards") {
+                        Some(value) => serde_json::from_value::<Vec<crate::Card>>(value.clone())
+                            .unwrap_or_else(|error| {
+                                tracing::warn!(
+                                    error = %error,
+                                    "failed to deserialize cards from cancelled reply tool call; \
+                                     card content will be omitted from history"
+                                );
+                                Vec::new()
+                            }),
+                        None => Vec::new(),
+                    };
+
+                    let merged = crate::OutboundResponse::text_with_cards(text, &cards);
+                    if !merged.trim().is_empty() {
+                        return Some(merged);
+                    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/agent/channel_history.rs` around lines 172 - 194, The current logic only
extracts cards when a string "content" exists, so card-only replies are dropped;
update the branch in the function handling tool_call.function.arguments to parse
cards unconditionally (use the existing
serde_json::from_value::<Vec<crate::Card>>(...) logic against
arguments.get("cards") regardless of "content"), then build the outbound
response by merging available parts: if content is a string use text, if cards
parsed non-empty include them, and return
crate::OutboundResponse::text_with_cards or the appropriate constructor with
just cards/text as needed (preserve the current warning behavior on deserialize
failure).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/agent/channel_history.rs`:
- Around line 172-194: The current logic only extracts cards when a string
"content" exists, so card-only replies are dropped; update the branch in the
function handling tool_call.function.arguments to parse cards unconditionally
(use the existing serde_json::from_value::<Vec<crate::Card>>(...) logic against
arguments.get("cards") regardless of "content"), then build the outbound
response by merging available parts: if content is a string use text, if cards
parsed non-empty include them, and return
crate::OutboundResponse::text_with_cards or the appropriate constructor with
just cards/text as needed (preserve the current warning behavior on deserialize
failure).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 23e37f50-3b81-4c03-85ba-c82821109a52

📥 Commits

Reviewing files that changed from the base of the PR and between dc3cf71 and 3c9b6b6.

📒 Files selected for processing (5)
  • src/agent/channel_history.rs
  • src/lib.rs
  • src/main.rs
  • src/messaging/webchat.rs
  • src/tools/reply.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/messaging/webchat.rs
  • src/main.rs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant