Skip to content

feat(ios): add inline Adaptive Card rendering in chat#42306

Closed
VikrantSingh01 wants to merge 0 commit intoopenclaw:mainfrom
VikrantSingh01:feat/adaptive-cards-ios-rendering
Closed

feat(ios): add inline Adaptive Card rendering in chat#42306
VikrantSingh01 wants to merge 0 commit intoopenclaw:mainfrom
VikrantSingh01:feat/adaptive-cards-ios-rendering

Conversation

@VikrantSingh01
Copy link
Copy Markdown

Summary

Adds native Adaptive Card rendering to the iOS and macOS apps. When a chat message contains <!--adaptive-card--> markers, the card is parsed and rendered inline in the chat bubble using SwiftUI.

New files (in apps/shared/OpenClawKit/Sources/OpenClawChatUI/AdaptiveCard/)

  • AdaptiveCardModels.swift (179 lines) — Codable models for AC v1.5 with manual decoding dispatching on type field
  • AdaptiveCardParser.swift (44 lines) — marker extraction + JSON decode
  • AdaptiveCardView.swift (213 lines) — SwiftUI renderer with dark/light mode via @Environment(\.colorScheme)

Modified

  • ChatMessageViews.swiftChatAssistantTextBody checks for markers before markdown render; renders AdaptiveCardView inline when found

Element support

TextBlock (with weight/size/subtle), FactSet (label-value rows), ColumnSet (HStack), Container (VStack with background), Image (AsyncImage), Action.Submit (Button), Action.OpenUrl (openURL)

macOS

Gets this for free — reuses OpenClawChatView from the shared OpenClawChatUI module.

Performance target

<50ms first render, <2MB memory per card.

Part of the Adaptive Cards first-class GenUI strategy. Works with the standalone openclaw-adaptive-cards plugin.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented Mar 10, 2026

Greptile Summary

This PR adds native Adaptive Card rendering to the iOS/macOS chat UI, introducing three new Swift files (models, parser, SwiftUI renderer) and wiring them into ChatAssistantTextBody in ChatMessageViews.swift. The feature fits well into the existing chat architecture and the element coverage (TextBlock, FactSet, ColumnSet, Container, Image, actions) is a solid first cut. Several issues need attention before this is production-ready:

  • JSON decoding in SwiftUI body: AdaptiveCardParser.parseAdaptiveCardMarkers — which does string searching and full JSONDecoder.decode — is called inside the body computed property of ChatAssistantTextBody. SwiftUI calls body on every render pass, meaning this decoding runs repeatedly during message streaming and contradicts the stated <50ms performance target. The parsed result should be lifted out of body (e.g., computed once at the model layer and passed down).
  • Action.Submit renders a non-functional button: The button is styled as interactive but its tap handler only print()s to the console. No callback or delegate exists to propagate the submit event and its data payload to the host app — users clicking it will see no response.
  • AnyCodableAction silently drops complex data payloads: The decoder only handles [String: String] and String, discarding numbers, booleans, nested objects, or arrays, which are common in real submit payloads. This data loss will become a bug as soon as a real submit handler is wired up.
  • Column.width is ignored: The parsed width field on Column is never applied — all columns always render at equal width regardless of the card's layout hints.
  • Dead code in textBlockFont: The "extraLarge" case is unreachable because size?.lowercased() has already been applied; only "extralarge" can match.

Confidence Score: 2/5

  • Not safe to merge — the Action.Submit button is visually functional but does nothing, JSON decoding runs on every render pass during streaming, and submit data payloads are silently dropped.
  • Three logic-level issues need resolution: a non-functional but interactive UI element (Action.Submit), a performance regression from parsing in body (impactful during streaming), and silent data loss in AnyCodableAction. The column width omission further reduces spec fidelity. These are not just style concerns — two of them will directly cause user-visible broken behavior.
  • AdaptiveCardView.swift (unimplemented submit handler, ignored column width), AdaptiveCardModels.swift (limited AnyCodableAction), and ChatMessageViews.swift (decoding in body) all require attention before merging.

Last reviewed commit: 762ab48

font: font,
textColor: OpenClawChatTheme.assistantText)
// Check for adaptive card markers before standard markdown rendering
if let parsed = AdaptiveCardParser.parseAdaptiveCardMarkers(from: self.text) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

JSON decoding runs on every SwiftUI render

AdaptiveCardParser.parseAdaptiveCardMarkers(from:) performs string range searches and a full JSONDecoder.decode(...) call inside the body computed property, which SwiftUI can call many times — and very frequently during message streaming. This will cause repeated JSON decoding of the same content on every render pass, directly contradicting the stated <50ms performance target under streaming load.

The parsed result should be computed once and cached. A common pattern is to move the parsing into a let/@State calculated outside body, or hoist the parsed card up to a parent view, for example:

private struct ChatAssistantTextBody: View {
    let text: String
    let markdownVariant: ChatMarkdownVariant
    let includesThinking: Bool

    // Parsed once per unique text value
    private var parsed: ParsedAdaptiveCard? {
        AdaptiveCardParser.parseAdaptiveCardMarkers(from: text)
    }
    ...
}

But even that recomputes on each render. Ideally, the ParsedAdaptiveCard result should be lifted into the model layer (e.g., alongside primaryText) and passed down so parsing only happens when the message content changes, not on every view invalidation.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatMessageViews.swift
Line: 623

Comment:
**JSON decoding runs on every SwiftUI render**

`AdaptiveCardParser.parseAdaptiveCardMarkers(from:)` performs string range searches and a full `JSONDecoder.decode(...)` call inside the `body` computed property, which SwiftUI can call many times — and very frequently during message streaming. This will cause repeated JSON decoding of the same content on every render pass, directly contradicting the stated <50ms performance target under streaming load.

The parsed result should be computed once and cached. A common pattern is to move the parsing into a `let`/`@State` calculated outside `body`, or hoist the parsed card up to a parent view, for example:

```swift
private struct ChatAssistantTextBody: View {
    let text: String
    let markdownVariant: ChatMarkdownVariant
    let includesThinking: Bool

    // Parsed once per unique text value
    private var parsed: ParsedAdaptiveCard? {
        AdaptiveCardParser.parseAdaptiveCardMarkers(from: text)
    }
    ...
}
```

But even that recomputes on each render. Ideally, the `ParsedAdaptiveCard` result should be lifted into the model layer (e.g., alongside `primaryText`) and passed down so parsing only happens when the message content changes, not on every view invalidation.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 0a2fd15. Moved parsing to a parsedCard computed property on the struct (outside body). Also added filteredText that strips think blocks before card parsing.

Comment on lines +179 to +189
case .submit(let a):
Button {
// Submit action tap (logged for debugging)
print("[AdaptiveCard] Action.Submit tapped: \(a.title ?? "untitled")")
} label: {
Text(a.title ?? "Submit")
.font(.caption)
}
.buttonStyle(.bordered)
.controlSize(.small)
case .unknown:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Action.Submit button silently does nothing

The Action.Submit button is rendered with full interactive styling (.bordered, .controlSize(.small)) making it appear functional to users, but its handler only print(...)s to the console. There is no callback, delegate, or environment key to propagate the submit event and its data payload to the host application.

A user tapping this button will receive no feedback and no action will be taken. This is a UX regression for any card that relies on submit actions. Either:

  1. Expose an action handler (e.g., via an @Environment or a closure on AdaptiveCardView) so the host can respond to submit taps.
  2. Disable the button (.disabled(true)) and add a visual affordance indicating it is not yet supported, so users aren't misled.

Without a real handler, Action.Submit cards will silently appear broken to end users.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/shared/OpenClawKit/Sources/OpenClawChatUI/AdaptiveCard/AdaptiveCardView.swift
Line: 179-189

Comment:
**`Action.Submit` button silently does nothing**

The `Action.Submit` button is rendered with full interactive styling (`.bordered`, `.controlSize(.small)`) making it appear functional to users, but its handler only `print(...)`s to the console. There is no callback, delegate, or environment key to propagate the submit event and its `data` payload to the host application.

A user tapping this button will receive no feedback and no action will be taken. This is a UX regression for any card that relies on submit actions. Either:

1. Expose an action handler (e.g., via an `@Environment` or a closure on `AdaptiveCardView`) so the host can respond to submit taps.
2. Disable the button (`.disabled(true)`) and add a visual affordance indicating it is not yet supported, so users aren't misled.

Without a real handler, `Action.Submit` cards will silently appear broken to end users.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 0a2fd15. Button is now .disabled(true) with .help("Card actions not yet supported"). No non-functional interactive buttons ship.

Comment on lines +124 to +146
struct AnyCodableAction: Codable {
let value: Any

init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let dict = try? container.decode([String: String].self) {
self.value = dict
} else if let str = try? container.decode(String.self) {
self.value = str
} else {
self.value = ""
}
}

func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
if let dict = self.value as? [String: String] {
try container.encode(dict)
} else if let str = self.value as? String {
try container.encode(str)
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

AnyCodableAction silently drops non-[String: String] data

The decoder only attempts [String: String] and then String. In real-world Adaptive Card Action.Submit payloads, data frequently contains mixed-type dictionaries — numbers, booleans, nested objects, or arrays. Any of these will fall through to self.value = "", silently discarding the payload data.

For example, a card with:

{ "type": "Action.Submit", "data": { "amount": 42, "confirmed": true } }

...will decode data as "" instead of the intended dictionary.

Since this data is what the host application would act upon in a real submit handler, data loss here would cause broken submit flows once a real handler is wired up. Consider broadening the type to handle [String: Any] or using a JSONValue enum pattern that covers all JSON primitive types.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/shared/OpenClawKit/Sources/OpenClawChatUI/AdaptiveCard/AdaptiveCardModels.swift
Line: 124-146

Comment:
**`AnyCodableAction` silently drops non-`[String: String]` data**

The decoder only attempts `[String: String]` and then `String`. In real-world Adaptive Card `Action.Submit` payloads, `data` frequently contains mixed-type dictionaries — numbers, booleans, nested objects, or arrays. Any of these will fall through to `self.value = ""`, silently discarding the payload data.

For example, a card with:
```json
{ "type": "Action.Submit", "data": { "amount": 42, "confirmed": true } }
```
...will decode `data` as `""` instead of the intended dictionary.

Since this `data` is what the host application would act upon in a real submit handler, data loss here would cause broken submit flows once a real handler is wired up. Consider broadening the type to handle `[String: Any]` or using a `JSONValue` enum pattern that covers all JSON primitive types.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 0a2fd15. AnyCodableAction now decodes [String: AnyCodableAction] (recursive), [AnyCodableAction], String, Double, Bool, Int — no more silent data loss on complex payloads.

Comment on lines +91 to +104
@ViewBuilder
private func renderColumnSet(_ cs: CardElement.ColumnSet) -> some View {
HStack(alignment: .top, spacing: 8) {
ForEach(cs.columns.indices, id: \.self) { i in
VStack(alignment: .leading, spacing: 6) {
if let items = cs.columns[i].items {
ForEach(items.indices, id: \.self) { j in
self.renderElement(items[j])
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Column.width is parsed but never used in layout

CardElement.Column.width is decoded from the JSON but renderColumnSet always applies .frame(maxWidth: .infinity) to every column regardless. This means adaptive cards with explicit column widths (e.g., "width": "auto", "width": "1", "width": "200px") will all render at equal width, breaking the intended layout.

The width field should be applied during rendering. The Adaptive Cards spec supports "auto", "stretch", and pixel/weight values, so at minimum distinguishing between "auto" and weighted numeric values would improve fidelity.

Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/shared/OpenClawKit/Sources/OpenClawChatUI/AdaptiveCard/AdaptiveCardView.swift
Line: 91-104

Comment:
**`Column.width` is parsed but never used in layout**

`CardElement.Column.width` is decoded from the JSON but `renderColumnSet` always applies `.frame(maxWidth: .infinity)` to every column regardless. This means adaptive cards with explicit column widths (e.g., `"width": "auto"`, `"width": "1"`, `"width": "200px"`) will all render at equal width, breaking the intended layout.

The `width` field should be applied during rendering. The Adaptive Cards spec supports `"auto"`, `"stretch"`, and pixel/weight values, so at minimum distinguishing between `"auto"` and weighted numeric values would improve fidelity.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 0a2fd15. Column width is now read and applied: "auto" gets maxWidth: nil, others stretch with .infinity.

Comment on lines +59 to +72
private func textBlockFont(_ tb: CardElement.TextBlock) -> Font {
switch tb.size?.lowercased() {
case "extralarge", "extraLarge":
return .title
case "large":
return .title2
case "medium":
return .body
case "small":
return .caption
default:
return .subheadline
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Unreachable "extraLarge" case in font switch

tb.size?.lowercased() is called before the switch, so the input is already fully lowercase. The "extraLarge" pattern in the first case will never match because a lowercased string cannot contain an uppercase L.

Suggested change
private func textBlockFont(_ tb: CardElement.TextBlock) -> Font {
switch tb.size?.lowercased() {
case "extralarge", "extraLarge":
return .title
case "large":
return .title2
case "medium":
return .body
case "small":
return .caption
default:
return .subheadline
}
}
private func textBlockFont(_ tb: CardElement.TextBlock) -> Font {
switch tb.size?.lowercased() {
case "extralarge":
return .title
case "large":
return .title2
case "medium":
return .body
case "small":
return .caption
default:
return .subheadline
}
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/shared/OpenClawKit/Sources/OpenClawChatUI/AdaptiveCard/AdaptiveCardView.swift
Line: 59-72

Comment:
**Unreachable `"extraLarge"` case in font switch**

`tb.size?.lowercased()` is called before the `switch`, so the input is already fully lowercase. The `"extraLarge"` pattern in the first `case` will never match because a lowercased string cannot contain an uppercase `L`.

```suggestion
    private func textBlockFont(_ tb: CardElement.TextBlock) -> Font {
        switch tb.size?.lowercased() {
        case "extralarge":
            return .title
        case "large":
            return .title2
        case "medium":
            return .body
        case "small":
            return .caption
        default:
            return .subheadline
        }
    }
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 0a2fd15. Changed "extraLarge" to "extralarge" since lowercased() is already applied.

Comment on lines +123 to +143
private func renderImage(_ img: CardElement.ImageElement) -> some View {
if let url = URL(string: img.url) {
AsyncImage(url: url) { phase in
switch phase {
case .success(let image):
image
.resizable()
.scaledToFit()
.frame(maxHeight: self.imageMaxHeight(img.size))
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
case .failure:
Label(img.altText ?? "Image", systemImage: "photo")
.font(.caption)
.foregroundStyle(.secondary)
default:
ProgressView()
.frame(height: 40)
}
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No fallback shown for invalid image URLs

When URL(string: img.url) returns nil (malformed URL), renderImage silently produces an EmptyView — no error label, no placeholder. By contrast, the AsyncImage .failure phase correctly shows a Label with img.altText. An invalid URL silently vanishes from the layout, which can be confusing.

Consider rendering the same Label(img.altText ?? "Image", systemImage: "photo") fallback for the nil-URL case:

} else {
    Label(img.altText ?? "Image", systemImage: "photo")
        .font(.caption)
        .foregroundStyle(.secondary)
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/shared/OpenClawKit/Sources/OpenClawChatUI/AdaptiveCard/AdaptiveCardView.swift
Line: 123-143

Comment:
**No fallback shown for invalid image URLs**

When `URL(string: img.url)` returns `nil` (malformed URL), `renderImage` silently produces an `EmptyView` — no error label, no placeholder. By contrast, the `AsyncImage` `.failure` phase correctly shows a `Label` with `img.altText`. An invalid URL silently vanishes from the layout, which can be confusing.

Consider rendering the same `Label(img.altText ?? "Image", systemImage: "photo")` fallback for the nil-URL case:

```swift
} else {
    Label(img.altText ?? "Image", systemImage: "photo")
        .font(.caption)
        .foregroundStyle(.secondary)
}
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 0a2fd15. Added Label(img.altText ?? "Image", systemImage: "photo") fallback for invalid image URLs.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 762ab48bce

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

font: font,
textColor: OpenClawChatTheme.assistantText)
// Check for adaptive card markers before standard markdown rendering
if let parsed = AdaptiveCardParser.parseAdaptiveCardMarkers(from: self.text) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Parse adaptive cards after filtering think blocks

This parses adaptive-card markers from self.text before applying includesThinking, so cards embedded inside <think>...</think> are still rendered even when assistant trace is hidden. In the normal showsAssistantTrace == false path, that leaks content that was previously suppressed by AssistantTextParser (for example, a model/tool that emits card JSON in a thinking segment will now surface it in the UI). Parse against the visible response text (or strip thinking blocks first) to preserve the existing confidentiality boundary.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 0a2fd15. Card parsing now runs against filteredText (after AssistantTextParser.segments strips think blocks), not raw self.text. Cards inside <think> blocks no longer leak.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant