Skip to content

Commit dfb5401

Browse files
refactor(ui): лента чата — отдельный composable
ChatOverlay.kt 1552 -> 1455 строк. Лента (пустая картинка, сообщения, строка «думаю», живой чип инструмента, плашка отказа) ушла в chat/ChatFeed.kt. Вход ленты — ChatFeedState: сообщения, снимок сессии, защёлка плашки, шрифт, цвет и ключ печати плюс единственный колбэк onTyped. Шесть отдельных параметров по одиночке ничего не значили, а вместе они и есть «что показать». weight(1f) остаётся у вызывающего: без гибкого ребёнка колонка сжимается до содержимого и поле ввода уезжает вверх. feedItems — расширение LazyListScope: состав ленты читается отдельно от картинки и скролла. Одной функцией лента не проходила лимиты detekt (72 строки, сложность 16). Проверено на устройстве: 8 сообщений рисуются, в логе RENDER msgs=8, черновик печатается в поле, микрофон прячется при непустом черновике и возвращается после очистки.
1 parent e81e8ec commit dfb5401

3 files changed

Lines changed: 210 additions & 114 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -481,6 +481,21 @@ ChatOverlay разобран на четыре файла (25.10.2026)
481481
ответов (#DB2D00 → #009E96 по тапу в квадрат). Юнит-тестов на вёрстку нет —
482482
гейт здесь `verify` плюс прогон на телефоне.
483483

484+
Лента чата — отдельный composable (26.10.2026)
485+
- `ui/ChatOverlay.kt` — 1552 → 1455 строк: лента (пустая картинка, сообщения,
486+
«думаю», живой чип инструмента, плашка отказа) ушла в `chat/ChatFeed.kt`.
487+
- Вход ленты — `ChatFeedState`: сообщения, снимок сессии, защёлка плашки, шрифт,
488+
цвет и ключ печати плюс единственный колбэк `onTyped`. Шесть отдельных параметров
489+
по одиночке ничего не значили, а вместе они и есть «что показать».
490+
- `weight(1f)` остаётся у вызывающего: без гибкого ребёнка колонка сжимается до
491+
содержимого и поле ввода уезжает вверх — так и было в новой или очищенной сессии.
492+
- `feedItems` — расширение `LazyListScope`: состав ленты читается отдельно от
493+
картинки и скролла. Одной функцией лента не проходила лимиты detekt (72 строки,
494+
сложность 16), разрезанная — проходит.
495+
- Проверено на устройстве: 8 сообщений рисуются, в логе `RENDER msgs=8`, черновик
496+
печатается в поле, микрофон прячется при непустом черновике и возвращается после
497+
очистки.
498+
484499
---
485500

486501
## Стек и зависимости

‎app/src/main/java/org/opencode/mobile/ui/ChatOverlay.kt‎

Lines changed: 17 additions & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,6 @@ import android.widget.Toast
1515
import androidx.activity.compose.rememberLauncherForActivityResult
1616
import androidx.activity.result.contract.ActivityResultContracts
1717
import androidx.compose.animation.AnimatedVisibility
18-
import androidx.compose.animation.core.animateFloatAsState
19-
import androidx.compose.animation.core.tween
2018
import androidx.compose.animation.fadeIn
2119
import androidx.compose.animation.fadeOut
2220
import androidx.compose.foundation.ExperimentalFoundationApi
@@ -25,7 +23,6 @@ import androidx.compose.foundation.clickable
2523
import androidx.compose.foundation.combinedClickable
2624
import androidx.compose.foundation.gestures.detectTapGestures
2725
import androidx.compose.foundation.gestures.scrollBy
28-
import androidx.compose.foundation.layout.Arrangement
2926
import androidx.compose.foundation.layout.Box
3027
import androidx.compose.foundation.layout.Column
3128
import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -35,10 +32,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
3532
import androidx.compose.foundation.layout.imePadding
3633
import androidx.compose.foundation.layout.padding
3734
import androidx.compose.foundation.layout.size
38-
import androidx.compose.foundation.lazy.LazyColumn
3935
import androidx.compose.foundation.lazy.LazyListState
4036
import androidx.compose.foundation.lazy.items
41-
import androidx.compose.foundation.lazy.itemsIndexed
4237
import androidx.compose.foundation.lazy.rememberLazyListState
4338
import androidx.compose.foundation.shape.CircleShape
4439
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -111,28 +106,24 @@ import org.opencode.mobile.ui.chat.AudioRecorder
111106
import org.opencode.mobile.ui.chat.CONTEXT_LIMIT
112107
import org.opencode.mobile.ui.chat.ChatAppearance
113108
import org.opencode.mobile.ui.chat.ChatCache
109+
import org.opencode.mobile.ui.chat.ChatFeed
110+
import org.opencode.mobile.ui.chat.ChatFeedState
114111
import org.opencode.mobile.ui.chat.ChatPollState
115112
import org.opencode.mobile.ui.chat.ChatUiState
116113
import org.opencode.mobile.ui.chat.ContextGauge
117-
import org.opencode.mobile.ui.chat.DeadRow
118114
import org.opencode.mobile.ui.chat.ElevenKeySettings
119115
import org.opencode.mobile.ui.chat.FontPanel
120116
import org.opencode.mobile.ui.chat.GaugePanel
121-
import org.opencode.mobile.ui.chat.LiveToolRow
122117
import org.opencode.mobile.ui.chat.MCPIndicator
123118
import org.opencode.mobile.ui.chat.McpPanel
124-
import org.opencode.mobile.ui.chat.MessageRow
125119
import org.opencode.mobile.ui.chat.ModelColorPanel
126120
import org.opencode.mobile.ui.chat.MoltbookCard
127121
import org.opencode.mobile.ui.chat.NarrationInput
128-
import org.opencode.mobile.ui.chat.NoticeRow
129122
import org.opencode.mobile.ui.chat.QuotaBadge
130123
import org.opencode.mobile.ui.chat.ServerSendTransport
131124
import org.opencode.mobile.ui.chat.SharedPreferencesAppearanceStore
132125
import org.opencode.mobile.ui.chat.SharedPreferencesVoiceStore
133-
import org.opencode.mobile.ui.chat.StalledRow
134126
import org.opencode.mobile.ui.chat.SttPanel
135-
import org.opencode.mobile.ui.chat.ThinkingRow
136127
import org.opencode.mobile.ui.chat.TtsNarration
137128
import org.opencode.mobile.ui.chat.TtsPanel
138129
import org.opencode.mobile.ui.chat.TtsSettings
@@ -153,7 +144,6 @@ import org.opencode.mobile.ui.chat.pruneEmptySessions
153144
import org.opencode.mobile.ui.chat.readWavPcm16
154145
import org.opencode.mobile.ui.chat.sessionRunning
155146
import org.opencode.mobile.ui.chat.stopAndPurge
156-
import org.opencode.mobile.ui.chat.typedKeyOf
157147
import org.opencode.mobile.ui.chat.vibrate
158148
import java.io.File
159149
import java.util.Locale
@@ -1273,109 +1263,22 @@ fun ChatOverlay(
12731263
if (showFontPicker) {
12741264
FontPanel(appearance, modelColor) { closeHeaderPanel(PANEL_FONT) }
12751265
}
1276-
val msgs = ui.snapshot?.messages ?: emptyList()
1277-
// The empty-state art fades out on the first message instead of
1278-
// blinking off. It is driven here rather than inside the branch that
1279-
// draws it, because a branch flip cannot be animated.
1280-
val emptyAlpha by animateFloatAsState(
1281-
targetValue = if (msgs.isEmpty()) 1f else 0f,
1282-
animationSpec = tween(durationMillis = 450),
1283-
label = "empty-art",
1284-
)
1285-
LaunchedEffect(msgs.size, ui.snapshot?.thinking, ui.snapshot?.liveTool) {
1286-
android.util.Log.d(
1287-
"ChatOverlay",
1288-
"RENDER msgs=${msgs.size} thinking=${ui.snapshot?.thinking} live=${ui.snapshot?.liveTool?.name}",
1289-
)
1290-
}
1291-
// The box keeps weight(1f) whatever is inside it: it is what pins
1292-
// the input row with the mic and Stop to the bottom of the screen.
1293-
// Without a flexible child the column collapses to its content and
1294-
// the input rides to the top, which is what a new or cleared session
1295-
// used to do.
1296-
//
1297-
// The octopus is composed even while the feed has messages, so it can
1298-
// fade out instead of blinking off. It sits under the feed.
1299-
Box(
1266+
ChatFeed(
1267+
state =
1268+
ChatFeedState(
1269+
messages = ui.snapshot?.messages ?: emptyList(),
1270+
snapshot = ui.snapshot,
1271+
noticeLatch = ui.noticeLatch,
1272+
modelFont = modelFont,
1273+
modelColor = modelColor,
1274+
typingKey = typingKey,
1275+
onTyped = { typingKey = it },
1276+
),
1277+
listState = listState,
13001278
modifier = Modifier.weight(1f),
1301-
contentAlignment = Alignment.Center,
1302-
) {
1303-
EmptyChatArt(alpha = emptyAlpha)
1304-
if (msgs.isNotEmpty()) {
1305-
LazyColumn(
1306-
state = listState,
1307-
modifier =
1308-
Modifier
1309-
.fillMaxSize()
1310-
.padding(top = 6.dp),
1311-
verticalArrangement = Arrangement.spacedBy(6.dp),
1312-
) {
1313-
// Позиционный ключ (без кастомного key). Кастомный key из контента
1314-
// крашил LazyColumn (Key "…was already used") при дубликатах сообщений
1315-
// (например, дважды отправленная команда «Стой»). Лента append-only,
1316-
// добавление в конец не трогает существующие позиции — позиционный
1317-
// ключ безопасен и полностью устраняет этот краш.
1318-
itemsIndexed(msgs) { idx, m ->
1319-
// Печать — только ПОСЛЕДНЕЙ реплике модели и только один
1320-
// раз на её текст. Ключ гасится в onTyped: иначе каждая
1321-
// пересборка ленты (опрос сервера каждые доли секунды)
1322-
// начинала бы печать заново, а возврат в чат
1323-
// перепечатывал бы всю историю.
1324-
// Условие «последняя» обязательно: без него печаталась бы
1325-
// каждая реплика, ключ которой ещё не погашен, то есть
1326-
// вся лента целиком при каждом новом ответе.
1327-
val last = idx == msgs.lastIndex
1328-
// Пустой ответ модели (шаг с одними вызовами
1329-
// инструментов) печатать нечего: MessageText для
1330-
// него вообще не вызывается, значит и гасить ключ
1331-
// некому — иначе typingKey навсегда остался бы на
1332-
// предыдущем ответе.
1333-
val printable = last && m.role == "assistant" && m.text.isNotBlank()
1334-
MessageRow(
1335-
m,
1336-
ui.snapshot?.modelName ?: "Модель",
1337-
modelFont,
1338-
modelColor,
1339-
onTyped =
1340-
if (printable && typingKey != typedKeyOf(m)) {
1341-
{ typingKey = typedKeyOf(m) }
1342-
} else {
1343-
null
1344-
},
1345-
)
1346-
}
1347-
if (ui.snapshot?.thinking == true) {
1348-
val snap = requireNotNull(ui.snapshot)
1349-
item(key = if (snap.stalled) "stalled" else "thinking") {
1350-
when {
1351-
snap.stalledDead ->
1352-
DeadRow(
1353-
onAbort = { snap.activeId?.let(::recoverDeadTurn) },
1354-
onReset = { snap.activeId?.let { confirmResetDead = true } },
1355-
)
1356-
snap.stalled -> StalledRow()
1357-
else -> ThinkingRow()
1358-
}
1359-
}
1360-
// Живой чип «какой тул выполняет модель» — поверх индикатора думания.
1361-
snap.liveTool?.let { t ->
1362-
item(key = "livetool_${t.name}_${t.detail.hashCode()}") {
1363-
LiveToolRow(t)
1364-
}
1365-
}
1366-
}
1367-
// Причина отказа сервера (квота/ретрай) — всегда последняя строка,
1368-
// чтобы её не съедал автоскролл вниз и она читалась как вывод.
1369-
// Рендерим защёлку: плашка должна висеть, пока сервер держит
1370-
// ту же причину, даже если конкретный тик опроса её не увидел.
1371-
(ui.noticeLatch ?: ui.snapshot?.notice)?.let { n ->
1372-
item(key = "notice_${n.title}_${n.attempt}") {
1373-
NoticeRow(n, context)
1374-
}
1375-
}
1376-
}
1377-
}
1378-
}
1279+
onAbortDead = ::recoverDeadTurn,
1280+
onResetDead = { confirmResetDead = true },
1281+
)
13791282
if (listening || speechError != null || whisperBusy) {
13801283
Text(
13811284
when {

0 commit comments

Comments
 (0)