From a8c4418aff76913b888eac0f62a1b1fac33e7112 Mon Sep 17 00:00:00 2001 From: ARIA Date: Fri, 21 Aug 2026 00:43:39 +0200 Subject: [PATCH] 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 --- app/desktopApp/build.gradle.kts | 13 ++ app/settings.gradle.kts | 2 + app/shared/build.gradle.kts | 14 ++ .../kotlin/iris/platform/AndroidWebView.kt | 39 ++++ .../src/commonMain/kotlin/iris/IrisApp.kt | 4 + .../kotlin/iris/platform/PlatformWebView.kt | 18 ++ .../commonMain/kotlin/iris/ui/HtmlArtifact.kt | 212 ++++++++++++++++++ .../commonMain/kotlin/iris/ui/MarkdownText.kt | 19 +- .../kotlin/iris/ui/screens/ChatScreen.kt | 2 + .../kotlin/iris/ui/HtmlArtifactTest.kt | 65 ++++++ .../kotlin/iris/platform/DesktopWebView.kt | 94 ++++++++ 11 files changed, 479 insertions(+), 3 deletions(-) create mode 100644 app/shared/src/androidMain/kotlin/iris/platform/AndroidWebView.kt create mode 100644 app/shared/src/commonMain/kotlin/iris/platform/PlatformWebView.kt create mode 100644 app/shared/src/commonMain/kotlin/iris/ui/HtmlArtifact.kt create mode 100644 app/shared/src/commonTest/kotlin/iris/ui/HtmlArtifactTest.kt create mode 100644 app/shared/src/desktopMain/kotlin/iris/platform/DesktopWebView.kt diff --git a/app/desktopApp/build.gradle.kts b/app/desktopApp/build.gradle.kts index 8fef8bf..e5fff03 100644 --- a/app/desktopApp/build.gradle.kts +++ b/app/desktopApp/build.gradle.kts @@ -28,6 +28,16 @@ compose.desktop { } } +// M9: KCEF (JCEF/Chromium) for HTML artifact previews needs AWT reflection +// access on modern JDKs. Applied to the `run` task (JavaExec) and, below, to +// the jpackage `--java-options`. +afterEvaluate { + tasks.withType { + jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED") + jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED") + } +} + // ── M6: native packaging (docs/11 §11.4) ───────────────────────────────── // `./gradlew :desktopApp:jpackage` builds a self-contained app image under // build/jpackage (JRE bundled). `./gradlew :desktopApp:jpackage @@ -78,5 +88,8 @@ tasks.register("jpackage") { "--icon", file("src/main/resources/icon.png").absolutePath, "--dest", destDir.absolutePath, "--java-options", "-Xmx1g", + // M9: KCEF (JCEF) AWT reflection access (see the JavaExec block above). + "--java-options", "--add-opens=java.desktop/sun.awt=ALL-UNNAMED", + "--java-options", "--add-opens=java.desktop/java.awt.peer=ALL-UNNAMED", ) } \ No newline at end of file diff --git a/app/settings.gradle.kts b/app/settings.gradle.kts index ed24893..bf1a6e6 100644 --- a/app/settings.gradle.kts +++ b/app/settings.gradle.kts @@ -10,6 +10,8 @@ dependencyResolutionManagement { repositories { mavenCentral() google() + // KCEF (desktop WebView backend) pulls jogamp artifacts from here. + maven("https://jogamp.org/deployment/maven") } } diff --git a/app/shared/build.gradle.kts b/app/shared/build.gradle.kts index f0530e7..04f12ec 100644 --- a/app/shared/build.gradle.kts +++ b/app/shared/build.gradle.kts @@ -10,6 +10,11 @@ val composeVersion = "1.7.3" val coroutinesVersion = "1.9.0" val serializationVersion = "1.7.3" val okhttpVersion = "4.12.0" +// Pinned to 1.9.40: the last release built against Compose 1.7.x (1.7.1); +// 2.0.x moves to Compose 1.8+. HTML artifact previews (M9): Android uses the +// platform WebView, desktop uses KCEF (JCEF/Chromium, downloaded on first use). +val webviewVersion = "1.9.40" +val kcefVersion = "2024.04.20.3" // Pinned to 0.33.0: the last release built against Compose 1.7.x / Kotlin 2.1.x // (0.34.0+ moves to Compose 1.8+). Provides GFM tables, bold/italic/underscore, // and the -code module for language-aware syntax highlighting (Highlights). @@ -45,10 +50,19 @@ kotlin { implementation("com.mikepenz:multiplatform-markdown-renderer:$markdownVersion") implementation("com.mikepenz:multiplatform-markdown-renderer-m3:$markdownVersion") implementation("com.mikepenz:multiplatform-markdown-renderer-code:$markdownVersion") + // M9: HTML artifact previews — WebView composable (platform WebView + // on Android, KCEF/JCEF on desktop). + implementation("io.github.kevinnzou:compose-webview-multiplatform:$webviewVersion") } commonTest.dependencies { implementation(kotlin("test")) } + // M9: KCEF (JCEF/Chromium) for desktop HTML artifact previews. The + // webview library exposes it transitively, but we reference KCEF + // directly (init + progress) so declare it explicitly. + desktopMain.dependencies { + implementation("dev.datlag:kcef:$kcefVersion") + } // M4: ExoPlayer (Media3) for inline audio/video playback (Android only). androidMain.dependencies { // M7: EncryptedSharedPreferences for the pairing token diff --git a/app/shared/src/androidMain/kotlin/iris/platform/AndroidWebView.kt b/app/shared/src/androidMain/kotlin/iris/platform/AndroidWebView.kt new file mode 100644 index 0000000..3364d31 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/iris/platform/AndroidWebView.kt @@ -0,0 +1,39 @@ +package iris.platform + +import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import com.multiplatform.webview.web.WebView +import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData + +/** + * M9: HTML artifact preview on Android — the platform WebView (JavaScript + * enabled by default in the library's WebSettings). + */ +@Composable +actual fun PlatformWebView(html: String, modifier: Modifier) { + val state = rememberWebViewStateWithHTMLData(data = html) + WebView(state, modifier = modifier) +} + +@Composable +actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier { + val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + val currentOnBack = rememberUpdatedState(onBack) + DisposableEffect(dispatcher) { + if (dispatcher != null) { + val callback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + currentOnBack.value() + } + } + dispatcher.addCallback(callback) + onDispose { callback.remove() } + } + onDispose { } + } + return this +} \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/iris/IrisApp.kt b/app/shared/src/commonMain/kotlin/iris/IrisApp.kt index 8b8a495..34ca887 100644 --- a/app/shared/src/commonMain/kotlin/iris/IrisApp.kt +++ b/app/shared/src/commonMain/kotlin/iris/IrisApp.kt @@ -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() } } } diff --git a/app/shared/src/commonMain/kotlin/iris/platform/PlatformWebView.kt b/app/shared/src/commonMain/kotlin/iris/platform/PlatformWebView.kt new file mode 100644 index 0000000..d9d2299 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/iris/platform/PlatformWebView.kt @@ -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 \ No newline at end of file diff --git a/app/shared/src/commonMain/kotlin/iris/ui/HtmlArtifact.kt b/app/shared/src/commonMain/kotlin/iris/ui/HtmlArtifact.kt new file mode 100644 index 0000000..5705a18 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/iris/ui/HtmlArtifact.kt @@ -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(" (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("]*>(.*?)", 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(null) + val current: State 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)) + } + } +} + diff --git a/app/shared/src/commonMain/kotlin/iris/ui/MarkdownText.kt b/app/shared/src/commonMain/kotlin/iris/ui/MarkdownText.kt index f60dec1..9e67092 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/MarkdownText.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/MarkdownText.kt @@ -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, diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index 0de9df7..35a78a5 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -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, ) } } diff --git a/app/shared/src/commonTest/kotlin/iris/ui/HtmlArtifactTest.kt b/app/shared/src/commonTest/kotlin/iris/ui/HtmlArtifactTest.kt new file mode 100644 index 0000000..99b6132 --- /dev/null +++ b/app/shared/src/commonTest/kotlin/iris/ui/HtmlArtifactTest.kt @@ -0,0 +1,65 @@ +package iris.ui + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class HtmlArtifactTest { + + @Test + fun htmlLanguageTagIsArtifact() { + assertTrue(isHtmlArtifact("html", "

hi

")) + assertTrue(isHtmlArtifact("htm", "

hi

")) + assertTrue(isHtmlArtifact("HTML", "

hi

")) + assertTrue(isHtmlArtifact(" html ", "

hi

")) + } + + @Test + fun otherLanguageTagsAreNotArtifacts() { + assertFalse(isHtmlArtifact("css", "")) + assertFalse(isHtmlArtifact("js", "console.log(1)")) + assertFalse(isHtmlArtifact("kotlin", "fun main() {}")) + // Even a full document inside a non-html fence is not an artifact. + assertFalse(isHtmlArtifact("text", "")) + } + + @Test + fun untaggedCompleteDocumentIsArtifact() { + assertTrue(isHtmlArtifact(null, "\nhi")) + assertTrue(isHtmlArtifact(null, " hi")) + assertTrue(isHtmlArtifact(null, "hi")) + } + + @Test + fun untaggedFragmentIsNotArtifact() { + assertFalse(isHtmlArtifact(null, "
just a fragment
")) + assertFalse(isHtmlArtifact(null, "Some prose with bold inside.")) + assertFalse(isHtmlArtifact(null, "")) + } + + @Test + fun extractsTitle() { + assertEquals( + "My Page", + htmlTitle("My Page"), + ) + assertEquals( + "Spaced", + htmlTitle(" Spaced "), + ) + // Case-insensitive, attributes allowed. + assertEquals( + "X", + htmlTitle("X"), + ) + } + + @Test + fun missingOrEmptyTitleIsNull() { + assertNull(htmlTitle("no title")) + assertNull(htmlTitle("")) + assertNull(htmlTitle(" ")) + } +} \ No newline at end of file diff --git a/app/shared/src/desktopMain/kotlin/iris/platform/DesktopWebView.kt b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopWebView.kt new file mode 100644 index 0000000..0feb828 --- /dev/null +++ b/app/shared/src/desktopMain/kotlin/iris/platform/DesktopWebView.kt @@ -0,0 +1,94 @@ +package iris.platform + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import com.multiplatform.webview.web.WebView +import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData +import dev.datlag.kcef.KCEF +import iris.ui.theme.IrisColors +import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.withContext + +/** + * M9: HTML artifact preview on desktop — JCEF (Chromium) via KCEF. + * + * KCEF is initialized once per process, lazily on the first artifact + * preview. The JCEF/Chromium binary is downloaded from the pinned + * JetBrainsRuntime release on first use and cached in ~/.iris/kcef-bundle, + * so later starts (and the packaged app) reuse it. + */ +private val kcefReady = MutableStateFlow(false) +private val kcefProgress = MutableStateFlow(0f) +private val kcefError = MutableStateFlow(null) + +private suspend fun ensureKcefInitialized() { + if (kcefReady.value) return + withContext(Dispatchers.IO) { + KCEF.init( + builder = { + installDir(File(System.getProperty("user.home"), ".iris/kcef-bundle")) + // Pinned to the JBR 17 release the KCEF 2024.04.20.x line was + // built against; the default "latest" JBR may be JDK 21-based + // and is not guaranteed compatible. + download { + github { release("jbr-release-17.0.12b1207.37") } + } + settings { + cachePath = File(System.getProperty("user.home"), ".iris/kcef-cache").absolutePath + } + progress { + onDownloading { kcefProgress.value = it } + onInitialized { kcefReady.value = true } + } + }, + onError = { kcefError.value = it?.message ?: "Preview engine failed to start" }, + onRestartRequired = { kcefError.value = "Restart the app to finish setting up previews" }, + ) + } +} + +@Composable +actual fun PlatformWebView(html: String, modifier: Modifier) { + val ready by kcefReady.collectAsState() + val progress by kcefProgress.collectAsState() + val error by kcefError.collectAsState() + LaunchedEffect(Unit) { ensureKcefInitialized() } + Box(modifier = modifier.fillMaxSize()) { + when { + error != null -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text(error!!, color = IrisColors.errorText) + } + } + !ready -> { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator(progress = { progress }) + Text( + text = "Preparing preview… ${progress.toInt()}%", + color = IrisColors.textSecondary, + ) + } + } + } + else -> { + val state = rememberWebViewStateWithHTMLData(data = html) + WebView(state, modifier = Modifier.fillMaxSize()) + } + } + } +} + +@Composable +actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier = this \ No newline at end of file