@@ -15,8 +15,6 @@ import android.widget.Toast
1515import androidx.activity.compose.rememberLauncherForActivityResult
1616import androidx.activity.result.contract.ActivityResultContracts
1717import androidx.compose.animation.AnimatedVisibility
18- import androidx.compose.animation.core.animateFloatAsState
19- import androidx.compose.animation.core.tween
2018import androidx.compose.animation.fadeIn
2119import androidx.compose.animation.fadeOut
2220import androidx.compose.foundation.ExperimentalFoundationApi
@@ -25,7 +23,6 @@ import androidx.compose.foundation.clickable
2523import androidx.compose.foundation.combinedClickable
2624import androidx.compose.foundation.gestures.detectTapGestures
2725import androidx.compose.foundation.gestures.scrollBy
28- import androidx.compose.foundation.layout.Arrangement
2926import androidx.compose.foundation.layout.Box
3027import androidx.compose.foundation.layout.Column
3128import androidx.compose.foundation.layout.ExperimentalLayoutApi
@@ -35,10 +32,8 @@ import androidx.compose.foundation.layout.fillMaxWidth
3532import androidx.compose.foundation.layout.imePadding
3633import androidx.compose.foundation.layout.padding
3734import androidx.compose.foundation.layout.size
38- import androidx.compose.foundation.lazy.LazyColumn
3935import androidx.compose.foundation.lazy.LazyListState
4036import androidx.compose.foundation.lazy.items
41- import androidx.compose.foundation.lazy.itemsIndexed
4237import androidx.compose.foundation.lazy.rememberLazyListState
4338import androidx.compose.foundation.shape.CircleShape
4439import androidx.compose.foundation.shape.RoundedCornerShape
@@ -111,28 +106,24 @@ import org.opencode.mobile.ui.chat.AudioRecorder
111106import org.opencode.mobile.ui.chat.CONTEXT_LIMIT
112107import org.opencode.mobile.ui.chat.ChatAppearance
113108import org.opencode.mobile.ui.chat.ChatCache
109+ import org.opencode.mobile.ui.chat.ChatFeed
110+ import org.opencode.mobile.ui.chat.ChatFeedState
114111import org.opencode.mobile.ui.chat.ChatPollState
115112import org.opencode.mobile.ui.chat.ChatUiState
116113import org.opencode.mobile.ui.chat.ContextGauge
117- import org.opencode.mobile.ui.chat.DeadRow
118114import org.opencode.mobile.ui.chat.ElevenKeySettings
119115import org.opencode.mobile.ui.chat.FontPanel
120116import org.opencode.mobile.ui.chat.GaugePanel
121- import org.opencode.mobile.ui.chat.LiveToolRow
122117import org.opencode.mobile.ui.chat.MCPIndicator
123118import org.opencode.mobile.ui.chat.McpPanel
124- import org.opencode.mobile.ui.chat.MessageRow
125119import org.opencode.mobile.ui.chat.ModelColorPanel
126120import org.opencode.mobile.ui.chat.MoltbookCard
127121import org.opencode.mobile.ui.chat.NarrationInput
128- import org.opencode.mobile.ui.chat.NoticeRow
129122import org.opencode.mobile.ui.chat.QuotaBadge
130123import org.opencode.mobile.ui.chat.ServerSendTransport
131124import org.opencode.mobile.ui.chat.SharedPreferencesAppearanceStore
132125import org.opencode.mobile.ui.chat.SharedPreferencesVoiceStore
133- import org.opencode.mobile.ui.chat.StalledRow
134126import org.opencode.mobile.ui.chat.SttPanel
135- import org.opencode.mobile.ui.chat.ThinkingRow
136127import org.opencode.mobile.ui.chat.TtsNarration
137128import org.opencode.mobile.ui.chat.TtsPanel
138129import org.opencode.mobile.ui.chat.TtsSettings
@@ -153,7 +144,6 @@ import org.opencode.mobile.ui.chat.pruneEmptySessions
153144import org.opencode.mobile.ui.chat.readWavPcm16
154145import org.opencode.mobile.ui.chat.sessionRunning
155146import org.opencode.mobile.ui.chat.stopAndPurge
156- import org.opencode.mobile.ui.chat.typedKeyOf
157147import org.opencode.mobile.ui.chat.vibrate
158148import java.io.File
159149import 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