Skip to content

Commit 69c91b3

Browse files
refactor(ui): состояние чата вынесено в ChatUiState
snapshot, draft, sending, stopping и noticeLatch жили пятью remember-объявлениями вперемешку с локальными переменными отрисовки. Теперь это один объект: правила записи лежат рядом с полями, а в ChatOverlay осталась отрисовка. Документация по каждому полю объясняет, почему оно сохраняется или нет (черновик переживает смерть процесса, sending/stopping/noticeLatch - нет). Saver отдаёт только строку черновика: лишнее поле в Bundle переживает процесс и притворяется живым состоянием. ChatOverlay 2163 -> 2087 строк. Юнит-теста нет - проверено сборкой (verify зелёный) и смоуком на устройстве: шапка, гейдж и thinking-строка рисуются, опрос пишет снимок, крашей нет.
1 parent 0b661b9 commit 69c91b3

2 files changed

Lines changed: 130 additions & 61 deletions

File tree

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

Lines changed: 50 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ import org.opencode.mobile.ui.chat.CONTEXT_LIMIT
132132
import org.opencode.mobile.ui.chat.ChatAppearance
133133
import org.opencode.mobile.ui.chat.ChatCache
134134
import org.opencode.mobile.ui.chat.ChatPollState
135+
import org.opencode.mobile.ui.chat.ChatUiState
135136
import org.opencode.mobile.ui.chat.ContextGauge
136137
import org.opencode.mobile.ui.chat.DeadRow
137138
import org.opencode.mobile.ui.chat.ElevenKeySettings
@@ -321,21 +322,9 @@ fun ChatOverlay(
321322
val scope = rememberCoroutineScope()
322323
val keyboard = LocalSoftwareKeyboardController.current
323324
val focusManager = LocalFocusManager.current
324-
var snapshot by remember { mutableStateOf<ChatSnapshot?>(null) }
325-
// Защёлка на плашку причины. Retry/ошибка провайдера - УСТОЙЧИВОЕ состояние,
326-
// а не событие: сервер будет отдавать free_tier_limit и после любого TTL, пока
327-
// квота не восстановится. Поэтому держим плашку, пока идёт ход, и гасим только
328-
// когда opencode дошёл до успешного assistant-шага (thinking снят).
329-
var noticeLatch by remember { mutableStateOf<ChatNotice?>(null) }
330-
// Черновик переживает и пересоздание Activity, и убийство процесса: строка —
331-
// saveable тип, а терять набранный текст из-за смены размера шрифта или
332-
// возврата из другого приложения нельзя. Остальные состояния НЕ saveable
333-
// намеренно: восстановленный sending=true оставил бы кнопку вечно занятой
334-
// (корутины отправки после смерти процесса уже нет), а noticeLatch и typingKey
335-
// восстановились бы без своего контекста.
336-
var draft by rememberSaveable { mutableStateOf("") }
337-
var sending by remember { mutableStateOf(false) }
338-
var stopping by remember { mutableStateOf(false) }
325+
// Снимок ленты, черновик, отправка/остановка и плашка причины живут в ChatUiState:
326+
// правила записи и что переживает смерть процесса — там, у полей. Здесь отрисовка.
327+
val ui = rememberSaveable(saver = ChatUiState.saver) { ChatUiState() }
339328
var whisperRecorder: AudioRecorder? = null
340329
val listState = rememberLazyListState()
341330
var knownMsgs by remember { mutableIntStateOf(0) }
@@ -440,7 +429,7 @@ fun ChatOverlay(
440429
// другую сессию. Иначе после частичного провала (ответ ушёл в очередь, но
441430
// отклонить не удалось) тап «Разрешить» с новым текстом ушёл бы без текста:
442431
// answerPendingAsk решил бы, что этот ответ уже отправляли.
443-
val shownCard = snapshot?.ask?.firstOrNull()
432+
val shownCard = ui.snapshot?.ask?.firstOrNull()
444433
val shownAskId = shownCard?.ask?.id
445434
LaunchedEffect(shownAskId) {
446435
askAnswerSent = null
@@ -457,7 +446,7 @@ fun ChatOverlay(
457446
decision: String,
458447
message: String? = null,
459448
) {
460-
val sessionId = snapshot?.activeId ?: return
449+
val sessionId = ui.snapshot?.activeId ?: return
461450
if (askBusy) return
462451
askBusy = true
463452
askError = null
@@ -483,7 +472,7 @@ fun ChatOverlay(
483472
askAnswerSent = null
484473
askError = null
485474
userScrolledUp = false
486-
scrollToBottomFull(listState, (snapshot?.messages?.size ?: 0) - 1)
475+
scrollToBottomFull(listState, (ui.snapshot?.messages?.size ?: 0) - 1)
487476
}
488477
}
489478
}
@@ -558,10 +547,10 @@ fun ChatOverlay(
558547
val moltbookStats = rememberMoltbookState(moltbookRefresh)
559548

560549
fun send() {
561-
val text = draft.trim()
562-
if (text.isEmpty() || sending) return
563-
val sessionId = snapshot?.activeId
564-
sending = true
550+
val text = ui.draft.trim()
551+
if (text.isEmpty() || ui.sending) return
552+
val sessionId = ui.snapshot?.activeId
553+
ui.sending = true
565554
userScrolledUp = false
566555
scope.launch {
567556
// Создание сессии и отправка — одна операция, и правило «сервер мог
@@ -571,14 +560,14 @@ fun ChatOverlay(
571560
withContext(Dispatchers.IO) {
572561
deliverPrompt(ServerSendTransport(serverPort, context), sessionId, text)
573562
}
574-
sending = false
563+
ui.sending = false
575564
if (outcome.delivered) {
576-
draft = ""
565+
ui.draft = ""
577566
keyboard?.hide()
578567
focusManager.clearFocus()
579568
// Оптимистично: мгновенно показываем своё сообщение в ленте.
580-
snapshot = snapshot?.let { it.copy(messages = it.messages + ChatMsg("user", text)) }
581-
scrollToBottomFull(listState, (snapshot?.messages?.size ?: 0) - 1)
569+
ui.snapshot = ui.snapshot?.let { it.copy(messages = it.messages + ChatMsg("user", text)) }
570+
scrollToBottomFull(listState, (ui.snapshot?.messages?.size ?: 0) - 1)
582571
} else {
583572
// Черновик намеренно остаётся: сообщение не ушло, повтор должен
584573
// быть возможен одним нажатием. Молчаливый отказ - это тот баг,
@@ -598,15 +587,15 @@ fun ChatOverlay(
598587
}
599588

600589
fun stopGen() {
601-
if (stopping) return
602-
val sessionId = snapshot?.activeId ?: return
603-
stopping = true
590+
if (ui.stopping) return
591+
val sessionId = ui.snapshot?.activeId ?: return
592+
ui.stopping = true
604593
scope.launch {
605594
val ok =
606595
try {
607596
withContext(Dispatchers.IO) { abortSession(serverPort, sessionId) }
608597
} finally {
609-
stopping = false
598+
ui.stopping = false
610599
}
611600
if (ok) {
612601
vibrate(context)
@@ -783,7 +772,7 @@ fun ChatOverlay(
783772
val best = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)?.firstOrNull()?.trim()
784773
if (!best.isNullOrEmpty()) {
785774
speechError = null
786-
draft = best
775+
ui.draft = best
787776
send()
788777
} else {
789778
speechError = "не расслышал, попробуй ещё"
@@ -912,7 +901,7 @@ fun ChatOverlay(
912901
whisperBusy = false
913902
if (!text.isNullOrBlank() && !text.startsWith("ОШИБКА")) {
914903
speechError = null
915-
draft = text
904+
ui.draft = text
916905
send()
917906
} else {
918907
speechError = if (text.startsWith("ОШИБКА")) text else "не распознал голос, попробуй ещё"
@@ -989,9 +978,9 @@ fun ChatOverlay(
989978
// Если юзер сам листал вверх, автопрокрутка СТАРОЙ ленты не дёргает его,
990979
// НО когда приходит НОВОЕ сообщение (лента выросла) — принудительно
991980
// спускаемся к низу, чтобы последнее сообщение всегда было видно.
992-
LaunchedEffect(snapshot?.messages?.size, snapshot?.thinking, snapshot?.liveTool) {
981+
LaunchedEffect(ui.snapshot?.messages?.size, ui.snapshot?.thinking, ui.snapshot?.liveTool) {
993982
delay(90) // дождаться рекомпозиции LazyColumn — здесь новый ответ УЖЕ отрисован
994-
val snap = snapshot ?: return@LaunchedEffect
983+
val snap = ui.snapshot ?: return@LaunchedEffect
995984
val n = snap.messages.size
996985
val newMsg = n > prevMsgCount // появилось новое сообщение (свой вопрос или ответ модели)
997986
if (n > 0 && (!userScrolledUp || newMsg)) {
@@ -1009,7 +998,7 @@ fun ChatOverlay(
1009998
// (композиция/измерение заняли время), вибрируем строго после показа текста.
1010999
// knownRendered — индекс последнего, на который уже «вибрировали» (высчитывается
10111000
// как количество завершённых ответов на момент последней вибрации).
1012-
val lastResp = snapshot?.messages?.indexOfLast { it.role == "assistant" && it.text.isNotBlank() && it.notice == null }
1001+
val lastResp = ui.snapshot?.messages?.indexOfLast { it.role == "assistant" && it.text.isNotBlank() && it.notice == null }
10131002
LaunchedEffect(lastResp) {
10141003
val idx = lastResp ?: return@LaunchedEffect
10151004
if (idx < 0) return@LaunchedEffect
@@ -1029,7 +1018,7 @@ fun ChatOverlay(
10291018
snapshotFlow { listState.firstVisibleItemIndex }
10301019
.collect { idx ->
10311020
if (idx < 0) return@collect
1032-
val total = snapshot?.messages?.size ?: 0
1021+
val total = ui.snapshot?.messages?.size ?: 0
10331022
if (idx >= total - 1) {
10341023
userScrolledUp = false // юзер на самом низу — автоскролл можно
10351024
} else if (prevIdx >= 0 && idx < prevIdx) {
@@ -1062,12 +1051,12 @@ fun ChatOverlay(
10621051
val decision =
10631052
advancePoll(
10641053
previous = poll,
1065-
shown = snapshot,
1054+
shown = ui.snapshot,
10661055
fresh = fetchChatSnapshot(serverPort),
10671056
now = System.currentTimeMillis(),
10681057
)
10691058
poll = decision.state
1070-
noticeLatch = decision.latch
1059+
ui.noticeLatch = decision.latch
10711060
knownMsgs = decision.knownMsgs
10721061
decision.baselineRendered?.let { knownRendered = it }
10731062
decision.abort?.let { abort ->
@@ -1081,7 +1070,7 @@ fun ChatOverlay(
10811070
}
10821071
val publish = decision.publish
10831072
if (publish != null) {
1084-
snapshot = publish
1073+
ui.snapshot = publish
10851074
// Озвучка: раскладываем растущий текст ответа на предложения.
10861075
// Все правила (первый снапшот не читает историю, смена сессии
10871076
// гасит речь, новый вопрос сбрасывает курсор, живое сообщение
@@ -1161,17 +1150,17 @@ fun ChatOverlay(
11611150
// контекста уехал ниже, чтобы шапка не расползалась: раньше круг
11621151
// занимал всю свободную ширину и отжимал индикаторы вправо.
11631152
QuotaBadge(
1164-
reachable = snapshot?.reachable,
1165-
pingMs = snapshot?.pingMs ?: 0L,
1166-
rejected = snapshot?.notice != null,
1153+
reachable = ui.snapshot?.reachable,
1154+
pingMs = ui.snapshot?.pingMs ?: 0L,
1155+
rejected = ui.snapshot?.notice != null,
11671156
modifier = Modifier.weight(1f),
11681157
)
11691158
// Индикатор MCP-серверов (НЕОНОВЫЙ): «N MCP» + мигающая точка.
11701159
// Зелёный — все N подключённых серверов работают; красный — какой-то
11711160
// из них не работает (или их нет вовсе). Стоит ЛЕВЕЕ выбора цвета.
11721161
MCPIndicator(
1173-
connected = snapshot?.mcpConnected ?: 0,
1174-
total = snapshot?.mcpTotal ?: 0,
1162+
connected = ui.snapshot?.mcpConnected ?: 0,
1163+
total = ui.snapshot?.mcpTotal ?: 0,
11751164
onClick = { toggleHeaderPanel(PANEL_MCP) },
11761165
modifier = Modifier.padding(start = 4.dp),
11771166
)
@@ -1284,7 +1273,7 @@ fun ChatOverlay(
12841273
// приближения к компакту; внутри — процент заполнения.
12851274
Row(verticalAlignment = Alignment.CenterVertically) {
12861275
ContextGauge(
1287-
filled = snapshot?.contextTokens ?: 0L,
1276+
filled = ui.snapshot?.contextTokens ?: 0L,
12881277
limit = CONTEXT_LIMIT,
12891278
shape = appearance.ctxShape,
12901279
palette = appearance.palette,
@@ -1307,7 +1296,7 @@ fun ChatOverlay(
13071296
// У каждого имени — мигающая точка: зелёная (работает) / красная (нет).
13081297
if (showMcpList) {
13091298
McpServerList(
1310-
servers = snapshot?.mcpServers ?: emptyList(),
1299+
servers = ui.snapshot?.mcpServers ?: emptyList(),
13111300
modifier =
13121301
Modifier
13131302
.fillMaxWidth()
@@ -1753,7 +1742,7 @@ fun ChatOverlay(
17531742
}
17541743
}
17551744
}
1756-
val msgs = snapshot?.messages ?: emptyList()
1745+
val msgs = ui.snapshot?.messages ?: emptyList()
17571746
// The empty-state art fades out on the first message instead of
17581747
// blinking off. It is driven here rather than inside the branch that
17591748
// draws it, because a branch flip cannot be animated.
@@ -1762,10 +1751,10 @@ fun ChatOverlay(
17621751
animationSpec = tween(durationMillis = 450),
17631752
label = "empty-art",
17641753
)
1765-
LaunchedEffect(msgs.size, snapshot?.thinking, snapshot?.liveTool) {
1754+
LaunchedEffect(msgs.size, ui.snapshot?.thinking, ui.snapshot?.liveTool) {
17661755
android.util.Log.d(
17671756
"ChatOverlay",
1768-
"RENDER msgs=${msgs.size} thinking=${snapshot?.thinking} live=${snapshot?.liveTool?.name}",
1757+
"RENDER msgs=${msgs.size} thinking=${ui.snapshot?.thinking} live=${ui.snapshot?.liveTool?.name}",
17691758
)
17701759
}
17711760
// Палитра шрифтов (настройка): тап по варианту — мгновенно применяется и сохраняется.
@@ -1858,7 +1847,7 @@ fun ChatOverlay(
18581847
val printable = last && m.role == "assistant" && m.text.isNotBlank()
18591848
MessageRow(
18601849
m,
1861-
snapshot?.modelName ?: "Модель",
1850+
ui.snapshot?.modelName ?: "Модель",
18621851
modelFont,
18631852
modelColor,
18641853
onTyped =
@@ -1869,8 +1858,8 @@ fun ChatOverlay(
18691858
},
18701859
)
18711860
}
1872-
if (snapshot?.thinking == true) {
1873-
val snap = requireNotNull(snapshot)
1861+
if (ui.snapshot?.thinking == true) {
1862+
val snap = requireNotNull(ui.snapshot)
18741863
item(key = if (snap.stalled) "stalled" else "thinking") {
18751864
when {
18761865
snap.stalledDead ->
@@ -1893,7 +1882,7 @@ fun ChatOverlay(
18931882
// чтобы её не съедал автоскролл вниз и она читалась как вывод.
18941883
// Рендерим защёлку: плашка должна висеть, пока сервер держит
18951884
// ту же причину, даже если конкретный тик опроса её не увидел.
1896-
(noticeLatch ?: snapshot?.notice)?.let { n ->
1885+
(ui.noticeLatch ?: ui.snapshot?.notice)?.let { n ->
18971886
item(key = "notice_${n.title}_${n.attempt}") {
18981887
NoticeRow(n, context)
18991888
}
@@ -1919,7 +1908,7 @@ fun ChatOverlay(
19191908
// сообщений — то есть на строку «думаю», а карточка оставалась под
19201909
// сгибом. Блокирующий вопрос обязан быть виден всегда, поэтому
19211910
// скроллить его не нужно вовсе.
1922-
snapshot?.ask?.firstOrNull()?.let { card ->
1911+
ui.snapshot?.ask?.firstOrNull()?.let { card ->
19231912
Box(modifier = Modifier.padding(top = 8.dp)) {
19241913
AskCardView(
19251914
card = card,
@@ -1950,8 +1939,8 @@ fun ChatOverlay(
19501939
verticalAlignment = Alignment.CenterVertically,
19511940
) {
19521941
BasicTextField(
1953-
value = draft,
1954-
onValueChange = { draft = it },
1942+
value = ui.draft,
1943+
onValueChange = { ui.draft = it },
19551944
modifier =
19561945
Modifier
19571946
.weight(1f)
@@ -1967,7 +1956,7 @@ fun ChatOverlay(
19671956
keyboardActions = KeyboardActions(onSend = { send() }),
19681957
decorationBox = { inner ->
19691958
Box {
1970-
if (draft.isEmpty()) {
1959+
if (ui.draft.isEmpty()) {
19711960
Text(
19721961
"Напиши сообщение…",
19731962
color = Color(0xFF777777),
@@ -1984,7 +1973,7 @@ fun ChatOverlay(
19841973
// Пока идёт запись (listening) он остаётся — иначе нечем
19851974
// остановить её.
19861975
AnimatedVisibility(
1987-
visible = draft.isEmpty() || listening,
1976+
visible = ui.draft.isEmpty() || listening,
19881977
enter = fadeIn(),
19891978
exit = fadeOut(),
19901979
) {
@@ -2030,9 +2019,9 @@ fun ChatOverlay(
20302019
Modifier
20312020
.padding(start = 8.dp)
20322021
.size(38.dp)
2033-
.clickable(enabled = !stopping) { stopGen() },
2022+
.clickable(enabled = !ui.stopping) { stopGen() },
20342023
shape = CircleShape,
2035-
color = if (stopping) Color(0xFF3A3A3A) else Color(0xFF9E1C1C),
2024+
color = if (ui.stopping) Color(0xFF3A3A3A) else Color(0xFF9E1C1C),
20362025
) {
20372026
Icon(
20382027
imageVector = Icons.Filled.Stop,
@@ -2096,7 +2085,7 @@ fun ChatOverlay(
20962085
confirmButton = {
20972086
TextButton(onClick = {
20982087
confirmResetDead = false
2099-
snapshot?.activeId?.let(::resetDeadSession)
2088+
ui.snapshot?.activeId?.let(::resetDeadSession)
21002089
}) { Text("Удалить и начать заново", color = Color(0xFFFF6F5A)) }
21012090
},
21022091
dismissButton = {

0 commit comments

Comments
 (0)