@@ -132,6 +132,7 @@ import org.opencode.mobile.ui.chat.CONTEXT_LIMIT
132132import org.opencode.mobile.ui.chat.ChatAppearance
133133import org.opencode.mobile.ui.chat.ChatCache
134134import org.opencode.mobile.ui.chat.ChatPollState
135+ import org.opencode.mobile.ui.chat.ChatUiState
135136import org.opencode.mobile.ui.chat.ContextGauge
136137import org.opencode.mobile.ui.chat.DeadRow
137138import 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