Перенос темы с AgentikTheme на ProvideAgentikTheme, рефакторинг стилизации Markdown

This commit is contained in:
2026-09-28 16:28:02 +03:00
parent 9aaf0c3a46
commit 9c2e794a73
7 changed files with 186 additions and 123 deletions
@@ -38,6 +38,8 @@ import pw.binom.agentik.desktop.ui.screens.ConversationList
import pw.binom.agentik.desktop.ui.screens.GroupsDialog
import pw.binom.agentik.desktop.ui.screens.NewChatDialog
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme
import pw.binom.agentik.desktop.ui.theme.toAgentikTheme
private sealed interface AppDialog {
data object Agents : AppDialog
@@ -52,7 +54,7 @@ private sealed interface AppDialog {
*/
@Composable
fun App(state: AppState) {
AgentikTheme(themeId = state.settingsState.themeId) {
ProvideAgentikTheme(theme = state.settingsState.themeId.toAgentikTheme()) {
CompositionLocalProvider(LocalAvatarStorage provides state.avatars) {
LaunchedEffect(Unit) { state.start() }
Root(state)
@@ -24,6 +24,7 @@ import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import pw.binom.agentik.desktop.ui.ClientImageSupport
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import java.util.Base64
private enum class ImageLoadState { Loading, Ready, Error }
@@ -41,6 +42,7 @@ fun ChatImage(src: String, alt: String?) {
// Декодер не зарегистрирован — fallback: alt (или ссылка) текстом.
Text(
text = alt?.takeIf { it.isNotBlank() } ?: src,
color = AgentikTheme.colors.text,
modifier = Modifier.widthIn(max = 280.dp),
)
return
@@ -18,12 +18,9 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.ClickableText
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -38,52 +35,55 @@ import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import pw.binom.agentik.desktop.ui.theme.AgentikColors
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import pw.binom.agentik.desktop.ui.theme.darkAgentikColors
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.markdown.MarkdownSegment
import pw.binom.agentik.desktop.markdown.TextStyleSpan
import pw.binom.agentik.desktop.markdown.parseMarkdown
/** Скругление у блоков (код, таблица). Геометрия, а не тема — поэтому константа. */
private val RoundedBlockShape = RoundedCornerShape(8.dp)
/** Ширина колонки таблицы ≈ [TableCharWidth] на символ, но не уже [TableMinColumnWidth]. */
private val TableCharWidth = 9.dp
private val TableMinColumnWidth = 60.dp
// ─────────────────────────────────────────────────────────────
// Public rendering entry point
// ─────────────────────────────────────────────────────────────
/**
* Parse markdown and render segments with the given base text style.
* Разбирает markdown и рисует сегменты стилем [style] (по умолчанию — текущий
* стиль темы из [LocalMarkdownStyle]).
*/
@Composable
fun renderMarkdownSegments(
content: String,
textColor: Color,
fontSize: TextUnit,
isBubbleScrolled: Boolean = false,
onUrlClick: (String) -> Unit = {},
style: MarkdownStyle = LocalMarkdownStyle.current,
) {
val baseStyle = SpanStyle(color = textColor, fontSize = fontSize)
val colors = AgentikTheme.colors
val segments = parseMarkdown(content)
val segments = remember(content) { parseMarkdown(content) }
var previousWasText = false
for (segment in segments) {
when (segment) {
is MarkdownSegment.Text -> {
val annotated = segment.toAnnotatedString(baseStyle, colors)
val annotated = segment.toAnnotatedString(style)
if (annotated.text.isNotEmpty()) {
// Между соседними абзацами (последовательными текстовыми сегментами)
// оставляем вертикальный отступ, иначе абзацы склеиваются в сплошной блок.
if (previousWasText) {
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(style.paragraphSpacing))
}
clickableMarkdownText(
annotatedString = annotated,
lineHeight = style.bodyLineHeight,
modifier = Modifier
.then(if (isBubbleScrolled) Modifier.widthIn(max = 600.dp) else Modifier),
.then(if (isBubbleScrolled) Modifier.widthIn(max = style.maxContentWidth) else Modifier),
onUrlClick = onUrlClick,
)
previousWasText = true
@@ -92,76 +92,79 @@ fun renderMarkdownSegments(
is MarkdownSegment.CodeBlock -> {
previousWasText = false
val clipboardManager = LocalClipboardManager.current
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(style.blockSpacing))
Column(
modifier = Modifier
.then(
if (isBubbleScrolled) Modifier.widthIn(max = 600.dp)
if (isBubbleScrolled) Modifier.widthIn(max = style.maxContentWidth)
else Modifier.fillMaxWidth()
)
.clip(RoundedCornerShape(8.dp))
.background(colors.codeBackground),
.clip(RoundedBlockShape)
.background(style.codeBackground),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(AgentikTheme.colors.panelElevated)
.padding(horizontal = 12.dp, vertical = 6.dp),
.background(style.codeHeaderBackground)
.padding(
horizontal = style.codeHeaderPaddingHorizontal,
vertical = style.codeHeaderPaddingVertical,
),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = segment.language ?: "Code",
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = colors.textDim,
fontSize = style.labelFontSize,
fontFamily = style.monospaceFamily,
color = style.labelColor,
)
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = "Скопировать",
modifier = Modifier
.size(16.dp)
.size(style.copyIconSize)
.clickable {
clipboardManager.setText(AnnotatedString(segment.code))
},
tint = colors.textDim,
tint = style.labelColor,
)
}
Box(
modifier = Modifier
.padding(12.dp)
.padding(style.codePadding)
.horizontalScroll(rememberScrollState()),
) {
Text(
text = segment.code,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
color = colors.codeText,
fontFamily = style.monospaceFamily,
fontSize = style.codeFontSize,
color = style.codeTextColor,
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(style.blockSpacing))
}
is MarkdownSegment.Table -> {
previousWasText = false
renderTable(segment, textColor, fontSize, isBubbleScrolled)
renderTable(segment, style, isBubbleScrolled)
}
is MarkdownSegment.Image -> {
previousWasText = false
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(style.blockSpacing))
ChatImage(src = segment.src, alt = segment.alt)
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(style.blockSpacing))
}
is MarkdownSegment.ThematicBreak -> {
previousWasText = false
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(style.blockSpacing))
Box(
modifier = Modifier
.fillMaxWidth()
.height(1.dp)
.background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f)),
.background(style.divider),
)
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(style.blockSpacing))
}
}
}
@@ -174,11 +177,10 @@ fun renderMarkdownSegments(
@Composable
private fun renderTable(
segment: MarkdownSegment.Table,
textColor: Color,
fontSize: TextUnit,
style: MarkdownStyle,
isBubbleScrolled: Boolean,
) {
Spacer(modifier = Modifier.height(6.dp))
Spacer(modifier = Modifier.height(style.tableSpacing))
val columnWidths = remember(segment) {
val columnCount = segment.headers.size
@@ -203,15 +205,14 @@ private fun renderTable(
Column(
modifier = Modifier
.then(if (isBubbleScrolled) Modifier.wrapContentWidth() else Modifier.fillMaxWidth())
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.3f))
.clip(RoundedBlockShape)
.background(style.tableSurface)
) {
if (isBubbleScrolled) {
TableRows(
segment = segment,
columnWidths = columnWidths,
textColor = textColor,
fontSize = fontSize,
style = style,
)
} else {
Box(
@@ -223,30 +224,27 @@ private fun renderTable(
TableRows(
segment = segment,
columnWidths = columnWidths,
textColor = textColor,
fontSize = fontSize,
style = style,
)
}
}
}
}
Spacer(modifier = Modifier.height(6.dp))
Spacer(modifier = Modifier.height(style.tableSpacing))
}
@Composable
private fun TableRows(
segment: MarkdownSegment.Table,
columnWidths: List<Int>,
textColor: Color,
fontSize: TextUnit,
style: MarkdownStyle,
) {
// Header row
TableRow(
cells = segment.headers,
widths = columnWidths,
isHeader = true,
textColor = textColor,
fontSize = fontSize,
style = style,
)
// Data rows
segment.rows.forEachIndexed { idx, row ->
@@ -254,8 +252,7 @@ private fun TableRows(
cells = row,
widths = columnWidths,
isHeader = false,
textColor = textColor,
fontSize = fontSize,
style = style,
rowIndex = idx,
)
}
@@ -266,14 +263,13 @@ private fun TableRow(
cells: List<String>,
widths: List<Int>,
isHeader: Boolean,
textColor: Color,
fontSize: TextUnit,
style: MarkdownStyle,
rowIndex: Int = 0,
) {
val bgColor = when {
isHeader -> MaterialTheme.colorScheme.primary.copy(alpha = 0.1f)
isHeader -> style.tableHeaderBackground
rowIndex % 2 == 0 -> Color.Transparent
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.03f)
else -> style.tableStripeBackground
}
Row(
modifier = Modifier
@@ -283,13 +279,19 @@ private fun TableRow(
cells.forEachIndexed { i, cell ->
Box(
modifier = Modifier
.width((widths.getOrElse(i) { 10 } * 9).dp.coerceAtLeast(60.dp))
.padding(horizontal = 8.dp, vertical = 6.dp),
.width(
(TableCharWidth * widths.getOrElse(i) { 10 })
.coerceAtLeast(TableMinColumnWidth)
)
.padding(
horizontal = style.tableCellPaddingHorizontal,
vertical = style.tableCellPaddingVertical,
),
) {
Text(
text = cell,
color = textColor,
fontSize = fontSize,
color = style.textColor,
fontSize = style.tableFontSize,
fontWeight = if (isHeader) FontWeight.SemiBold else FontWeight.Normal,
softWrap = true,
)
@@ -306,6 +308,7 @@ private fun TableRow(
private fun clickableMarkdownText(
annotatedString: AnnotatedString,
modifier: Modifier = Modifier,
lineHeight: TextUnit = TextUnit.Unspecified,
onUrlClick: (String) -> Unit = {},
) {
val urlAnnotations = annotatedString.getStringAnnotations("URL", 0, annotatedString.length)
@@ -317,6 +320,7 @@ private fun clickableMarkdownText(
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
Text(
text = annotatedString,
lineHeight = lineHeight,
modifier = modifier.pointerInput(annotatedString) {
detectTapGestures { offset ->
val layout = textLayoutResult ?: return@detectTapGestures
@@ -334,7 +338,7 @@ private fun clickableMarkdownText(
onTextLayout = { textLayoutResult = it },
)
} else {
Text(text = annotatedString, modifier = modifier)
Text(text = annotatedString, lineHeight = lineHeight, modifier = modifier)
}
}
@@ -342,16 +346,27 @@ private fun clickableMarkdownText(
// Convert MarkdownSegment.Text to Compose AnnotatedString
// ─────────────────────────────────────────────────────────────
/**
* Текст сегмента → [AnnotatedString].
*
* База ([baseStyle], по умолчанию [MarkdownStyle.bodySpan]) накладывается на ВЕСЬ
* текст, а не только на размеченные спаны: парсер заводит [TextStyleSpan] лишь
* для жирного/курсива/кода/ссылок, поэтому «простой» текст без базы оставался
* без цвета и рисовался цветом по умолчанию (чёрным) — это и был баг «текст
* чёрный вместо белого».
*/
fun MarkdownSegment.Text.toAnnotatedString(
baseStyle: SpanStyle = SpanStyle(),
colors: AgentikColors = darkAgentikColors,
style: MarkdownStyle,
baseStyle: SpanStyle = style.bodySpan(),
): AnnotatedString {
val builder = AnnotatedString.Builder(text)
if (text.isNotEmpty()) {
builder.addStyle(baseStyle, 0, text.length)
}
for (span in spans) {
val style = buildSpanStyle(span, colors)
val merged = baseStyle.merge(style)
builder.addStyle(merged, span.start, span.end)
val spanStyle = buildSpanStyle(span, style)
builder.addStyle(baseStyle.merge(spanStyle), span.start, span.end)
val link = span.link
if (link != null) {
builder.addStringAnnotation("URL", link, span.start, span.end)
@@ -360,23 +375,23 @@ fun MarkdownSegment.Text.toAnnotatedString(
return builder.toAnnotatedString()
}
private fun buildSpanStyle(span: TextStyleSpan, colors: AgentikColors): SpanStyle {
var style = SpanStyle()
if (span.bold) style = style.copy(fontWeight = FontWeight.Bold)
if (span.italic) style = style.copy(fontStyle = androidx.compose.ui.text.font.FontStyle.Italic)
private fun buildSpanStyle(span: TextStyleSpan, style: MarkdownStyle): SpanStyle {
var out = SpanStyle()
if (span.bold) out = out.copy(fontWeight = FontWeight.Bold)
if (span.italic) out = out.copy(fontStyle = FontStyle.Italic)
if (span.code) {
style = style.copy(
fontFamily = FontFamily.Monospace,
fontSize = 13.sp,
background = colors.panelElevated,
out = out.copy(
fontFamily = style.monospaceFamily,
fontSize = style.inlineCodeFontSize,
background = style.inlineCodeBackground,
)
}
if (span.strikethrough) style = style.copy(textDecoration = androidx.compose.ui.text.style.TextDecoration.LineThrough)
if (span.strikethrough) out = out.copy(textDecoration = TextDecoration.LineThrough)
if (span.link != null) {
style = style.copy(
color = colors.accent,
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
out = out.copy(
color = style.linkColor,
textDecoration = TextDecoration.Underline,
)
}
return style
return out
}
@@ -205,15 +205,10 @@ private fun TextContent(content: List<UiContent>, textColor: Color, fontSize: an
@Composable
private fun MarkdownContent(content: List<UiContent>, agentColor: Color) {
val colors = AgentikTheme.colors
Column {
content.forEach { part ->
when (part) {
is UiContent.Text -> renderMarkdownSegments(
content = part.body,
textColor = colors.text,
fontSize = 13.5.sp,
)
is UiContent.Text -> renderMarkdownSegments(content = part.body)
is UiContent.Image -> {
ChatImage(src = dataUrl(part), alt = null)
@@ -3,6 +3,7 @@ package pw.binom.agentik.desktop.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.material3.LocalContentColor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
@@ -10,6 +11,9 @@ import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import pw.binom.agentik.desktop.settings.ThemeId
import pw.binom.agentik.desktop.ui.AgentikMarkdownStyle
import pw.binom.agentik.desktop.ui.LocalMarkdownStyle
import pw.binom.agentik.desktop.ui.MarkdownStyle
/**
* Семантические цвета интерфейса. **Ни один цвет не задаётся числом вне
@@ -146,30 +150,83 @@ val lightAgentikColors = AgentikColors(
codeText = Color(0xFF1A1D23),
)
private val LocalAgentikColors = staticCompositionLocalOf { darkAgentikColors }
/**
* Контракт темы приложения.
*
* Все параметры, которые в UI зависят от выбранной темы, живут здесь:
* - [colors] — семантические цвета (`AgentikColors`);
* - [markdown] — стиль отрисовки md (`MarkdownStyle`, см. `MarkdownStyle.kt`);
* - [dark] — для построения Material-схемы (`toMaterialScheme(dark)`).
*
* Чтобы добавить новую тему, достаточно положить ещё один `object` сюда и
* поднять его в настройках через [ThemeId] + [ThemeId.toTheme]. Экраны и
* рендер md не меняются — они читают только [AgentikTheme.current].
*/
interface AgentikTheme {
val colors: AgentikColors
val markdown: MarkdownStyle
val dark: Boolean
/** Доступ к семантическим цветам из экранов: `AgentikTheme.colors.panel`. */
object AgentikTheme {
companion object {
/** Активная тема (то, что положили в [ProvideAgentikTheme]). */
val current: AgentikTheme
@Composable
@ReadOnlyComposable
get() = LocalAgentikTheme.current
/** Короткий доступ к [AgentikTheme.colors] — для кода вида `val colors = AgentikTheme.colors`. */
val colors: AgentikColors
@Composable
@ReadOnlyComposable
get() = LocalAgentikColors.current
get() = current.colors
/** Короткий доступ к [AgentikTheme.markdown] — для `renderMarkdownSegments` и т. п. */
val markdown: MarkdownStyle
@Composable
@ReadOnlyComposable
get() = current.markdown
}
}
/**
* Корневой провайдер темы. Оборачивает всё приложение; [themeId] — значение
* из настроек (`ThemeId.DARK` / `ThemeId.LIGHT`).
* Локаль, через которую [AgentikTheme.current] доходит до экранов.
* Объявлен на уровне файла, чтобы [ProvideAgentikTheme] мог им пользоваться
* напрямую (внутри companion-объекта интерфейса он был бы недоступен снаружи).
*/
private val LocalAgentikTheme = staticCompositionLocalOf<AgentikTheme> { DarkAgentikTheme }
/** Тёмная тема — значения `darkAgentikColors` + её markdown-стиль. */
object DarkAgentikTheme : AgentikTheme {
override val colors: AgentikColors = darkAgentikColors
override val markdown: MarkdownStyle = AgentikMarkdownStyle(darkAgentikColors)
override val dark: Boolean = true
}
/** Светлая тема — значения `lightAgentikColors` + её markdown-стиль. */
object LightAgentikTheme : AgentikTheme {
override val colors: AgentikColors = lightAgentikColors
override val markdown: MarkdownStyle = AgentikMarkdownStyle(lightAgentikColors)
override val dark: Boolean = false
}
/** Преобразует значение [ThemeId] из настроек (`AgentikTheme.colors.themeId` хранится как `String`) в активный [AgentikTheme]. */
fun String.toAgentikTheme(): AgentikTheme = if (this == ThemeId.LIGHT) LightAgentikTheme else DarkAgentikTheme
/**
* Корневой провайдер темы. Оборачивает всё приложение; принимает готовый
* экземпляр [AgentikTheme] (обычно результат [ThemeId.toTheme]).
*/
@Composable
fun AgentikTheme(
themeId: String,
content: @Composable () -> Unit,
fun ProvideAgentikTheme(theme: AgentikTheme, content: @Composable () -> Unit) {
CompositionLocalProvider(
LocalAgentikTheme provides theme,
// MaterialTheme сам LocalContentColor не подкладывает: без этого любой
// Text без явного color рисовался бы чёрным (дефолт локала), а не текстом темы.
LocalContentColor provides theme.colors.text,
LocalMarkdownStyle provides theme.markdown,
) {
val dark = themeId != ThemeId.LIGHT
val colors = if (dark) darkAgentikColors else lightAgentikColors
CompositionLocalProvider(LocalAgentikColors provides colors) {
MaterialTheme(
colorScheme = colors.toMaterialScheme(dark = dark),
colorScheme = theme.colors.toMaterialScheme(dark = theme.dark),
content = content,
)
}
@@ -13,11 +13,12 @@ import androidx.compose.ui.test.captureToImage
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.ui.theme.AgentikColors
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import pw.binom.agentik.desktop.ui.theme.darkAgentikColors
import pw.binom.agentik.desktop.ui.theme.lightAgentikColors
import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme
import pw.binom.agentik.desktop.ui.theme.toAgentikTheme
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
@@ -80,14 +81,10 @@ class ThemeRenderTest {
@Test
fun codeBlockPaintsThemeCodeBackgroundInLightTheme() = runComposeUiTest {
setContent {
AgentikTheme("light") {
ProvideAgentikTheme("light".toAgentikTheme()) {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) {
Column {
renderMarkdownSegments(
content = "```kotlin\nval x = 1\n```",
textColor = AgentikTheme.colors.text,
fontSize = 14.sp,
)
renderMarkdownSegments(content = "```kotlin\nval x = 1\n```")
}
}
}
@@ -116,14 +113,10 @@ class ThemeRenderTest {
@Test
fun darkThemeStillRendersCodeBlockDark() = runComposeUiTest {
setContent {
AgentikTheme("dark") {
ProvideAgentikTheme("dark".toAgentikTheme()) {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble).padding(8.dp)) {
Column {
renderMarkdownSegments(
content = "```kotlin\nval x = 1\n```",
textColor = AgentikTheme.colors.text,
fontSize = 14.sp,
)
renderMarkdownSegments(content = "```kotlin\nval x = 1\n```")
}
}
}
@@ -6,9 +6,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest
import androidx.compose.ui.unit.sp
import pw.binom.agentik.desktop.ui.renderMarkdownSegments
import pw.binom.agentik.desktop.ui.theme.AgentikTheme
import pw.binom.agentik.desktop.ui.theme.ProvideAgentikTheme
import pw.binom.agentik.desktop.ui.theme.toAgentikTheme
import kotlin.test.Test
import kotlin.test.assertTrue
@@ -38,12 +39,10 @@ class RealSpeechIT {
// 2. skiko/Compose инициализируется и реально рисует (как окно приложения).
runComposeUiTest {
setContent {
AgentikTheme("dark") {
ProvideAgentikTheme("dark".toAgentikTheme()) {
Box(Modifier.fillMaxSize().background(AgentikTheme.colors.botBubble)) {
renderMarkdownSegments(
content = "проверка: **окно** уже отрисовано",
textColor = AgentikTheme.colors.text,
fontSize = 14.sp,
)
}
}