HTML artifacts: render agent-sent HTML/CSS/JS code blocks in an in-app WebView (Android platform WebView, desktop JCEF/KCEF) via a full-screen preview; artifact card with Preview button + code toggle, shown only once the message stops streaming; webview-multiplatform 1.9.40 + KCEF deps, detection + unit tests

This commit is contained in:
ARIA committed 2026-08-21 00:43:39 +02:00
1 parent a1814d016c
commit 7ba5632a03
11 files changed
+479 -3

No files matched your search

@@ -17,6 +17,7 @@ import iris.data.SecureStore
import iris.net.GatewayClient
import iris.platform.setActiveController
import iris.state.IrisController
import iris.ui.ArtifactPreviewOverlay
import iris.ui.screens.ChatScreen
import iris.ui.screens.ConnectScreen
import iris.ui.theme.IrisColors
@@ -96,6 +97,9 @@ fun IrisApp(
// without blocking the view (M7's full-screen spinner is gone).
else -> ChatScreen(controller)
}
// M9: full-screen HTML artifact preview (opened from an
// artifact card in the chat); covers everything while open.
ArtifactPreviewOverlay()
}
}
}
@@ -0,0 +1,18 @@
package iris.platform
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* M9: renders [html] in an in-app WebView with JavaScript enabled.
*
* Platform actuals: Android → the platform WebView; desktop → JCEF (KCEF),
* whose Chromium binary is downloaded on first use (the caller shows a
* progress indicator until it is ready).
*/
@Composable
expect fun PlatformWebView(html: String, modifier: Modifier)
/** Consumes the system back button (Android) while applied; no-op elsewhere. */
@Composable
expect fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier
@@ -0,0 +1,212 @@
package iris.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Code
import androidx.compose.material.icons.filled.CodeOff
import androidx.compose.material3.Button
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.snipme.highlights.Highlights
import iris.platform.PlatformWebView
import iris.platform.handleSystemBack
import iris.ui.theme.IrisColors
/**
* M9: HTML artifact support. When the agent emits a complete HTML document
* (HTML + CSS + JS) in a fenced code block, the bubble renders an artifact
* card instead of plain highlighted code. "Preview" opens the document in a
* full-screen in-app WebView with JavaScript enabled, so interactive scripts
* run inside the app.
*/
/**
* True when a fenced code block should be rendered as a runnable HTML
* artifact: an explicit ```html / ```htm language tag, or an untagged block
* that starts with a complete HTML document.
*/
fun isHtmlArtifact(language: String?, code: String): Boolean {
val lang = language?.trim()?.lowercase()
if (lang == "html" || lang == "htm") return true
if (lang != null) return false
val head = code.trimStart().take(64).lowercase()
return head.startsWith("<!doctype html") || head.startsWith("<html")
}
/** The document's <title> (card / overlay header), or null when absent. */
fun htmlTitle(code: String): String? =
TITLE_REGEX.find(code)?.groupValues?.get(1)?.trim()?.takeIf { it.isNotEmpty() }
private val TITLE_REGEX =
Regex("<title[^>]*>(.*?)</title>", setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL))
/** The artifact currently open in the full-screen preview (null = closed). */
data class ArtifactPreviewData(val html: String, val title: String?)
/**
* Process-wide preview state: the artifact card (deep in the message list)
* writes here, the overlay at the app root reads it.
*/
object ArtifactPreview {
private val _current = mutableStateOf<ArtifactPreviewData?>(null)
val current: State<ArtifactPreviewData?> get() = _current
fun show(html: String, title: String?) {
_current.value = ArtifactPreviewData(html, title)
}
fun hide() {
_current.value = null
}
}
/**
* Artifact card shown in place of a highlighted HTML code block: title row
* with a Preview button (opens the full-screen overlay) and a code toggle
* (reveals the highlighted source with its copy button).
*/
@Composable
fun HtmlArtifactCard(
code: String,
style: TextStyle,
highlights: Highlights.Builder,
) {
val title = remember(code) { htmlTitle(code) }
var showCode by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxWidth()) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
color = IrisColors.panel,
) {
Row(
modifier = Modifier.padding(start = 12.dp, top = 6.dp, bottom = 6.dp, end = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Code,
contentDescription = null,
tint = IrisColors.statusGreen,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title ?: "HTML artifact",
color = IrisColors.textBright,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "Interactive HTML preview",
color = IrisColors.textMuted,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Button(
onClick = { ArtifactPreview.show(code, title) },
contentPadding = androidx.compose.foundation.layout.PaddingValues(
horizontal = 12.dp,
vertical = 4.dp,
),
) {
Text("Preview", style = MaterialTheme.typography.bodyMedium)
}
IconButton(onClick = { showCode = !showCode }) {
Icon(
imageVector = if (showCode) Icons.Filled.CodeOff else Icons.Filled.Code,
contentDescription = if (showCode) "Hide code" else "Show code",
tint = IrisColors.textSecondary,
)
}
}
}
if (showCode) {
Spacer(modifier = Modifier.height(6.dp))
CodeBlockWithCopy(code = code, language = "html", style = style, highlights = highlights)
}
}
}
/**
* Full-screen artifact preview overlay (rendered at the app root). Covers the
* whole window; the header close button and the system back button (Android)
* dismiss it.
*/
@Composable
fun ArtifactPreviewOverlay() {
val preview by ArtifactPreview.current
if (preview == null) return
val data = preview!!
Box(
modifier = Modifier
.fillMaxSize()
.background(IrisColors.background)
.handleSystemBack { ArtifactPreview.hide() },
) {
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 8.dp, end = 2.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = Icons.Filled.Code,
contentDescription = null,
tint = IrisColors.statusGreen,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = data.title ?: "HTML preview",
modifier = Modifier.weight(1f),
color = IrisColors.textBright,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
IconButton(onClick = { ArtifactPreview.hide() }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close preview",
tint = IrisColors.textSecondary,
)
}
}
HorizontalDivider(color = IrisColors.divider)
PlatformWebView(html = data.html, modifier = Modifier.fillMaxSize().weight(1f))
}
}
}
@@ -60,6 +60,10 @@ fun MarkdownText(
color: Color,
fontSize: TextUnit,
modifier: Modifier = Modifier,
// M9: while the message is still streaming, HTML code blocks render as
// plain highlighted code — the artifact card (and its runnable preview)
// only appears once the message is complete.
isStreaming: Boolean = false,
) {
val state = rememberMarkdownState(text)
// The app is always dark-themed, so force the dark highlight palette
@@ -159,12 +163,20 @@ fun MarkdownText(
components = markdownComponents(
codeFence = { model ->
MarkdownCodeFence(model.content, model.node, model.typography.code) { code, language, style ->
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
if (!isStreaming && isHtmlArtifact(language, code)) {
HtmlArtifactCard(code = code, style = style, highlights = highlights)
} else {
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
}
}
},
codeBlock = { model ->
MarkdownCodeBlock(model.content, model.node, model.typography.code) { code, language, style ->
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
if (!isStreaming && isHtmlArtifact(language, code)) {
HtmlArtifactCard(code = code, style = style, highlights = highlights)
} else {
CodeBlockWithCopy(code = code, language = language, style = style, highlights = highlights)
}
}
},
// The core default checkbox renders literal "[x]"/"[ ]" text; use the
@@ -184,9 +196,10 @@ fun MarkdownText(
/**
* Renders a highlighted code block with a copy button in the top-right corner
* (Telegram-style). Tapping the button copies the whole block to the clipboard.
* Also used by [HtmlArtifactCard] to reveal the source of an HTML artifact.
*/
@Composable
private fun CodeBlockWithCopy(
internal fun CodeBlockWithCopy(
code: String,
language: String?,
style: TextStyle,
@@ -1921,6 +1921,7 @@ private fun MessageBubble(
if (msg.streaming) " ▉" else "",
color = textColor,
fontSize = 15.sp,
isStreaming = msg.streaming,
)
}
// M7: timestamp + delivery status on their own line at the
@@ -1963,6 +1964,7 @@ private fun MessageBubble(
color = textColor,
fontSize = if (isCommentary) 13.sp else 15.sp,
modifier = Modifier.fillMaxWidth(),
isStreaming = msg.streaming,
)
}
}