From eba5f9b4a4cad5435c795f0131c875f30c04f67b Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Mon, 16 Feb 2026 20:23:40 +0000 Subject: [PATCH 01/32] fix(chat): restore thinking fallback and add live run progress --- .../ayagmar/pimobile/chat/ChatViewModel.kt | 134 +++++++++++++++++- .../ayagmar/pimobile/ui/chat/ChatScreen.kt | 82 ++++++++++- .../ayagmar/pimobile/ui/chat/LiveRunPhase.kt | 53 +++++++ .../pimobile/ui/chat/LiveRunProgressTest.kt | 118 +++++++++++++++ .../corerpc/AssistantTextAssembler.kt | 11 +- .../corerpc/AssistantTextAssemblerTest.kt | 64 +++++++++ 6 files changed, 458 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/ayagmar/pimobile/ui/chat/LiveRunPhase.kt create mode 100644 app/src/test/java/com/ayagmar/pimobile/ui/chat/LiveRunProgressTest.kt diff --git a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt index 36cb709..320b0c5 100644 --- a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt +++ b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt @@ -69,6 +69,8 @@ class ChatViewModel( private var historyWindowAbsoluteOffset: Int = 0 private var historyParsedStartIndex: Int = 0 private val pendingLocalUserIds = ArrayDeque() + private var thinkingDiagnostics = ThinkingDiagnosticsCounters() + private var thinkingDiagnosticsRunActive = false val uiState: StateFlow = _uiState.asStateFlow() @@ -487,6 +489,14 @@ class ChatViewModel( private fun observeStreamingState() { viewModelScope.launch { sessionController.isStreaming.collect { isStreaming -> + val wasStreaming = _uiState.value.isStreaming + + if (!wasStreaming && isStreaming) { + resetThinkingDiagnostics(startNewRun = true) + } else if (wasStreaming && !isStreaming) { + logThinkingDiagnostics(reason = "streaming_state_complete") + } + _uiState.update { current -> current.copy( isStreaming = isStreaming, @@ -540,8 +550,10 @@ class ChatViewModel( viewModelScope.launch { sessionController.sessionChanged.collect { // Reset state for new session + logThinkingDiagnostics(reason = "session_changed") hasRecordedFirstToken = false resetStreamingUpdateState() + resetThinkingDiagnostics(startNewRun = false) fullTimeline = emptyList() visibleTimelineSize = 0 pendingLocalUserIds.clear() @@ -583,7 +595,10 @@ class ChatViewModel( is AutoCompactionEndEvent -> handleCompactionEnd(event) is AutoRetryStartEvent -> handleRetryStart(event) is AutoRetryEndEvent -> handleRetryEnd(event) - is AgentEndEvent -> flushAllPendingStreamUpdates(force = true) + is AgentEndEvent -> { + flushAllPendingStreamUpdates(force = true) + logThinkingDiagnostics(reason = "agent_end") + } else -> Unit } } @@ -800,6 +815,105 @@ class ChatViewModel( addSystemNotification(message, type) } + private fun trackThinkingEventDiagnostics(event: MessageUpdateEvent) { + val assistantEvent = event.assistantMessageEvent ?: return + when (assistantEvent.type) { + "thinking_start" -> { + if (!thinkingDiagnosticsRunActive) { + resetThinkingDiagnostics(startNewRun = true) + } + thinkingDiagnostics = thinkingDiagnostics.copy(startEvents = thinkingDiagnostics.startEvents + 1) + } + + "thinking_delta" -> { + if (!thinkingDiagnosticsRunActive) { + resetThinkingDiagnostics(startNewRun = true) + } + val deltaLength = assistantEvent.delta?.length ?: 0 + thinkingDiagnostics = + thinkingDiagnostics.copy( + deltaEvents = thinkingDiagnostics.deltaEvents + 1, + deltaChars = thinkingDiagnostics.deltaChars + deltaLength, + ) + } + + "thinking_end" -> { + if (!thinkingDiagnosticsRunActive) { + resetThinkingDiagnostics(startNewRun = true) + } + val payload = assistantEvent.thinking ?: assistantEvent.content + thinkingDiagnostics = + thinkingDiagnostics.copy( + endEvents = thinkingDiagnostics.endEvents + 1, + endPayloadEvents = + thinkingDiagnostics.endPayloadEvents + + if (payload != null) { + 1 + } else { + 0 + }, + endPayloadChars = thinkingDiagnostics.endPayloadChars + (payload?.length ?: 0), + ) + } + } + } + + private fun trackThinkingRenderDiagnostics(update: AssistantTextUpdate) { + if (update.thinking.isNullOrBlank()) return + if (!thinkingDiagnosticsRunActive) return + + thinkingDiagnostics = + thinkingDiagnostics.copy( + renderedThinkingUpdates = thinkingDiagnostics.renderedThinkingUpdates + 1, + renderedThinkingCompleteEvents = + thinkingDiagnostics.renderedThinkingCompleteEvents + + if (update.isThinkingComplete) { + 1 + } else { + 0 + }, + ) + } + + private fun resetThinkingDiagnostics(startNewRun: Boolean) { + thinkingDiagnostics = ThinkingDiagnosticsCounters() + thinkingDiagnosticsRunActive = startNewRun + } + + private fun logThinkingDiagnostics(reason: String) { + if (!thinkingDiagnosticsRunActive) return + + val snapshot = thinkingDiagnostics + val totalThinkingEvents = snapshot.startEvents + snapshot.deltaEvents + snapshot.endEvents + val assessment = + when { + totalThinkingEvents == 0 -> "provider_no_thinking_events" + snapshot.renderedThinkingUpdates == 0 -> "client_rendering_gap_possible" + else -> "thinking_rendered" + } + + val message = + "reason=$reason start=${snapshot.startEvents} " + + "delta=${snapshot.deltaEvents} deltaChars=${snapshot.deltaChars} " + + "end=${snapshot.endEvents} endPayload=${snapshot.endPayloadEvents} " + + "endPayloadChars=${snapshot.endPayloadChars} " + + "rendered=${snapshot.renderedThinkingUpdates} " + + "renderedComplete=${snapshot.renderedThinkingCompleteEvents} " + + "assessment=$assessment" + + emitThinkingDiagnosticsLog(message) + resetThinkingDiagnostics(startNewRun = false) + } + + private fun emitThinkingDiagnosticsLog(message: String) { + val prefixed = "thinking_diagnostics $message" + runCatching { + android.util.Log.i(THINKING_DIAGNOSTICS_LOG_TAG, prefixed) + }.onFailure { + println("$THINKING_DIAGNOSTICS_LOG_TAG: $prefixed") + } + } + private fun addSystemNotification( message: String, type: String, @@ -1039,6 +1153,8 @@ class ChatViewModel( hasRecordedFirstToken = true } + trackThinkingEventDiagnostics(event) + val assistantEventType = event.assistantMessageEvent?.type when (assistantEventType) { "error" -> { @@ -1073,6 +1189,8 @@ class ChatViewModel( } private fun applyAssistantUpdate(update: AssistantTextUpdate) { + trackThinkingRenderDiagnostics(update) + val itemId = "assistant-stream-${update.messageKey}-${update.contentIndex}" val nextItem = ChatTimelineItem.Assistant( @@ -1699,7 +1817,9 @@ class ChatViewModel( override fun onCleared() { initialLoadJob?.cancel() + logThinkingDiagnostics(reason = "viewmodel_cleared") resetStreamingUpdateState() + resetThinkingDiagnostics(startNewRun = false) pendingLocalUserIds.clear() super.onCleared() } @@ -1779,6 +1899,7 @@ class ChatViewModel( private const val BASH_HISTORY_SIZE = 10 private const val MAX_NOTIFICATIONS = 6 private const val MAX_PENDING_QUEUE_ITEMS = 20 + private const val THINKING_DIAGNOSTICS_LOG_TAG = "ThinkingDiagnostics" } } @@ -1959,6 +2080,17 @@ private data class InitialLoadMetadata( val followUpMode: String, ) +private data class ThinkingDiagnosticsCounters( + val startEvents: Int = 0, + val deltaEvents: Int = 0, + val deltaChars: Int = 0, + val endEvents: Int = 0, + val endPayloadEvents: Int = 0, + val endPayloadChars: Int = 0, + val renderedThinkingUpdates: Int = 0, + val renderedThinkingCompleteEvents: Int = 0, +) + private data class HistoryMessageWindow( val messages: List, val absoluteOffset: Int, diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt index 8549604..f8817be 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt @@ -73,6 +73,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -112,6 +113,7 @@ import com.ayagmar.pimobile.sessions.SessionController import com.ayagmar.pimobile.sessions.SessionTreeEntry import com.ayagmar.pimobile.sessions.SessionTreeSnapshot import com.ayagmar.pimobile.sessions.SlashCommandInfo +import kotlinx.coroutines.delay private data class ChatCallbacks( val onToggleToolExpansion: (String) -> Unit, @@ -346,6 +348,8 @@ private fun ChatScreenContent( ) { ChatHeader( isStreaming = state.isStreaming, + isRetrying = state.isRetrying, + timeline = state.timeline, extensionTitle = state.extensionTitle, connectionState = state.connectionState, currentModel = state.currentModel, @@ -403,10 +407,12 @@ private fun ChatScreenContent( } } -@Suppress("LongMethod", "LongParameterList") +@Suppress("LongMethod", "LongParameterList", "CyclomaticComplexMethod") @Composable private fun ChatHeader( isStreaming: Boolean, + isRetrying: Boolean, + timeline: List, extensionTitle: String?, connectionState: com.ayagmar.pimobile.corenet.ConnectionState, currentModel: ModelInfo?, @@ -415,6 +421,43 @@ private fun ChatHeader( callbacks: ChatCallbacks, ) { val isCompact = isStreaming + var runStartedAtMs by remember { mutableStateOf(null) } + + LaunchedEffect(isStreaming) { + if (isStreaming) { + if (runStartedAtMs == null) { + runStartedAtMs = System.currentTimeMillis() + } + } else { + runStartedAtMs = null + } + } + + val elapsedSeconds by + produceState( + initialValue = 0L, + key1 = isStreaming, + key2 = runStartedAtMs, + ) { + val startedAt = runStartedAtMs + if (!isStreaming || startedAt == null) { + value = 0L + return@produceState + } + + while (true) { + value = ((System.currentTimeMillis() - startedAt).coerceAtLeast(0L)) / RUN_PROGRESS_TICK_MS + delay(RUN_PROGRESS_TICK_MS) + } + } + + val runPhase = + remember(isRetrying, timeline) { + inferLiveRunPhase( + isRetrying = isRetrying, + timeline = timeline, + ) + } Column(modifier = Modifier.fillMaxWidth()) { // Top row: Title and minimal actions @@ -481,6 +524,13 @@ private fun ChatHeader( } } + if (isStreaming) { + LiveRunProgressIndicator( + phase = runPhase, + elapsedSeconds = elapsedSeconds, + ) + } + // Compact model/thinking controls ModelThinkingControls( currentModel = currentModel, @@ -500,6 +550,34 @@ private fun ChatHeader( } } +@Composable +private fun LiveRunProgressIndicator( + phase: LiveRunPhase, + elapsedSeconds: Long, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 4.dp) + .testTag(CHAT_RUN_PROGRESS_TAG), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(12.dp), + strokeWidth = 2.dp, + ) + Text( + text = "Working · ${phase.label} · ${formatRunElapsed(elapsedSeconds)}", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + @Suppress("LongParameterList") @Composable private fun ChatBody( @@ -1876,6 +1954,7 @@ private fun ExtensionWidgets( internal const val CHAT_PROMPT_CONTROLS_TAG = "chat_prompt_controls" internal const val CHAT_STREAMING_CONTROLS_TAG = "chat_streaming_controls" internal const val CHAT_PROMPT_INPUT_ROW_TAG = "chat_prompt_input_row" +internal const val CHAT_RUN_PROGRESS_TAG = "chat_run_progress" private const val COLLAPSED_OUTPUT_LENGTH = 280 private const val THINKING_COLLAPSE_THRESHOLD = 280 @@ -1884,6 +1963,7 @@ private const val MAX_INLINE_USER_IMAGE_PREVIEWS = 4 private const val USER_IMAGE_PREVIEW_SIZE_DP = 56 private const val AUTO_SCROLL_BOTTOM_THRESHOLD_ITEMS = 2 private const val TOOL_HIGHLIGHT_MAX_LENGTH = 1_000 +private const val RUN_PROGRESS_TICK_MS = 1_000L private const val STREAMING_FRAME_LOG_TAG = "StreamingFrameMetrics" private val THINKING_LEVEL_OPTIONS = listOf("off", "minimal", "low", "medium", "high", "xhigh") private val CODE_FENCE_REGEX = Regex("```([\\w+-]*)\\r?\\n([\\s\\S]*?)```") diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/LiveRunPhase.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/LiveRunPhase.kt new file mode 100644 index 0000000..6aa8b70 --- /dev/null +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/LiveRunPhase.kt @@ -0,0 +1,53 @@ +package com.ayagmar.pimobile.ui.chat + +import com.ayagmar.pimobile.chat.ChatTimelineItem +import java.util.Locale + +internal enum class LiveRunPhase( + val label: String, +) { + WORKING("Working"), + THINKING("Thinking"), + RESPONDING("Responding"), + RUNNING_TOOLS("Running tools"), + RETRYING("Retrying"), +} + +internal fun inferLiveRunPhase( + isRetrying: Boolean, + timeline: List, +): LiveRunPhase { + if (isRetrying) return LiveRunPhase.RETRYING + + val latestStreamingItem = + timeline + .asReversed() + .firstOrNull { item -> + when (item) { + is ChatTimelineItem.Assistant -> item.isStreaming + is ChatTimelineItem.Tool -> item.isStreaming + is ChatTimelineItem.User -> false + } + } + + return when (latestStreamingItem) { + is ChatTimelineItem.Tool -> LiveRunPhase.RUNNING_TOOLS + is ChatTimelineItem.Assistant -> { + if (!latestStreamingItem.thinking.isNullOrBlank() && !latestStreamingItem.isThinkingComplete) { + LiveRunPhase.THINKING + } else { + LiveRunPhase.RESPONDING + } + } + else -> LiveRunPhase.WORKING + } +} + +internal fun formatRunElapsed(elapsedSeconds: Long): String { + val safeSeconds = elapsedSeconds.coerceAtLeast(0L) + val minutes = safeSeconds / SECONDS_PER_MINUTE + val seconds = safeSeconds % SECONDS_PER_MINUTE + return String.format(Locale.US, "%02d:%02d", minutes, seconds) +} + +private const val SECONDS_PER_MINUTE = 60L diff --git a/app/src/test/java/com/ayagmar/pimobile/ui/chat/LiveRunProgressTest.kt b/app/src/test/java/com/ayagmar/pimobile/ui/chat/LiveRunProgressTest.kt new file mode 100644 index 0000000..a417463 --- /dev/null +++ b/app/src/test/java/com/ayagmar/pimobile/ui/chat/LiveRunProgressTest.kt @@ -0,0 +1,118 @@ +package com.ayagmar.pimobile.ui.chat + +import com.ayagmar.pimobile.chat.ChatTimelineItem +import org.junit.Assert.assertEquals +import org.junit.Test + +class LiveRunProgressTest { + @Test + fun retryingStateTakesPriorityOverTimeline() { + val phase = + inferLiveRunPhase( + isRetrying = true, + timeline = + listOf( + ChatTimelineItem.Assistant( + id = "assistant-1", + text = "", + thinking = "draft", + isThinkingComplete = false, + isStreaming = true, + ), + ), + ) + + assertEquals(LiveRunPhase.RETRYING, phase) + } + + @Test + fun infersThinkingWhenAssistantHasIncompleteThinkingContent() { + val phase = + inferLiveRunPhase( + isRetrying = false, + timeline = + listOf( + ChatTimelineItem.Assistant( + id = "assistant-1", + text = "", + thinking = "reasoning", + isThinkingComplete = false, + isStreaming = true, + ), + ), + ) + + assertEquals(LiveRunPhase.THINKING, phase) + } + + @Test + fun infersRespondingForStreamingAssistantWithoutActiveThinking() { + val phase = + inferLiveRunPhase( + isRetrying = false, + timeline = + listOf( + ChatTimelineItem.Assistant( + id = "assistant-1", + text = "partial", + thinking = "done", + isThinkingComplete = true, + isStreaming = true, + ), + ), + ) + + assertEquals(LiveRunPhase.RESPONDING, phase) + } + + @Test + fun infersToolPhaseWhenLatestStreamingItemIsTool() { + val phase = + inferLiveRunPhase( + isRetrying = false, + timeline = + listOf( + ChatTimelineItem.Assistant( + id = "assistant-1", + text = "partial", + thinking = null, + isStreaming = true, + ), + ChatTimelineItem.Tool( + id = "tool-1", + toolName = "bash", + output = "running", + isCollapsed = true, + isStreaming = true, + isError = false, + ), + ), + ) + + assertEquals(LiveRunPhase.RUNNING_TOOLS, phase) + } + + @Test + fun fallsBackToWorkingWhenNoStreamingTimelineContext() { + val phase = + inferLiveRunPhase( + isRetrying = false, + timeline = + listOf( + ChatTimelineItem.User( + id = "user-1", + text = "hello", + ), + ), + ) + + assertEquals(LiveRunPhase.WORKING, phase) + } + + @Test + fun formatsElapsedAsMinuteSecond() { + assertEquals("00:00", formatRunElapsed(0)) + assertEquals("00:09", formatRunElapsed(9)) + assertEquals("01:01", formatRunElapsed(61)) + } +} diff --git a/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/AssistantTextAssembler.kt b/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/AssistantTextAssembler.kt index 21a37ec..8915fe6 100644 --- a/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/AssistantTextAssembler.kt +++ b/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/AssistantTextAssembler.kt @@ -28,7 +28,13 @@ class AssistantTextAssembler( type == "text_end" -> handleTextEnd(event, contentIndex, assistantEvent.content) type == "thinking_start" -> handleThinkingStart(event, contentIndex) type == "thinking_delta" -> handleThinkingDelta(event, contentIndex, assistantEvent.delta) - type == "thinking_end" -> handleThinkingEnd(event, contentIndex, assistantEvent.thinking) + type == "thinking_end" -> + handleThinkingEnd( + event = event, + contentIndex = contentIndex, + thinking = assistantEvent.thinking, + content = assistantEvent.content, + ) else -> null } } @@ -114,9 +120,10 @@ class AssistantTextAssembler( event: MessageUpdateEvent, contentIndex: Int, thinking: String?, + content: String?, ): AssistantTextUpdate { val builder = thinkingBuilderFor(event, contentIndex) - val resolvedThinking = thinking ?: builder.toString() + val resolvedThinking = thinking ?: content ?: builder.toString() builder.clear() builder.append(resolvedThinking) return AssistantTextUpdate( diff --git a/core-rpc/src/test/kotlin/com/ayagmar/pimobile/corerpc/AssistantTextAssemblerTest.kt b/core-rpc/src/test/kotlin/com/ayagmar/pimobile/corerpc/AssistantTextAssemblerTest.kt index 9ed20c5..cdbd2b5 100644 --- a/core-rpc/src/test/kotlin/com/ayagmar/pimobile/corerpc/AssistantTextAssemblerTest.kt +++ b/core-rpc/src/test/kotlin/com/ayagmar/pimobile/corerpc/AssistantTextAssemblerTest.kt @@ -156,6 +156,70 @@ class AssistantTextAssemblerTest { assertTrue(textEnd?.isThinkingComplete ?: false) } + @Test + fun `thinking_end falls back to content payload when thinking field is absent`() { + val assembler = AssistantTextAssembler() + + val thinkingEnd = + assembler.apply( + messageUpdate( + messageTimestamp = 300, + eventType = "thinking_end", + contentIndex = 0, + content = "Reasoning from content payload", + thinking = null, + ), + ) + + assertEquals("Reasoning from content payload", thinkingEnd?.thinking) + assertTrue(thinkingEnd?.isThinkingComplete ?: false) + } + + @Test + fun `supports mixed thinking payload shapes across providers`() { + val assembler = AssistantTextAssembler() + + assembler.apply( + messageUpdate( + messageTimestamp = 400, + eventType = "thinking_start", + contentIndex = 0, + ), + ) + val legacyThinkingEnd = + assembler.apply( + messageUpdate( + messageTimestamp = 400, + eventType = "thinking_end", + contentIndex = 0, + thinking = "legacy-thinking-field", + ), + ) + + assembler.apply( + messageUpdate( + messageTimestamp = 500, + eventType = "thinking_start", + contentIndex = 0, + ), + ) + val contentThinkingEnd = + assembler.apply( + messageUpdate( + messageTimestamp = 500, + eventType = "thinking_end", + contentIndex = 0, + content = "content-thinking-field", + thinking = null, + ), + ) + + assertEquals("legacy-thinking-field", legacyThinkingEnd?.thinking) + assertEquals("content-thinking-field", contentThinkingEnd?.thinking) + assertTrue(legacyThinkingEnd?.isThinkingComplete ?: false) + assertTrue(contentThinkingEnd?.isThinkingComplete ?: false) + } + @Test fun `evicts oldest message buffers when limit reached`() { val assembler = AssistantTextAssembler(maxTrackedMessages = 1) From f35195a632fb0c280cd1cef5bc38370470044a75 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Mon, 16 Feb 2026 20:40:31 +0000 Subject: [PATCH 02/32] fix(streaming): harden event buffering and delta diagnostics --- .../ayagmar/pimobile/chat/ChatViewModel.kt | 180 +++++++++++++++++- .../ChatViewModelThinkingExpansionTest.kt | 41 ++++ .../pimobile/corenet/PiRpcConnection.kt | 22 +-- .../pimobile/corenet/WebSocketTransport.kt | 99 +++++++++- 4 files changed, 313 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt index 320b0c5..a893685 100644 --- a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt +++ b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt @@ -71,6 +71,9 @@ class ChatViewModel( private val pendingLocalUserIds = ArrayDeque() private var thinkingDiagnostics = ThinkingDiagnosticsCounters() private var thinkingDiagnosticsRunActive = false + private var streamingDiagnostics = StreamingDeltaDiagnosticsCounters() + private var streamingDiagnosticsRunActive = false + private var streamingDiagnosticsRunStartedAtMs: Long = 0 val uiState: StateFlow = _uiState.asStateFlow() @@ -493,8 +496,10 @@ class ChatViewModel( if (!wasStreaming && isStreaming) { resetThinkingDiagnostics(startNewRun = true) + resetStreamingDiagnostics(startNewRun = true) } else if (wasStreaming && !isStreaming) { logThinkingDiagnostics(reason = "streaming_state_complete") + logStreamingDiagnostics(reason = "streaming_state_complete") } _uiState.update { current -> @@ -551,9 +556,11 @@ class ChatViewModel( sessionController.sessionChanged.collect { // Reset state for new session logThinkingDiagnostics(reason = "session_changed") + logStreamingDiagnostics(reason = "session_changed") hasRecordedFirstToken = false resetStreamingUpdateState() resetThinkingDiagnostics(startNewRun = false) + resetStreamingDiagnostics(startNewRun = false) fullTimeline = emptyList() visibleTimelineSize = 0 pendingLocalUserIds.clear() @@ -598,6 +605,7 @@ class ChatViewModel( is AgentEndEvent -> { flushAllPendingStreamUpdates(force = true) logThinkingDiagnostics(reason = "agent_end") + logStreamingDiagnostics(reason = "agent_end") } else -> Unit } @@ -914,6 +922,119 @@ class ChatViewModel( } } + private fun trackStreamingEventDiagnostics(event: MessageUpdateEvent) { + val assistantEventType = event.assistantMessageEvent?.type + if (!streamingDiagnosticsRunActive) { + if (assistantEventType == null) return + resetStreamingDiagnostics(startNewRun = true) + } + + incrementStreamingDiagnostics { counters -> + counters.copy(messageUpdateEvents = counters.messageUpdateEvents + 1) + } + + when (assistantEventType) { + "text_delta" -> + incrementStreamingDiagnostics { counters -> + counters.copy( + assistantDeltaEvents = counters.assistantDeltaEvents + 1, + textDeltaEvents = counters.textDeltaEvents + 1, + ) + } + + "thinking_delta" -> + incrementStreamingDiagnostics { counters -> + counters.copy( + assistantDeltaEvents = counters.assistantDeltaEvents + 1, + thinkingDeltaEvents = counters.thinkingDeltaEvents + 1, + ) + } + + "text_start", + "text_end", + "thinking_start", + "thinking_end", + -> + incrementStreamingDiagnostics { counters -> + counters.copy(assistantNonDeltaEvents = counters.assistantNonDeltaEvents + 1) + } + } + } + + private fun trackStreamingRenderDiagnostics(source: AssistantUpdateSource) { + if (!streamingDiagnosticsRunActive) return + + when (source) { + AssistantUpdateSource.IMMEDIATE_DELTA -> + incrementStreamingDiagnostics { counters -> + counters.copy(emittedImmediateDeltaEvents = counters.emittedImmediateDeltaEvents + 1) + } + + AssistantUpdateSource.FLUSHED_DELTA -> + incrementStreamingDiagnostics { counters -> + counters.copy(emittedFlushedDeltaEvents = counters.emittedFlushedDeltaEvents + 1) + } + + AssistantUpdateSource.NON_DELTA -> Unit + } + } + + private fun incrementStreamingDiagnostics( + update: (StreamingDeltaDiagnosticsCounters) -> StreamingDeltaDiagnosticsCounters, + ) { + streamingDiagnostics = update(streamingDiagnostics) + } + + private fun resetStreamingDiagnostics(startNewRun: Boolean) { + streamingDiagnostics = StreamingDeltaDiagnosticsCounters() + streamingDiagnosticsRunActive = startNewRun + streamingDiagnosticsRunStartedAtMs = if (startNewRun) System.currentTimeMillis() else 0L + } + + private fun logStreamingDiagnostics(reason: String) { + if (!streamingDiagnosticsRunActive) return + + val snapshot = streamingDiagnostics + val durationMs = + if (streamingDiagnosticsRunStartedAtMs > 0L) { + (System.currentTimeMillis() - streamingDiagnosticsRunStartedAtMs).coerceAtLeast(0L) + } else { + 0L + } + val emittedDeltaEvents = snapshot.emittedImmediateDeltaEvents + snapshot.emittedFlushedDeltaEvents + val assessment = + when { + snapshot.assistantDeltaEvents == 0 -> "provider_sparse_or_chunked" + snapshot.coalescedDeltaEvents > 0 -> "delta_coalesced" + else -> "delta_live" + } + + val message = + "reason=$reason durationMs=$durationMs " + + "messageUpdates=${snapshot.messageUpdateEvents} " + + "assistantDelta=${snapshot.assistantDeltaEvents} " + + "textDelta=${snapshot.textDeltaEvents} " + + "thinkingDelta=${snapshot.thinkingDeltaEvents} " + + "assistantNonDelta=${snapshot.assistantNonDeltaEvents} " + + "coalescedDelta=${snapshot.coalescedDeltaEvents} " + + "emittedImmediateDelta=${snapshot.emittedImmediateDeltaEvents} " + + "emittedFlushedDelta=${snapshot.emittedFlushedDeltaEvents} " + + "emittedDelta=$emittedDeltaEvents " + + "assessment=$assessment" + + emitStreamingDiagnosticsLog(message) + resetStreamingDiagnostics(startNewRun = false) + } + + private fun emitStreamingDiagnosticsLog(message: String) { + val prefixed = "streaming_diagnostics $message" + runCatching { + android.util.Log.i(STREAMING_DIAGNOSTICS_LOG_TAG, prefixed) + }.onFailure { + println("$STREAMING_DIAGNOSTICS_LOG_TAG: $prefixed") + } + } + private fun addSystemNotification( message: String, type: String, @@ -1154,6 +1275,7 @@ class ChatViewModel( } trackThinkingEventDiagnostics(event) + trackStreamingEventDiagnostics(event) val assistantEventType = event.assistantMessageEvent?.type when (assistantEventType) { @@ -1177,19 +1299,44 @@ class ChatViewModel( assistantEventType == "thinking_delta" if (isHighFrequencyDelta) { - assistantUpdateThrottler.offer(update)?.let(::applyAssistantUpdate) - ?: scheduleAssistantUpdateFlush() + handleHighFrequencyAssistantUpdate(update) } else { flushPendingAssistantUpdate(force = true) - applyAssistantUpdate(update) + applyAssistantUpdate(update, source = AssistantUpdateSource.NON_DELTA) } } } } } - private fun applyAssistantUpdate(update: AssistantTextUpdate) { + private fun handleHighFrequencyAssistantUpdate(update: AssistantTextUpdate) { + val hadPending = assistantUpdateThrottler.hasPending() + val immediateUpdate = assistantUpdateThrottler.offer(update) + + if (immediateUpdate != null) { + applyAssistantUpdate( + update = immediateUpdate, + source = AssistantUpdateSource.IMMEDIATE_DELTA, + ) + return + } + + if (hadPending) { + incrementStreamingDiagnostics { counters -> + counters.copy( + coalescedDeltaEvents = counters.coalescedDeltaEvents + 1, + ) + } + } + scheduleAssistantUpdateFlush() + } + + private fun applyAssistantUpdate( + update: AssistantTextUpdate, + source: AssistantUpdateSource, + ) { trackThinkingRenderDiagnostics(update) + trackStreamingRenderDiagnostics(source) val itemId = "assistant-stream-${update.messageKey}-${update.contentIndex}" val nextItem = @@ -1584,7 +1731,10 @@ class ChatViewModel( } if (update != null) { - applyAssistantUpdate(update) + applyAssistantUpdate( + update = update, + source = AssistantUpdateSource.FLUSHED_DELTA, + ) } if (!assistantUpdateThrottler.hasPending()) { @@ -1815,11 +1965,19 @@ class ChatViewModel( } } + private enum class AssistantUpdateSource { + IMMEDIATE_DELTA, + FLUSHED_DELTA, + NON_DELTA, + } + override fun onCleared() { initialLoadJob?.cancel() logThinkingDiagnostics(reason = "viewmodel_cleared") + logStreamingDiagnostics(reason = "viewmodel_cleared") resetStreamingUpdateState() resetThinkingDiagnostics(startNewRun = false) + resetStreamingDiagnostics(startNewRun = false) pendingLocalUserIds.clear() super.onCleared() } @@ -1900,6 +2058,7 @@ class ChatViewModel( private const val MAX_NOTIFICATIONS = 6 private const val MAX_PENDING_QUEUE_ITEMS = 20 private const val THINKING_DIAGNOSTICS_LOG_TAG = "ThinkingDiagnostics" + private const val STREAMING_DIAGNOSTICS_LOG_TAG = "StreamingDiagnostics" } } @@ -2091,6 +2250,17 @@ private data class ThinkingDiagnosticsCounters( val renderedThinkingCompleteEvents: Int = 0, ) +private data class StreamingDeltaDiagnosticsCounters( + val messageUpdateEvents: Int = 0, + val assistantDeltaEvents: Int = 0, + val textDeltaEvents: Int = 0, + val thinkingDeltaEvents: Int = 0, + val assistantNonDeltaEvents: Int = 0, + val coalescedDeltaEvents: Int = 0, + val emittedImmediateDeltaEvents: Int = 0, + val emittedFlushedDeltaEvents: Int = 0, +) + private data class HistoryMessageWindow( val messages: List, val absoluteOffset: Int, diff --git a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt index 7a186e2..909a811 100644 --- a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt @@ -2,6 +2,7 @@ package com.ayagmar.pimobile.chat +import com.ayagmar.pimobile.corerpc.AgentEndEvent import com.ayagmar.pimobile.corerpc.AssistantMessageEvent import com.ayagmar.pimobile.corerpc.MessageEndEvent import com.ayagmar.pimobile.corerpc.MessageUpdateEvent @@ -203,6 +204,46 @@ class ChatViewModelThinkingExpansionTest { assertEquals("Hello world", item.text) } + @Test + fun pendingAssistantDeltaIsFlushedWhenAgentEnds() = + runTest(dispatcher) { + val controller = FakeSessionController() + val viewModel = ChatViewModel(sessionController = controller) + dispatcher.scheduler.advanceUntilIdle() + awaitInitialLoad(viewModel) + + controller.emitEvent( + textUpdate( + assistantType = "text_start", + messageTimestamp = "1733234567902", + ), + ) + controller.emitEvent( + textUpdate( + assistantType = "text_delta", + delta = "Streaming", + messageTimestamp = "1733234567902", + ), + ) + controller.emitEvent( + textUpdate( + assistantType = "text_delta", + delta = " integrity", + messageTimestamp = "1733234567902", + ), + ) + controller.emitEvent( + AgentEndEvent( + type = "agent_end", + messages = null, + ), + ) + dispatcher.scheduler.advanceUntilIdle() + + val item = viewModel.singleAssistantItem() + assertEquals("Streaming integrity", item.text) + } + @Test fun sessionChangeDropsPendingAssistantDeltaFromPreviousSession() = runTest(dispatcher) { diff --git a/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/PiRpcConnection.kt b/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/PiRpcConnection.kt index 7be0fbe..754d672 100644 --- a/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/PiRpcConnection.kt +++ b/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/PiRpcConnection.kt @@ -11,7 +11,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow @@ -47,16 +46,8 @@ class PiRpcConnection( private val pendingResponses = ConcurrentHashMap>() private val bridgeChannels = ConcurrentHashMap>() - private val _rpcEvents = - MutableSharedFlow( - extraBufferCapacity = DEFAULT_BUFFER_CAPACITY, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - private val _bridgeEvents = - MutableSharedFlow( - extraBufferCapacity = DEFAULT_BUFFER_CAPACITY, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) + private val _rpcEvents = MutableSharedFlow(extraBufferCapacity = RPC_EVENT_BUFFER_CAPACITY) + private val _bridgeEvents = MutableSharedFlow(extraBufferCapacity = BRIDGE_EVENT_BUFFER_CAPACITY) private val _resyncEvents = MutableSharedFlow(replay = 1, extraBufferCapacity = 1) private var inboundJob: Job? = null @@ -223,14 +214,14 @@ class PiRpcConnection( }.getOrNull() ?: return - _rpcEvents.emit(rpcMessage) - if (rpcMessage is RpcResponse) { val responseId = rpcMessage.id if (responseId != null) { pendingResponses.remove(responseId)?.complete(rpcMessage) } } + + _rpcEvents.emit(rpcMessage) } BRIDGE_CHANNEL -> { @@ -239,8 +230,8 @@ class PiRpcConnection( type = envelope.payload.stringField("type") ?: UNKNOWN_BRIDGE_TYPE, payload = envelope.payload, ) - _bridgeEvents.emit(bridgeMessage) bridgeChannels[bridgeMessage.type]?.trySend(bridgeMessage) + _bridgeEvents.emit(bridgeMessage) } } } @@ -353,7 +344,8 @@ class PiRpcConnection( private const val RPC_CHANNEL = "rpc" private const val UNKNOWN_BRIDGE_TYPE = "unknown" private const val BRIDGE_HELLO_TYPE = "bridge_hello" - private const val DEFAULT_BUFFER_CAPACITY = 128 + private const val RPC_EVENT_BUFFER_CAPACITY = 256 + private const val BRIDGE_EVENT_BUFFER_CAPACITY = 128 private const val DEFAULT_REQUEST_TIMEOUT_MS = 10_000L val defaultJson: Json = diff --git a/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/WebSocketTransport.kt b/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/WebSocketTransport.kt index 59762da..0538aa7 100644 --- a/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/WebSocketTransport.kt +++ b/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/WebSocketTransport.kt @@ -6,7 +6,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -26,27 +25,34 @@ import okhttp3.WebSocket import okhttp3.WebSocketListener import okio.ByteString import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicLong +import java.util.logging.Logger import kotlin.math.min class WebSocketTransport( client: OkHttpClient? = null, private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + private val inboundBufferCapacity: Int = DEFAULT_INBOUND_BUFFER_CAPACITY, ) : SocketTransport { private val client: OkHttpClient = client ?: createDefaultClient() private val lifecycleMutex = Mutex() private val outboundQueue = Channel(DEFAULT_OUTBOUND_BUFFER_CAPACITY) - private val inbound = - MutableSharedFlow( - extraBufferCapacity = DEFAULT_INBOUND_BUFFER_CAPACITY, - onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) + private val inbound = MutableSharedFlow(extraBufferCapacity = inboundBufferCapacity) private val state = MutableStateFlow(ConnectionState.DISCONNECTED) + private val logger = Logger.getLogger(WebSocketTransport::class.java.name) + private val inboundReceivedCount = AtomicLong(0) + private val inboundDroppedCount = AtomicLong(0) + private val inboundBackpressureReconnectCount = AtomicLong(0) private var activeConnection: ActiveConnection? = null private var connectionJob: Job? = null private var target: WebSocketTarget? = null private var explicitDisconnect = false + init { + require(inboundBufferCapacity > 0) { "inboundBufferCapacity must be greater than 0" } + } + override val inboundMessages: Flow = inbound.asSharedFlow() override val connectionState = state.asStateFlow() @@ -101,6 +107,7 @@ class WebSocketTransport( jobToCancel?.join() clearOutboundQueue() state.value = ConnectionState.DISCONNECTED + logInboundDiagnostics(reason = "disconnect") } override suspend fun send(message: String) { @@ -188,8 +195,14 @@ class WebSocketTransport( val opened = CompletableDeferred() val closed = CompletableDeferred() + val receivedAtConnectionStart = inboundReceivedCount.get() + val droppedAtConnectionStart = inboundDroppedCount.get() + val reconnectsAtConnectionStart = inboundBackpressureReconnectCount.get() + val listener = object : WebSocketListener() { + private var backpressureRecoveryTriggered = false + override fun onOpen( webSocket: WebSocket, response: Response, @@ -201,14 +214,28 @@ class WebSocketTransport( webSocket: WebSocket, text: String, ) { - inbound.tryEmit(text) + emitInboundOrRecover( + webSocket = webSocket, + payload = text, + markBackpressureTriggered = { + backpressureRecoveryTriggered = true + }, + isBackpressureTriggered = { backpressureRecoveryTriggered }, + ) } override fun onMessage( webSocket: WebSocket, bytes: ByteString, ) { - inbound.tryEmit(bytes.utf8()) + emitInboundOrRecover( + webSocket = webSocket, + payload = bytes.utf8(), + markBackpressureTriggered = { + backpressureRecoveryTriggered = true + }, + isBackpressureTriggered = { backpressureRecoveryTriggered }, + ) } override fun onClosing( @@ -224,6 +251,12 @@ class WebSocketTransport( code: Int, reason: String, ) { + logInboundDiagnostics( + reason = "socket_closed:$code:$reason", + receivedAtStart = receivedAtConnectionStart, + droppedAtStart = droppedAtConnectionStart, + reconnectsAtStart = reconnectsAtConnectionStart, + ) closed.complete(Unit) } @@ -232,6 +265,12 @@ class WebSocketTransport( t: Throwable, response: Response?, ) { + logInboundDiagnostics( + reason = "socket_failure:${t.message ?: "unknown"}", + receivedAtStart = receivedAtConnectionStart, + droppedAtStart = droppedAtConnectionStart, + reconnectsAtStart = reconnectsAtConnectionStart, + ) if (!opened.isCompleted) { opened.completeExceptionally(t) } @@ -284,6 +323,46 @@ class WebSocketTransport( } } + private fun emitInboundOrRecover( + webSocket: WebSocket, + payload: String, + markBackpressureTriggered: () -> Unit, + isBackpressureTriggered: () -> Boolean, + ) { + inboundReceivedCount.incrementAndGet() + + if (inbound.tryEmit(payload)) { + return + } + + inboundDroppedCount.incrementAndGet() + + if (!isBackpressureTriggered()) { + markBackpressureTriggered() + inboundBackpressureReconnectCount.incrementAndGet() + logger.warning("Inbound buffer saturated; restarting websocket to force deterministic resync") + val closed = webSocket.close(BACKPRESSURE_CLOSE_CODE, BACKPRESSURE_CLOSE_REASON) + if (!closed) { + webSocket.cancel() + } + } + } + + private fun logInboundDiagnostics( + reason: String, + receivedAtStart: Long = 0, + droppedAtStart: Long = 0, + reconnectsAtStart: Long = 0, + ) { + val received = inboundReceivedCount.get() - receivedAtStart + val dropped = inboundDroppedCount.get() - droppedAtStart + val reconnects = inboundBackpressureReconnectCount.get() - reconnectsAtStart + logger.info( + "ws_inbound_diagnostics reason=$reason " + + "received=$received dropped=$dropped backpressureReconnects=$reconnects", + ) + } + private data class ActiveConnection( val socket: WebSocket, val closed: CompletableDeferred, @@ -298,7 +377,9 @@ class WebSocketTransport( companion object { private const val CLIENT_DISCONNECT_REASON = "client disconnect" private const val NORMAL_CLOSE_CODE = 1000 - private const val DEFAULT_INBOUND_BUFFER_CAPACITY = 256 + private const val BACKPRESSURE_CLOSE_CODE = 1013 + private const val BACKPRESSURE_CLOSE_REASON = "inbound backpressure" + private const val DEFAULT_INBOUND_BUFFER_CAPACITY = 1024 private const val DEFAULT_OUTBOUND_BUFFER_CAPACITY = 256 private fun createDefaultClient(): OkHttpClient { From 19b45d490cc257c4212310361b740678258d5b23 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Mon, 16 Feb 2026 20:48:34 +0000 Subject: [PATCH 03/32] fix(session): add sync-now and cross-device coherency warnings --- .../ayagmar/pimobile/chat/ChatViewModel.kt | 103 +++++++++++++++++- .../ayagmar/pimobile/ui/chat/ChatScreen.kt | 29 +++++ .../ChatViewModelThinkingExpansionTest.kt | 61 +++++++++++ 3 files changed, 187 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt index a893685..e4bcdda 100644 --- a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt +++ b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt @@ -81,7 +81,7 @@ class ChatViewModel( observeConnection() observeStreamingState() observeEvents() - loadInitialMessages() + loadInitialMessages(reason = TimelineReloadReason.INITIAL) } fun onInputTextChanged(text: String) { @@ -129,6 +129,8 @@ class ChatViewModel( return } + _uiState.update { it.copy(sessionCoherencyWarning = null) } + // Record prompt send for TTFT tracking recordMetricsSafely { PerformanceMetrics.recordPromptSend() } hasRecordedFirstToken = false @@ -483,7 +485,7 @@ class ChatViewModel( } // Reload messages when connection becomes active and timeline is empty if (state == ConnectionState.CONNECTED && previousState != ConnectionState.CONNECTED && timelineEmpty) { - loadInitialMessages() + loadInitialMessages(reason = TimelineReloadReason.CONNECTION_RECOVERY) } } } @@ -549,7 +551,7 @@ class ChatViewModel( runCatching(record) } - @Suppress("CyclomaticComplexMethod") + @Suppress("CyclomaticComplexMethod", "LongMethod") private fun observeEvents() { // Observe session changes and reload timeline viewModelScope.launch { @@ -565,7 +567,8 @@ class ChatViewModel( visibleTimelineSize = 0 pendingLocalUserIds.clear() resetHistoryWindow() - loadInitialMessages() + _uiState.update { it.copy(sessionCoherencyWarning = null, isSyncingSession = false) } + loadInitialMessages(reason = TimelineReloadReason.SESSION_CHANGED) } } @@ -1113,6 +1116,18 @@ class ChatViewModel( _uiState.update { it.copy(pendingQueueItems = emptyList()) } } + fun syncNow() { + if (_uiState.value.isSyncingSession) return + + _uiState.update { + it.copy( + isSyncingSession = true, + errorMessage = null, + ) + } + loadInitialMessages(reason = TimelineReloadReason.MANUAL_SYNC) + } + fun loadOlderMessages() { when { visibleTimelineSize < fullTimeline.size -> { @@ -1155,7 +1170,18 @@ class ChatViewModel( } } - private fun loadInitialMessages() { + @Suppress("LongMethod", "CyclomaticComplexMethod") + private fun loadInitialMessages(reason: TimelineReloadReason) { + val previousHistorySignature = + if ( + reason == TimelineReloadReason.MANUAL_SYNC || + reason == TimelineReloadReason.CONNECTION_RECOVERY + ) { + historyWindowSignature(historyWindowMessages) + } else { + null + } + initialLoadJob?.cancel() initialLoadJob = viewModelScope.launch(Dispatchers.IO) { @@ -1191,6 +1217,45 @@ class ChatViewModel( ) } } + + val refreshedHistorySignature = historyWindowSignature(historyWindowMessages) + val hasPotentialExternalChanges = + messagesResult.isSuccess && + previousHistorySignature != null && + previousHistorySignature != refreshedHistorySignature + + if (reason == TimelineReloadReason.MANUAL_SYNC) { + _uiState.update { state -> + state.copy( + isSyncingSession = false, + sessionCoherencyWarning = + if (hasPotentialExternalChanges) { + SESSION_COHERENCY_WARNING_MESSAGE + } else { + null + }, + ) + } + + if (messagesResult.isSuccess) { + val message = + if (hasPotentialExternalChanges) { + "Potential cross-device edits detected. Timeline refreshed." + } else { + "Session sync complete" + } + val type = if (hasPotentialExternalChanges) "warning" else "info" + addSystemNotification(message = message, type = type) + } + } else if (hasPotentialExternalChanges) { + _uiState.update { + it.copy(sessionCoherencyWarning = SESSION_COHERENCY_WARNING_MESSAGE) + } + addSystemNotification( + message = "Session changed while disconnected or edited elsewhere. Review before continuing.", + type = "warning", + ) + } } } @@ -1590,7 +1655,7 @@ class ChatViewModel( sessionTree = updatedTree, ) } - loadInitialMessages() + loadInitialMessages(reason = TimelineReloadReason.TREE_NAVIGATION) } else { _uiState.update { it.copy(errorMessage = result.exceptionOrNull()?.message) } } @@ -1971,6 +2036,14 @@ class ChatViewModel( NON_DELTA, } + private enum class TimelineReloadReason { + INITIAL, + CONNECTION_RECOVERY, + SESSION_CHANGED, + TREE_NAVIGATION, + MANUAL_SYNC, + } + override fun onCleared() { initialLoadJob?.cancel() logThinkingDiagnostics(reason = "viewmodel_cleared") @@ -2059,6 +2132,8 @@ class ChatViewModel( private const val MAX_PENDING_QUEUE_ITEMS = 20 private const val THINKING_DIAGNOSTICS_LOG_TAG = "ThinkingDiagnostics" private const val STREAMING_DIAGNOSTICS_LOG_TAG = "StreamingDiagnostics" + private const val SESSION_COHERENCY_WARNING_MESSAGE = + "Potential cross-device session edits detected. Use Sync now before continuing." } } @@ -2086,6 +2161,8 @@ data class ChatUiState( val steeringMode: String = ChatViewModel.DELIVERY_MODE_ALL, val followUpMode: String = ChatViewModel.DELIVERY_MODE_ALL, val pendingQueueItems: List = emptyList(), + val isSyncingSession: Boolean = false, + val sessionCoherencyWarning: String? = null, // Bash dialog state val isBashDialogVisible: Boolean = false, val bashCommand: String = "", @@ -2266,6 +2343,20 @@ private data class HistoryMessageWindow( val absoluteOffset: Int, ) +private fun historyWindowSignature(messages: List): String { + if (messages.isEmpty()) return "empty" + + val marker = + messages + .joinToString(separator = "|") { message -> + val role = message.stringField("role").orEmpty() + val entryId = message.stringField("entryId").orEmpty() + "$role:$entryId:${message.toString().hashCode()}" + } + + return "${messages.size}:$marker" +} + private fun extractHistoryMessageWindow(data: JsonObject?): HistoryMessageWindow { val rawMessages = runCatching { data?.get("messages")?.jsonArray }.getOrNull() ?: JsonArray(emptyList()) val startIndex = (rawMessages.size - HISTORY_WINDOW_MAX_ITEMS).coerceAtLeast(0) diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt index f8817be..3ee630a 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt @@ -153,6 +153,7 @@ private data class ChatCallbacks( val onHideModelPicker: () -> Unit, val onModelsQueryChanged: (String) -> Unit, val onSelectModel: (AvailableModel) -> Unit, + val onSyncNow: () -> Unit, // Tree navigation callbacks val onShowTreeSheet: () -> Unit, val onHideTreeSheet: () -> Unit, @@ -230,6 +231,7 @@ fun ChatRoute(sessionController: SessionController) { onHideModelPicker = chatViewModel::hideModelPicker, onModelsQueryChanged = chatViewModel::onModelsQueryChanged, onSelectModel = chatViewModel::selectModel, + onSyncNow = chatViewModel::syncNow, onShowTreeSheet = chatViewModel::showTreeSheet, onHideTreeSheet = chatViewModel::hideTreeSheet, onForkFromTreeEntry = chatViewModel::forkFromTreeEntry, @@ -337,6 +339,7 @@ private fun ChatScreen( ) } +@Suppress("LongMethod") @Composable private fun ChatScreenContent( state: ChatUiState, @@ -350,6 +353,8 @@ private fun ChatScreenContent( isStreaming = state.isStreaming, isRetrying = state.isRetrying, timeline = state.timeline, + isSyncingSession = state.isSyncingSession, + sessionCoherencyWarning = state.sessionCoherencyWarning, extensionTitle = state.extensionTitle, connectionState = state.connectionState, currentModel = state.currentModel, @@ -413,6 +418,8 @@ private fun ChatHeader( isStreaming: Boolean, isRetrying: Boolean, timeline: List, + isSyncingSession: Boolean, + sessionCoherencyWarning: String?, extensionTitle: String?, connectionState: com.ayagmar.pimobile.corenet.ConnectionState, currentModel: ModelInfo?, @@ -501,6 +508,20 @@ private fun ChatHeader( // Action buttons Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (isSyncingSession) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + ) + } else { + IconButton(onClick = callbacks.onSyncNow) { + Icon( + imageVector = Icons.Default.Refresh, + contentDescription = "Sync now", + ) + } + } + if (!isCompact) { IconButton(onClick = callbacks.onShowTreeSheet) { Icon( @@ -531,6 +552,14 @@ private fun ChatHeader( ) } + sessionCoherencyWarning?.let { warning -> + Text( + text = warning, + color = MaterialTheme.colorScheme.tertiary, + style = MaterialTheme.typography.bodySmall, + ) + } + // Compact model/thinking controls ModelThinkingControls( currentModel = currentModel, diff --git a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt index 909a811..ee0198f 100644 --- a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt @@ -574,6 +574,49 @@ class ChatViewModelThinkingExpansionTest { assertEquals(0, cappedWindowState.hiddenHistoryCount) } + @Test + fun syncNowFlagsPotentialCrossDeviceEditsWhenHistoryChanges() = + runTest(dispatcher) { + val controller = FakeSessionController() + controller.messagesPayload = historyWithMessageTexts(listOf("baseline")) + val viewModel = ChatViewModel(sessionController = controller) + dispatcher.scheduler.advanceUntilIdle() + awaitInitialLoad(viewModel) + + controller.messagesPayload = historyWithMessageTexts(listOf("baseline", "external-change")) + viewModel.syncNow() + dispatcher.scheduler.advanceUntilIdle() + + waitForState(viewModel) { state -> !state.isSyncingSession } + val state = viewModel.uiState.value + assertEquals( + "Potential cross-device session edits detected. Use Sync now before continuing.", + state.sessionCoherencyWarning, + ) + } + + @Test + fun syncNowClearsCoherencyWarningWhenNoExternalDiffIsFound() = + runTest(dispatcher) { + val controller = FakeSessionController() + controller.messagesPayload = historyWithMessageTexts(listOf("unchanged")) + val viewModel = ChatViewModel(sessionController = controller) + dispatcher.scheduler.advanceUntilIdle() + awaitInitialLoad(viewModel) + + controller.messagesPayload = historyWithMessageTexts(listOf("unchanged", "changed")) + viewModel.syncNow() + dispatcher.scheduler.advanceUntilIdle() + waitForState(viewModel) { state -> !state.isSyncingSession && state.sessionCoherencyWarning != null } + + controller.messagesPayload = historyWithMessageTexts(listOf("unchanged", "changed")) + viewModel.syncNow() + dispatcher.scheduler.advanceUntilIdle() + + waitForState(viewModel) { state -> !state.isSyncingSession } + assertEquals(null, viewModel.uiState.value.sessionCoherencyWarning) + } + @Test fun jumpAndContinueUsesInPlaceTreeNavigationResult() = runTest(dispatcher) { @@ -822,6 +865,24 @@ class ChatViewModelThinkingExpansionTest { ) } + private fun historyWithMessageTexts(messages: List): JsonObject = + buildJsonObject { + put( + "messages", + buildJsonArray { + messages.forEachIndexed { index, text -> + add( + buildJsonObject { + put("role", "user") + put("entryId", "entry-$index") + put("content", text) + }, + ) + } + }, + ) + } + companion object { private const val INITIAL_LOAD_WAIT_ATTEMPTS = 200 private const val INITIAL_LOAD_WAIT_STEP_MS = 5L From ce0a941f32b12603b3200c0acb0d99f0c33b0133 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Mon, 16 Feb 2026 20:51:08 +0000 Subject: [PATCH 04/32] feat(extension): restore setStatus visibility with compact strip --- .../ayagmar/pimobile/chat/ChatViewModel.kt | 19 ++++++- .../ayagmar/pimobile/ui/chat/ChatScreen.kt | 54 +++++++++++++++++++ .../chat/ChatViewModelWorkflowCommandTest.kt | 50 +++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt index e4bcdda..fc9195c 100644 --- a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt +++ b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt @@ -567,7 +567,13 @@ class ChatViewModel( visibleTimelineSize = 0 pendingLocalUserIds.clear() resetHistoryWindow() - _uiState.update { it.copy(sessionCoherencyWarning = null, isSyncingSession = false) } + _uiState.update { + it.copy( + sessionCoherencyWarning = null, + isSyncingSession = false, + extensionStatuses = emptyMap(), + ) + } loadInitialMessages(reason = TimelineReloadReason.SESSION_CHANGED) } } @@ -702,7 +708,15 @@ class ChatViewModel( return } - // Ignore non-workflow status messages to avoid UI clutter/noise. + _uiState.update { state -> + val updatedStatuses = state.extensionStatuses.toMutableMap() + if (text == null) { + updatedStatuses.remove(key) + } else { + updatedStatuses[key] = text + } + state.copy(extensionStatuses = updatedStatuses) + } } private fun handleInternalWorkflowStatus(payloadText: String) { @@ -2152,6 +2166,7 @@ data class ChatUiState( val activeExtensionRequest: ExtensionUiRequest? = null, val notifications: List = emptyList(), val extensionWidgets: Map = emptyMap(), + val extensionStatuses: Map = emptyMap(), val extensionTitle: String? = null, val isCommandPaletteVisible: Boolean = false, val isCommandPaletteAutoOpened: Boolean = false, diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt index 3ee630a..950a0c2 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt @@ -386,6 +386,8 @@ private fun ChatScreenContent( placement = "belowEditor", ) + ExtensionStatusStrip(statuses = state.extensionStatuses) + PromptControls( isStreaming = state.isStreaming, isRetrying = state.isRetrying, @@ -1951,6 +1953,57 @@ private fun ModelThinkingControls( } } +@Composable +private fun ExtensionStatusStrip(statuses: Map) { + if (statuses.isEmpty()) return + + var expanded by rememberSaveable { mutableStateOf(false) } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Extension status (${statuses.size})", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + TextButton(onClick = { expanded = !expanded }) { + Text(if (expanded) "Hide" else "Show") + } + } + + val orderedStatuses = statuses.toSortedMap() + val visibleStatuses = if (expanded) orderedStatuses.entries else orderedStatuses.entries.take(2) + visibleStatuses.forEach { (key, value) -> + Text( + text = "$key: ${value.take(STATUS_VALUE_MAX_LENGTH)}", + style = MaterialTheme.typography.bodySmall, + maxLines = if (expanded) 4 else 1, + overflow = TextOverflow.Ellipsis, + ) + } + + if (!expanded && statuses.size > 2) { + Text( + text = "+${statuses.size - 2} more", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @Composable private fun ExtensionWidgets( widgets: Map, @@ -1992,6 +2045,7 @@ private const val MAX_INLINE_USER_IMAGE_PREVIEWS = 4 private const val USER_IMAGE_PREVIEW_SIZE_DP = 56 private const val AUTO_SCROLL_BOTTOM_THRESHOLD_ITEMS = 2 private const val TOOL_HIGHLIGHT_MAX_LENGTH = 1_000 +private const val STATUS_VALUE_MAX_LENGTH = 180 private const val RUN_PROGRESS_TICK_MS = 1_000L private const val STREAMING_FRAME_LOG_TAG = "StreamingFrameMetrics" private val THINKING_LEVEL_OPTIONS = listOf("off", "minimal", "low", "medium", "high", "xhigh") diff --git a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelWorkflowCommandTest.kt b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelWorkflowCommandTest.kt index 956526c..c23e1ea 100644 --- a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelWorkflowCommandTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelWorkflowCommandTest.kt @@ -12,6 +12,7 @@ import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -152,6 +153,55 @@ class ChatViewModelWorkflowCommandTest { dispatcher.scheduler.advanceUntilIdle() assertTrue(viewModel.uiState.value.isStatsSheetVisible) + assertTrue(viewModel.uiState.value.extensionStatuses.isEmpty()) + } + + @Test + fun nonWorkflowStatusIsStoredUpdatedAndCleared() = + runTest(dispatcher) { + val controller = FakeSessionController() + val viewModel = ChatViewModel(sessionController = controller) + dispatcher.scheduler.advanceUntilIdle() + awaitInitialLoad(viewModel) + + controller.emitEvent( + ExtensionUiRequestEvent( + type = "extension_ui_request", + id = "req-1", + method = "setStatus", + statusKey = "ext.status", + statusText = "syncing", + ), + ) + dispatcher.scheduler.advanceUntilIdle() + + assertEquals("syncing", viewModel.uiState.value.extensionStatuses["ext.status"]) + + controller.emitEvent( + ExtensionUiRequestEvent( + type = "extension_ui_request", + id = "req-2", + method = "setStatus", + statusKey = "ext.status", + statusText = "idle", + ), + ) + dispatcher.scheduler.advanceUntilIdle() + + assertEquals("idle", viewModel.uiState.value.extensionStatuses["ext.status"]) + + controller.emitEvent( + ExtensionUiRequestEvent( + type = "extension_ui_request", + id = "req-3", + method = "setStatus", + statusKey = "ext.status", + statusText = null, + ), + ) + dispatcher.scheduler.advanceUntilIdle() + + assertNull(viewModel.uiState.value.extensionStatuses["ext.status"]) } private fun awaitInitialLoad(viewModel: ChatViewModel) { From a7dc68eee69ea5b0d19ee36d03342255e50b1d94 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Mon, 16 Feb 2026 20:53:16 +0000 Subject: [PATCH 05/32] refactor(ui): move secondary chat actions into overflow menu --- .../ayagmar/pimobile/ui/chat/ChatScreen.kt | 59 ++++++++++++------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt index 950a0c2..08c3de7 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt @@ -38,7 +38,6 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.AttachFile -import androidx.compose.material.icons.filled.BarChart import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Description @@ -47,6 +46,7 @@ import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Refresh import androidx.compose.material.icons.filled.Search @@ -431,6 +431,7 @@ private fun ChatHeader( ) { val isCompact = isStreaming var runStartedAtMs by remember { mutableStateOf(null) } + var showSecondaryActionsMenu by remember { mutableStateOf(false) } LaunchedEffect(isStreaming) { if (isStreaming) { @@ -509,7 +510,10 @@ private fun ChatHeader( } // Action buttons - Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { if (isSyncingSession) { CircularProgressIndicator( modifier = Modifier.size(18.dp), @@ -524,25 +528,38 @@ private fun ChatHeader( } } - if (!isCompact) { - IconButton(onClick = callbacks.onShowTreeSheet) { - Icon( - imageVector = Icons.Default.Folder, - contentDescription = "Tree", - ) - } - IconButton(onClick = callbacks.onShowBashDialog) { - Icon( - imageVector = Icons.Default.Terminal, - contentDescription = "Bash", - ) - } - IconButton(onClick = callbacks.onShowStatsSheet) { - Icon( - imageVector = Icons.Default.BarChart, - contentDescription = "Stats", - ) - } + IconButton(onClick = { showSecondaryActionsMenu = true }) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "More actions", + ) + } + + DropdownMenu( + expanded = showSecondaryActionsMenu, + onDismissRequest = { showSecondaryActionsMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Tree") }, + onClick = { + showSecondaryActionsMenu = false + callbacks.onShowTreeSheet() + }, + ) + DropdownMenuItem( + text = { Text("Bash") }, + onClick = { + showSecondaryActionsMenu = false + callbacks.onShowBashDialog() + }, + ) + DropdownMenuItem( + text = { Text("Stats") }, + onClick = { + showSecondaryActionsMenu = false + callbacks.onShowStatsSheet() + }, + ) } } } From dabd378686fff82a60b97df2f6a17e20e3e5d0d8 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Mon, 16 Feb 2026 21:04:10 +0000 Subject: [PATCH 06/32] test(core-rpc): cover thinking_end parser payload shapes --- .../pimobile/corerpc/RpcMessageParserTest.kt | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/core-rpc/src/test/kotlin/com/ayagmar/pimobile/corerpc/RpcMessageParserTest.kt b/core-rpc/src/test/kotlin/com/ayagmar/pimobile/corerpc/RpcMessageParserTest.kt index b47a243..39fbf10 100644 --- a/core-rpc/src/test/kotlin/com/ayagmar/pimobile/corerpc/RpcMessageParserTest.kt +++ b/core-rpc/src/test/kotlin/com/ayagmar/pimobile/corerpc/RpcMessageParserTest.kt @@ -82,6 +82,48 @@ class RpcMessageParserTest { assertEquals("Hello", deltaEvent.delta) } + @Test + fun `parse thinking_end payload with content field`() { + val line = + """ + { + "type":"message_update", + "assistantMessageEvent":{ + "type":"thinking_end", + "contentIndex":0, + "content":"reasoning from content" + } + } + """.trimIndent() + + val event = assertIs(parser.parse(line)) + val assistantEvent = assertNotNull(event.assistantMessageEvent) + assertEquals("thinking_end", assistantEvent.type) + assertEquals("reasoning from content", assistantEvent.content) + assertNull(assistantEvent.thinking) + } + + @Test + fun `parse thinking_end payload with legacy thinking field`() { + val line = + """ + { + "type":"message_update", + "assistantMessageEvent":{ + "type":"thinking_end", + "contentIndex":0, + "thinking":"reasoning from thinking" + } + } + """.trimIndent() + + val event = assertIs(parser.parse(line)) + val assistantEvent = assertNotNull(event.assistantMessageEvent) + assertEquals("thinking_end", assistantEvent.type) + assertEquals("reasoning from thinking", assistantEvent.thinking) + assertNull(assistantEvent.content) + } + @Test fun `parse message lifecycle events`() { val startLine = From 0dc7dad0d364d684a792a06961ed0d141d4c7cbd Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Mon, 16 Feb 2026 21:29:44 +0000 Subject: [PATCH 07/32] feat(sync): add bridge freshness checks and smart session refresh --- .../ayagmar/pimobile/chat/ChatViewModel.kt | 164 +++++++++++++++++- .../pimobile/sessions/RpcSessionController.kt | 55 ++++++ .../pimobile/sessions/SessionController.kt | 24 +++ .../sessions/RpcSessionControllerTest.kt | 44 +++++ .../testutil/FakeSessionController.kt | 32 +++- bridge/src/process-manager.ts | 12 ++ bridge/src/server.ts | 51 ++++++ bridge/src/session-indexer.ts | 130 ++++++++++++++ bridge/test/process-manager.test.ts | 38 ++++ bridge/test/server.test.ts | 145 +++++++++++++++- bridge/test/session-indexer.test.ts | 58 +++++++ docs/bridge-protocol.md | 36 ++++ 12 files changed, 780 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt index fc9195c..7a8cf98 100644 --- a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt +++ b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt @@ -28,6 +28,8 @@ import com.ayagmar.pimobile.corerpc.UiUpdateThrottler import com.ayagmar.pimobile.perf.PerformanceMetrics import com.ayagmar.pimobile.sessions.ModelInfo import com.ayagmar.pimobile.sessions.SessionController +import com.ayagmar.pimobile.sessions.SessionFreshnessFingerprint +import com.ayagmar.pimobile.sessions.SessionFreshnessSnapshot import com.ayagmar.pimobile.sessions.SessionTreeSnapshot import com.ayagmar.pimobile.sessions.SlashCommandInfo import kotlinx.coroutines.Dispatchers @@ -38,6 +40,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json @@ -74,6 +77,11 @@ class ChatViewModel( private var streamingDiagnostics = StreamingDeltaDiagnosticsCounters() private var streamingDiagnosticsRunActive = false private var streamingDiagnosticsRunStartedAtMs: Long = 0 + private var sessionFreshnessMonitorJob: Job? = null + private var latestSessionPath: String? = null + private var lastKnownSessionFreshness: SessionFreshnessFingerprint? = null + private var localSessionMutationGraceUntilMs: Long = 0 + private var isSessionFreshnessUnsupported = false val uiState: StateFlow = _uiState.asStateFlow() @@ -134,6 +142,7 @@ class ChatViewModel( // Record prompt send for TTFT tracking recordMetricsSafely { PerformanceMetrics.recordPromptSend() } hasRecordedFirstToken = false + markLocalSessionMutationExpected() val optimisticUserId = "$LOCAL_USER_ITEM_PREFIX${UUID.randomUUID()}" upsertTimelineItem( @@ -193,6 +202,7 @@ class ChatViewModel( viewModelScope.launch { _uiState.update { it.copy(errorMessage = null) } + markLocalSessionMutationExpected() val queueItemId = maybeTrackStreamingQueueItem(PendingQueueType.STEER, trimmedMessage) val result = sessionController.steer(trimmedMessage) if (result.isFailure) { @@ -208,6 +218,7 @@ class ChatViewModel( viewModelScope.launch { _uiState.update { it.copy(errorMessage = null) } + markLocalSessionMutationExpected() val queueItemId = maybeTrackStreamingQueueItem(PendingQueueType.FOLLOW_UP, trimmedMessage) val result = sessionController.followUp(trimmedMessage) if (result.isFailure) { @@ -483,6 +494,13 @@ class ChatViewModel( _uiState.update { current -> current.copy(connectionState = state) } + + if (state == ConnectionState.CONNECTED && previousState != ConnectionState.CONNECTED) { + startSessionFreshnessMonitor() + } else if (state != ConnectionState.CONNECTED && previousState == ConnectionState.CONNECTED) { + stopSessionFreshnessMonitor() + } + // Reload messages when connection becomes active and timeline is empty if (state == ConnectionState.CONNECTED && previousState != ConnectionState.CONNECTED && timelineEmpty) { loadInitialMessages(reason = TimelineReloadReason.CONNECTION_RECOVERY) @@ -491,6 +509,118 @@ class ChatViewModel( } } + private fun startSessionFreshnessMonitor() { + if (sessionFreshnessMonitorJob?.isActive == true || isSessionFreshnessUnsupported) { + return + } + + sessionFreshnessMonitorJob = + viewModelScope.launch { + while (isActive) { + refreshSessionFreshness(trigger = FreshnessCheckTrigger.POLL) + delay(SESSION_FRESHNESS_POLL_INTERVAL_MS) + } + } + } + + private fun stopSessionFreshnessMonitor() { + sessionFreshnessMonitorJob?.cancel() + sessionFreshnessMonitorJob = null + } + + private suspend fun refreshSessionFreshness(trigger: FreshnessCheckTrigger) { + if (isSessionFreshnessUnsupported) { + return + } + + val sessionPath = latestSessionPath + if (sessionPath == null) { + return + } + + val freshnessResult = sessionController.getSessionFreshness(sessionPath) + val freshness = freshnessResult.getOrNull() + + if (freshness == null) { + val errorMessage = freshnessResult.exceptionOrNull()?.message.orEmpty() + if (errorMessage.contains("unsupported_bridge_message", ignoreCase = true)) { + isSessionFreshnessUnsupported = true + stopSessionFreshnessMonitor() + } + } else { + latestSessionPath = freshness.sessionPath + val previous = lastKnownSessionFreshness + + when { + previous == null -> { + lastKnownSessionFreshness = freshness.fingerprint + } + + previous == freshness.fingerprint -> { + // No-op + } + + isWithinLocalMutationGraceWindow() -> { + lastKnownSessionFreshness = freshness.fingerprint + } + + else -> { + handleSessionFreshnessMismatch(freshness, trigger) + lastKnownSessionFreshness = freshness.fingerprint + } + } + } + } + + private fun handleSessionFreshnessMismatch( + freshness: SessionFreshnessSnapshot, + trigger: FreshnessCheckTrigger, + ) { + val state = _uiState.value + val isEditing = state.inputText.isNotBlank() || state.pendingImages.isNotEmpty() + val isBusy = state.isStreaming || isEditing || state.isRetrying || state.isSyncingSession + + if (!isBusy && initialLoadJob?.isActive != true) { + loadInitialMessages(reason = TimelineReloadReason.AUTO_FRESHNESS_REFRESH) + return + } + + _uiState.update { + it.copy(sessionCoherencyWarning = SESSION_COHERENCY_WARNING_MESSAGE) + } + + if (trigger == FreshnessCheckTrigger.POLL) { + addSystemNotification( + message = buildSessionFreshnessWarningMessage(freshness), + type = "warning", + ) + } + } + + private fun buildSessionFreshnessWarningMessage(freshness: SessionFreshnessSnapshot): String { + val lock = freshness.lock + val ownerHint = + when { + !lock.sessionOwnerClientId.isNullOrBlank() && !lock.isCurrentClientSessionOwner -> + " (owner=${lock.sessionOwnerClientId})" + + !lock.cwdOwnerClientId.isNullOrBlank() && !lock.isCurrentClientCwdOwner -> + " (owner=${lock.cwdOwnerClientId})" + + else -> "" + } + + return "Potential cross-device edits detected$ownerHint. Use Sync now before continuing." + } + + private fun markLocalSessionMutationExpected() { + localSessionMutationGraceUntilMs = System.currentTimeMillis() + LOCAL_SESSION_MUTATION_GRACE_MS + } + + private fun isWithinLocalMutationGraceWindow(): Boolean { + return System.currentTimeMillis() <= localSessionMutationGraceUntilMs + } + private fun observeStreamingState() { viewModelScope.launch { sessionController.isStreaming.collect { isStreaming -> @@ -567,6 +697,9 @@ class ChatViewModel( visibleTimelineSize = 0 pendingLocalUserIds.clear() resetHistoryWindow() + latestSessionPath = null + lastKnownSessionFreshness = null + localSessionMutationGraceUntilMs = 0 _uiState.update { it.copy( sessionCoherencyWarning = null, @@ -1189,7 +1322,8 @@ class ChatViewModel( val previousHistorySignature = if ( reason == TimelineReloadReason.MANUAL_SYNC || - reason == TimelineReloadReason.CONNECTION_RECOVERY + reason == TimelineReloadReason.CONNECTION_RECOVERY || + reason == TimelineReloadReason.AUTO_FRESHNESS_REFRESH ) { historyWindowSignature(historyWindowMessages) } else { @@ -1214,6 +1348,7 @@ class ChatViewModel( isStreaming = stateData?.booleanField("isStreaming") ?: false, steeringMode = stateData.deliveryModeField("steeringMode", "steering_mode"), followUpMode = stateData.deliveryModeField("followUpMode", "follow_up_mode"), + sessionPath = stateData?.stringField("sessionFile"), ) _uiState.update { state -> @@ -1232,6 +1367,9 @@ class ChatViewModel( } } + latestSessionPath = metadata.sessionPath ?: latestSessionPath + refreshSessionFreshness(trigger = FreshnessCheckTrigger.POST_LOAD) + val refreshedHistorySignature = historyWindowSignature(historyWindowMessages) val hasPotentialExternalChanges = messagesResult.isSuccess && @@ -1261,6 +1399,20 @@ class ChatViewModel( val type = if (hasPotentialExternalChanges) "warning" else "info" addSystemNotification(message = message, type = type) } + } else if (reason == TimelineReloadReason.AUTO_FRESHNESS_REFRESH) { + _uiState.update { + it.copy(sessionCoherencyWarning = null) + } + + if (messagesResult.isSuccess) { + val message = + if (hasPotentialExternalChanges) { + "Session changed externally. Timeline auto-refreshed." + } else { + "Session freshness changed. Timeline refreshed." + } + addSystemNotification(message = message, type = "info") + } } else if (hasPotentialExternalChanges) { _uiState.update { it.copy(sessionCoherencyWarning = SESSION_COHERENCY_WARNING_MESSAGE) @@ -2056,10 +2208,17 @@ class ChatViewModel( SESSION_CHANGED, TREE_NAVIGATION, MANUAL_SYNC, + AUTO_FRESHNESS_REFRESH, + } + + private enum class FreshnessCheckTrigger { + POLL, + POST_LOAD, } override fun onCleared() { initialLoadJob?.cancel() + stopSessionFreshnessMonitor() logThinkingDiagnostics(reason = "viewmodel_cleared") logStreamingDiagnostics(reason = "viewmodel_cleared") resetStreamingUpdateState() @@ -2148,6 +2307,8 @@ class ChatViewModel( private const val STREAMING_DIAGNOSTICS_LOG_TAG = "StreamingDiagnostics" private const val SESSION_COHERENCY_WARNING_MESSAGE = "Potential cross-device session edits detected. Use Sync now before continuing." + private const val SESSION_FRESHNESS_POLL_INTERVAL_MS = 4_000L + private const val LOCAL_SESSION_MUTATION_GRACE_MS = 90_000L } } @@ -2329,6 +2490,7 @@ private data class InitialLoadMetadata( val isStreaming: Boolean, val steeringMode: String, val followUpMode: String, + val sessionPath: String?, ) private data class ThinkingDiagnosticsCounters( diff --git a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt index 72b0d37..15fc0ba 100644 --- a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt @@ -287,6 +287,27 @@ class RpcSessionController( } } + override suspend fun getSessionFreshness(sessionPath: String): Result { + return mutex.withLock { + runCatching { + val connection = ensureActiveConnection() + val bridgePayload = + buildJsonObject { + put("type", BRIDGE_GET_SESSION_FRESHNESS_TYPE) + put("sessionPath", sessionPath) + } + + val bridgeResponse = + connection.requestBridge( + payload = bridgePayload, + expectedType = BRIDGE_SESSION_FRESHNESS_TYPE, + ) + + parseSessionFreshnessSnapshot(bridgeResponse.payload) + } + } + } + override suspend fun navigateTreeToEntry(entryId: String): Result { return mutex.withLock { runCatching { @@ -924,6 +945,8 @@ class RpcSessionController( private const val SET_FOLLOW_UP_MODE_COMMAND = "set_follow_up_mode" private const val BRIDGE_GET_SESSION_TREE_TYPE = "bridge_get_session_tree" private const val BRIDGE_SESSION_TREE_TYPE = "bridge_session_tree" + private const val BRIDGE_GET_SESSION_FRESHNESS_TYPE = "bridge_get_session_freshness" + private const val BRIDGE_SESSION_FRESHNESS_TYPE = "bridge_session_freshness" private const val BRIDGE_NAVIGATE_TREE_TYPE = "bridge_navigate_tree" private const val BRIDGE_TREE_NAVIGATION_RESULT_TYPE = "bridge_tree_navigation_result" private const val EVENT_BUFFER_CAPACITY = 256 @@ -1028,6 +1051,38 @@ private fun parseSessionTreeSnapshot(payload: JsonObject): SessionTreeSnapshot { ) } +private fun parseSessionFreshnessSnapshot(payload: JsonObject): SessionFreshnessSnapshot { + val sessionPath = payload.stringField("sessionPath") ?: error("Session freshness response missing sessionPath") + val cwd = payload.stringField("cwd") ?: error("Session freshness response missing cwd") + + val fingerprintPayload = runCatching { payload["fingerprint"]?.jsonObject }.getOrNull() + val lockPayload = runCatching { payload["lock"]?.jsonObject }.getOrNull() + + val fingerprint = + SessionFreshnessFingerprint( + mtimeMs = fingerprintPayload.longField("mtimeMs") ?: 0L, + sizeBytes = fingerprintPayload.longField("sizeBytes") ?: 0L, + entryCount = fingerprintPayload.intField("entryCount") ?: 0, + lastEntryId = fingerprintPayload.stringField("lastEntryId"), + lastEntriesHash = fingerprintPayload.stringField("lastEntriesHash"), + ) + + val lock = + SessionLockMetadata( + cwdOwnerClientId = lockPayload.stringField("cwdOwnerClientId"), + sessionOwnerClientId = lockPayload.stringField("sessionOwnerClientId"), + isCurrentClientCwdOwner = lockPayload.booleanField("isCurrentClientCwdOwner") ?: false, + isCurrentClientSessionOwner = lockPayload.booleanField("isCurrentClientSessionOwner") ?: false, + ) + + return SessionFreshnessSnapshot( + sessionPath = sessionPath, + cwd = cwd, + fingerprint = fingerprint, + lock = lock, + ) +} + private fun parseTreeNavigationResult(payload: JsonObject): TreeNavigationResult { return TreeNavigationResult( cancelled = payload.booleanField("cancelled") ?: false, diff --git a/app/src/main/java/com/ayagmar/pimobile/sessions/SessionController.kt b/app/src/main/java/com/ayagmar/pimobile/sessions/SessionController.kt index 7f196d8..811d126 100644 --- a/app/src/main/java/com/ayagmar/pimobile/sessions/SessionController.kt +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/SessionController.kt @@ -77,6 +77,8 @@ interface SessionController { filter: String? = null, ): Result + suspend fun getSessionFreshness(sessionPath: String): Result + suspend fun navigateTreeToEntry(entryId: String): Result suspend fun cycleModel(): Result @@ -139,6 +141,28 @@ data class SessionTreeSnapshot( val entries: List, ) +data class SessionFreshnessSnapshot( + val sessionPath: String, + val cwd: String, + val fingerprint: SessionFreshnessFingerprint, + val lock: SessionLockMetadata, +) + +data class SessionFreshnessFingerprint( + val mtimeMs: Long, + val sizeBytes: Long, + val entryCount: Int, + val lastEntryId: String?, + val lastEntriesHash: String?, +) + +data class SessionLockMetadata( + val cwdOwnerClientId: String?, + val sessionOwnerClientId: String?, + val isCurrentClientCwdOwner: Boolean, + val isCurrentClientSessionOwner: Boolean, +) + data class SessionTreeEntry( val entryId: String, val parentId: String?, diff --git a/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt b/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt index bf9613c..dc108c6 100644 --- a/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt @@ -228,6 +228,50 @@ class RpcSessionControllerTest { assertEquals(true, tree.entries[1].isBookmarked) } + @Test + fun parseSessionFreshnessSnapshotMapsBridgePayload() { + val snapshot = + invokeParser( + functionName = "parseSessionFreshnessSnapshot", + data = + buildJsonObject { + put("sessionPath", "/tmp/session-tree.jsonl") + put("cwd", "/tmp/project") + put( + "fingerprint", + buildJsonObject { + put("mtimeMs", 1730000000000) + put("sizeBytes", 2048) + put("entryCount", 42) + put("lastEntryId", "m42") + put("lastEntriesHash", "abc123") + }, + ) + put( + "lock", + buildJsonObject { + put("cwdOwnerClientId", "client-a") + put("sessionOwnerClientId", "client-b") + put("isCurrentClientCwdOwner", false) + put("isCurrentClientSessionOwner", false) + }, + ) + }, + ) + + assertEquals("/tmp/session-tree.jsonl", snapshot.sessionPath) + assertEquals("/tmp/project", snapshot.cwd) + assertEquals(1730000000000L, snapshot.fingerprint.mtimeMs) + assertEquals(2048L, snapshot.fingerprint.sizeBytes) + assertEquals(42, snapshot.fingerprint.entryCount) + assertEquals("m42", snapshot.fingerprint.lastEntryId) + assertEquals("abc123", snapshot.fingerprint.lastEntriesHash) + assertEquals("client-a", snapshot.lock.cwdOwnerClientId) + assertEquals("client-b", snapshot.lock.sessionOwnerClientId) + assertEquals(false, snapshot.lock.isCurrentClientCwdOwner) + assertEquals(false, snapshot.lock.isCurrentClientSessionOwner) + } + @Test fun parseForkableMessagesUsesTextFieldWithPreviewFallback() { val messages = diff --git a/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt b/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt index 8c229b5..0fae233 100644 --- a/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt +++ b/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt @@ -12,6 +12,7 @@ import com.ayagmar.pimobile.hosts.HostProfile import com.ayagmar.pimobile.sessions.ForkableMessage import com.ayagmar.pimobile.sessions.ModelInfo import com.ayagmar.pimobile.sessions.SessionController +import com.ayagmar.pimobile.sessions.SessionFreshnessSnapshot import com.ayagmar.pimobile.sessions.SessionTreeSnapshot import com.ayagmar.pimobile.sessions.SlashCommandInfo import com.ayagmar.pimobile.sessions.TransportPreference @@ -26,14 +27,21 @@ import kotlinx.serialization.json.JsonObject class FakeSessionController : SessionController { private val events = MutableSharedFlow(extraBufferCapacity = 16) private val streamingState = MutableStateFlow(false) + private val connectionStateFlow = MutableStateFlow(ConnectionState.DISCONNECTED) private val _sessionChanged = MutableSharedFlow(extraBufferCapacity = 16) var availableCommands: List = emptyList() var getCommandsCallCount: Int = 0 var sendPromptCallCount: Int = 0 + var getMessagesCallCount: Int = 0 + var getStateCallCount: Int = 0 + var getSessionFreshnessCallCount: Int = 0 var lastPromptMessage: String? = null + var lastFreshnessSessionPath: String? = null var sendPromptResult: Result = Result.success(Unit) var messagesPayload: JsonObject? = null + var sessionFreshnessResult: Result = + Result.failure(IllegalStateException("Not used")) var treeNavigationResult: Result = Result.success( TreeNavigationResult( @@ -52,7 +60,7 @@ class FakeSessionController : SessionController { var lastTransportPreference: TransportPreference = TransportPreference.AUTO override val rpcEvents: SharedFlow = events - override val connectionState: StateFlow = MutableStateFlow(ConnectionState.DISCONNECTED) + override val connectionState: StateFlow = connectionStateFlow override val isStreaming: StateFlow = streamingState override val sessionChanged: SharedFlow = _sessionChanged @@ -68,6 +76,10 @@ class FakeSessionController : SessionController { streamingState.value = isStreaming } + fun setConnectionState(state: ConnectionState) { + connectionStateFlow.value = state + } + override fun setTransportPreference(preference: TransportPreference) { lastTransportPreference = preference } @@ -90,8 +102,9 @@ class FakeSessionController : SessionController { session: SessionRecord, ): Result = Result.success(null) - override suspend fun getMessages(): Result = - Result.success( + override suspend fun getMessages(): Result { + getMessagesCallCount += 1 + return Result.success( RpcResponse( type = "response", command = "get_messages", @@ -99,15 +112,18 @@ class FakeSessionController : SessionController { data = messagesPayload, ), ) + } - override suspend fun getState(): Result = - Result.success( + override suspend fun getState(): Result { + getStateCallCount += 1 + return Result.success( RpcResponse( type = "response", command = "get_state", success = true, ), ) + } override suspend fun sendPrompt( message: String, @@ -139,6 +155,12 @@ class FakeSessionController : SessionController { filter: String?, ): Result = Result.failure(IllegalStateException("Not used")) + override suspend fun getSessionFreshness(sessionPath: String): Result { + getSessionFreshnessCallCount += 1 + lastFreshnessSessionPath = sessionPath + return sessionFreshnessResult + } + override suspend fun navigateTreeToEntry(entryId: String): Result { lastNavigatedEntryId = entryId return treeNavigationResult diff --git a/bridge/src/process-manager.ts b/bridge/src/process-manager.ts index 3c2ca59..fd00774 100644 --- a/bridge/src/process-manager.ts +++ b/bridge/src/process-manager.ts @@ -24,12 +24,18 @@ export interface ProcessManagerStats { lockedSessionCount: number; } +export interface ControlSnapshot { + cwdOwnerClientId?: string; + sessionOwnerClientId?: string; +} + export interface PiProcessManager { setMessageHandler(handler: (event: ProcessManagerEvent) => void): void; getOrStart(cwd: string): PiRpcForwarder; sendRpc(cwd: string, payload: Record): void; acquireControl(request: AcquireControlRequest): AcquireControlResult; hasControl(clientId: string, cwd: string, sessionPath?: string): boolean; + getControlSnapshot(cwd: string, sessionPath?: string): ControlSnapshot; releaseControl(clientId: string, cwd: string, sessionPath?: string): void; releaseClient(clientId: string): void; getStats(): ProcessManagerStats; @@ -172,6 +178,12 @@ export function createPiProcessManager(options: ProcessManagerOptions): PiProces return true; }, + getControlSnapshot(cwd: string, sessionPath?: string): ControlSnapshot { + return { + cwdOwnerClientId: lockByCwd.get(cwd), + sessionOwnerClientId: sessionPath ? lockBySession.get(sessionPath)?.clientId : undefined, + }; + }, releaseControl(clientId: string, cwd: string, sessionPath?: string): void { if (lockByCwd.get(cwd) === clientId) { lockByCwd.delete(cwd); diff --git a/bridge/src/server.ts b/bridge/src/server.ts index c2354d3..3050e78 100644 --- a/bridge/src/server.ts +++ b/bridge/src/server.ts @@ -72,6 +72,8 @@ const TREE_NAVIGATION_COMMAND = "pi-mobile-tree"; const TREE_NAVIGATION_STATUS_PREFIX = "pi_mobile_tree_result:"; const BRIDGE_NAVIGATE_TREE_TYPE = "bridge_navigate_tree"; const BRIDGE_TREE_NAVIGATION_RESULT_TYPE = "bridge_tree_navigation_result"; +const BRIDGE_GET_SESSION_FRESHNESS_TYPE = "bridge_get_session_freshness"; +const BRIDGE_SESSION_FRESHNESS_TYPE = "bridge_session_freshness"; const BRIDGE_INTERNAL_RPC_TIMEOUT_MS = 10_000; export function createBridgeServer( @@ -545,6 +547,55 @@ async function handleBridgeControlMessage( return; } + if (messageType === BRIDGE_GET_SESSION_FRESHNESS_TYPE) { + const sessionPath = typeof payload.sessionPath === "string" ? payload.sessionPath : undefined; + if (!sessionPath) { + client.send( + JSON.stringify( + createBridgeErrorEnvelope( + "invalid_session_path", + "sessionPath must be a non-empty string", + ), + ), + ); + return; + } + + try { + const freshness = await sessionIndexer.getSessionFreshness(sessionPath); + const lock = processManager.getControlSnapshot(freshness.cwd, freshness.sessionPath); + + client.send( + JSON.stringify( + createBridgeEnvelope({ + type: BRIDGE_SESSION_FRESHNESS_TYPE, + sessionPath: freshness.sessionPath, + cwd: freshness.cwd, + fingerprint: freshness.fingerprint, + lock: { + cwdOwnerClientId: lock.cwdOwnerClientId ?? null, + sessionOwnerClientId: lock.sessionOwnerClientId ?? null, + isCurrentClientCwdOwner: lock.cwdOwnerClientId === context.clientId, + isCurrentClientSessionOwner: lock.sessionOwnerClientId === context.clientId, + }, + }), + ), + ); + } catch (error: unknown) { + logger.error({ error, sessionPath }, "Failed to read session freshness"); + client.send( + JSON.stringify( + createBridgeErrorEnvelope( + "session_freshness_failed", + "Failed to read session freshness", + ), + ), + ); + } + + return; + } + if (messageType === BRIDGE_NAVIGATE_TREE_TYPE) { const cwd = getRequestedCwd(payload, context); if (!cwd) { diff --git a/bridge/src/session-indexer.ts b/bridge/src/session-indexer.ts index 89ffb6b..738a308 100644 --- a/bridge/src/session-indexer.ts +++ b/bridge/src/session-indexer.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import type { Dirent } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; @@ -40,9 +41,24 @@ export interface SessionTreeSnapshot { entries: SessionTreeEntry[]; } +export interface SessionFreshnessFingerprint { + mtimeMs: number; + sizeBytes: number; + entryCount: number; + lastEntryId?: string; + lastEntriesHash: string; +} + +export interface SessionFreshnessSnapshot { + sessionPath: string; + cwd: string; + fingerprint: SessionFreshnessFingerprint; +} + export interface SessionIndexer { listSessions(): Promise; getSessionTree(sessionPath: string, filter?: SessionTreeFilter): Promise; + getSessionFreshness(sessionPath: string): Promise; } export interface SessionIndexerOptions { @@ -56,9 +72,16 @@ interface CachedSessionMetadata { entry: SessionIndexEntry | undefined; } +interface CachedSessionFreshness { + mtimeMs: number; + size: number; + freshness: SessionFreshnessSnapshot; +} + export function createSessionIndexer(options: SessionIndexerOptions): SessionIndexer { const sessionsRoot = path.resolve(options.sessionsDirectory); const sessionMetadataCache = new Map(); + const sessionFreshnessCache = new Map(); return { async listSessions(): Promise { @@ -71,6 +94,11 @@ export function createSessionIndexer(options: SessionIndexerOptions): SessionInd sessionMetadataCache.delete(cachedPath); } } + for (const cachedPath of sessionFreshnessCache.keys()) { + if (!sessionFileSet.has(cachedPath)) { + sessionFreshnessCache.delete(cachedPath); + } + } for (const sessionFile of sessionFiles) { const entry = await parseSessionFileWithCache(sessionFile, options.logger, sessionMetadataCache); @@ -100,6 +128,11 @@ export function createSessionIndexer(options: SessionIndexerOptions): SessionInd const resolvedSessionPath = await resolveSessionPath(sessionPath, sessionsRoot); return parseSessionTreeFile(resolvedSessionPath, options.logger, filter); }, + + async getSessionFreshness(sessionPath: string): Promise { + const resolvedSessionPath = await resolveSessionPath(sessionPath, sessionsRoot); + return parseSessionFreshnessWithCache(resolvedSessionPath, options.logger, sessionFreshnessCache); + }, }; } @@ -199,6 +232,83 @@ async function parseSessionFileWithCache( return entry; } +async function parseSessionFreshnessWithCache( + sessionPath: string, + logger: Logger, + cache: Map, +): Promise { + let fileStats: Awaited>; + + try { + fileStats = await fs.stat(sessionPath); + } catch (error: unknown) { + cache.delete(sessionPath); + logger.warn({ sessionPath, error }, "Failed to stat session file for freshness"); + throw new Error(`Failed to read session freshness for ${sessionPath}`); + } + + const cached = cache.get(sessionPath); + if (cached && cached.mtimeMs === fileStats.mtimeMs && cached.size === fileStats.size) { + return cached.freshness; + } + + const freshness = await parseSessionFreshness(sessionPath, fileStats, logger); + cache.set(sessionPath, { + mtimeMs: fileStats.mtimeMs, + size: fileStats.size, + freshness, + }); + + return freshness; +} + +async function parseSessionFreshness( + sessionPath: string, + fileStats: Awaited>, + logger: Logger, +): Promise { + let fileContent: string; + + try { + fileContent = await fs.readFile(sessionPath, "utf-8"); + } catch (error: unknown) { + logger.warn({ sessionPath, error }, "Failed to read session file for freshness"); + throw new Error(`Failed to read session file: ${sessionPath}`); + } + + const lines = fileContent + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + if (lines.length === 0) { + throw new Error("Session file is empty"); + } + + const header = tryParseJson(lines[0]); + if (!header || header.type !== "session" || typeof header.cwd !== "string") { + logger.warn({ sessionPath }, "Invalid session header while computing freshness"); + throw new Error("Invalid session header"); + } + + const entryLines = lines.slice(1); + const parsedEntries = entryLines.map(tryParseJson).filter((entry): entry is Record => !!entry); + const lastEntryId = findLastEntryId(parsedEntries); + const lastEntriesHash = computeLastEntriesHash(entryLines); + + return { + sessionPath, + cwd: header.cwd, + fingerprint: { + mtimeMs: Number(fileStats.mtimeMs), + sizeBytes: Number(fileStats.size), + entryCount: parsedEntries.length, + lastEntryId, + lastEntriesHash, + }, + }; +} + async function parseSessionFile( sessionPath: string, fileStats: Awaited>, @@ -550,6 +660,24 @@ function normalizePreview(value: string): string | undefined { return `${compact.slice(0, maxLength - 1)}…`; } +function findLastEntryId(entries: Record[]): string | undefined { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (typeof entry.id === "string" && entry.id.length > 0) { + return entry.id; + } + } + + return undefined; +} + +function computeLastEntriesHash(entryLines: string[]): string { + const tail = entryLines.slice(-FRESHNESS_HASH_LINE_WINDOW); + return createHash("sha256") + .update(tail.join("\n")) + .digest("hex"); +} + function tryParseJson(value: string): Record | undefined { let parsed: unknown; @@ -616,3 +744,5 @@ function isErrorWithCode(error: unknown, code: string): boolean { function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } + +const FRESHNESS_HASH_LINE_WINDOW = 16; diff --git a/bridge/test/process-manager.test.ts b/bridge/test/process-manager.test.ts index 0a0d558..0de3197 100644 --- a/bridge/test/process-manager.test.ts +++ b/bridge/test/process-manager.test.ts @@ -101,6 +101,44 @@ describe("createPiProcessManager", () => { expect(secondResult.success).toBe(true); }); + it("exposes lock owners via getControlSnapshot", () => { + const manager = createPiProcessManager({ + idleTtlMs: 60_000, + logger: createLogger("silent"), + enableEvictionTimer: false, + forwarderFactory: () => new FakeRpcForwarder(), + }); + + const acquired = manager.acquireControl({ + clientId: "client-a", + cwd: "/tmp/project-a", + sessionPath: "/tmp/session-a.jsonl", + }); + expect(acquired.success).toBe(true); + + expect( + manager.getControlSnapshot( + "/tmp/project-a", + "/tmp/session-a.jsonl", + ), + ).toEqual({ + cwdOwnerClientId: "client-a", + sessionOwnerClientId: "client-a", + }); + + manager.releaseClient("client-a"); + + expect( + manager.getControlSnapshot( + "/tmp/project-a", + "/tmp/session-a.jsonl", + ), + ).toEqual({ + cwdOwnerClientId: undefined, + sessionOwnerClientId: undefined, + }); + }); + it("evicts idle RPC forwarders based on ttl", async () => { let nowMs = 0; const fakeForwarder = new FakeRpcForwarder(); diff --git a/bridge/test/server.test.ts b/bridge/test/server.test.ts index 958505f..fe55507 100644 --- a/bridge/test/server.test.ts +++ b/bridge/test/server.test.ts @@ -14,7 +14,12 @@ import type { import type { PiRpcForwarder } from "../src/rpc-forwarder.js"; import type { BridgeServer } from "../src/server.js"; import { createBridgeServer } from "../src/server.js"; -import type { SessionIndexGroup, SessionIndexer, SessionTreeSnapshot } from "../src/session-indexer.js"; +import type { + SessionFreshnessSnapshot, + SessionIndexGroup, + SessionIndexer, + SessionTreeSnapshot, +} from "../src/session-indexer.js"; describe("bridge websocket server", () => { let bridgeServer: BridgeServer | undefined; @@ -240,6 +245,81 @@ describe("bridge websocket server", () => { ws.close(); }); + it("returns session freshness fingerprint with lock metadata", async () => { + const fakeProcessManager = new FakeProcessManager(); + const fakeSessionIndexer = new FakeSessionIndexer(); + const { baseUrl, server } = await startBridgeServer({ + processManager: fakeProcessManager, + sessionIndexer: fakeSessionIndexer, + }); + bridgeServer = server; + + const ws = await connectWebSocket(baseUrl, { + headers: { + authorization: "Bearer bridge-token", + }, + }); + + const waitForCwdSet = waitForEnvelope(ws, (envelope) => envelope.payload?.type === "bridge_cwd_set"); + ws.send( + JSON.stringify({ + channel: "bridge", + payload: { + type: "bridge_set_cwd", + cwd: "/tmp/project", + }, + }), + ); + await waitForCwdSet; + + const waitForControl = waitForEnvelope(ws, (envelope) => envelope.payload?.type === "bridge_control_acquired"); + ws.send( + JSON.stringify({ + channel: "bridge", + payload: { + type: "bridge_acquire_control", + cwd: "/tmp/project", + sessionPath: "/tmp/session-tree.jsonl", + }, + }), + ); + await waitForControl; + + const waitForFreshness = + waitForEnvelope(ws, (envelope) => envelope.payload?.type === "bridge_session_freshness"); + ws.send( + JSON.stringify({ + channel: "bridge", + payload: { + type: "bridge_get_session_freshness", + sessionPath: "/tmp/session-tree.jsonl", + }, + }), + ); + + const freshnessEnvelope = await waitForFreshness; + expect(fakeSessionIndexer.freshnessCalls).toBe(1); + expect(fakeSessionIndexer.requestedSessionPath).toBe("/tmp/session-tree.jsonl"); + expect(freshnessEnvelope.payload?.type).toBe("bridge_session_freshness"); + expect(freshnessEnvelope.payload?.sessionPath).toBe("/tmp/session-tree.jsonl"); + expect(freshnessEnvelope.payload?.cwd).toBe("/tmp/project"); + + const fingerprint = freshnessEnvelope.payload?.fingerprint as Record; + expect(fingerprint.mtimeMs).toBe(1730000000000); + expect(fingerprint.sizeBytes).toBe(1024); + expect(fingerprint.entryCount).toBe(3); + expect(fingerprint.lastEntryId).toBe("m3"); + expect(fingerprint.lastEntriesHash).toBe("abc123"); + + const lock = freshnessEnvelope.payload?.lock as Record; + expect(lock.cwdOwnerClientId).toBeTypeOf("string"); + expect(lock.sessionOwnerClientId).toBeTypeOf("string"); + expect(lock.isCurrentClientCwdOwner).toBe(true); + expect(lock.isCurrentClientSessionOwner).toBe(true); + + ws.close(); + }); + it("forwards tree filter to session indexer", async () => { const fakeSessionIndexer = new FakeSessionIndexer(); const { baseUrl, server } = await startBridgeServer({ sessionIndexer: fakeSessionIndexer }); @@ -1229,6 +1309,7 @@ function isEnvelopeLike(value: unknown): value is EnvelopeLike { class FakeSessionIndexer implements SessionIndexer { listCalls = 0; treeCalls = 0; + freshnessCalls = 0; requestedSessionPath: string | undefined; requestedFilter: string | undefined; @@ -1239,6 +1320,17 @@ class FakeSessionIndexer implements SessionIndexer { rootIds: [], entries: [], }, + private readonly freshness: SessionFreshnessSnapshot = { + sessionPath: "/tmp/session-tree.jsonl", + cwd: "/tmp/project", + fingerprint: { + mtimeMs: 1730000000000, + sizeBytes: 1024, + entryCount: 3, + lastEntryId: "m3", + lastEntriesHash: "abc123", + }, + }, ) {} async listSessions(): Promise { @@ -1255,6 +1347,12 @@ class FakeSessionIndexer implements SessionIndexer { this.requestedFilter = filter; return this.tree; } + + async getSessionFreshness(sessionPath: string): Promise { + this.freshnessCalls += 1; + this.requestedSessionPath = sessionPath; + return this.freshness; + } } class FakeProcessManager implements PiProcessManager { @@ -1269,6 +1367,7 @@ class FakeProcessManager implements PiProcessManager { private messageHandler: (event: ProcessManagerEvent) => void = () => {}; private lockByCwd = new Map(); + private lockBySession = new Map(); emitRpcEvent(cwd: string, payload: Record): void { this.messageHandler({ cwd, payload }); @@ -1355,7 +1454,20 @@ class FakeProcessManager implements PiProcessManager { }; } + if (request.sessionPath) { + const sessionOwner = this.lockBySession.get(request.sessionPath); + if (sessionOwner && sessionOwner !== request.clientId) { + return { + success: false, + reason: `session is controlled by another client: ${request.sessionPath}`, + }; + } + } + this.lockByCwd.set(request.cwd, request.clientId); + if (request.sessionPath) { + this.lockBySession.set(request.sessionPath, request.clientId); + } return { success: true }; } @@ -1363,10 +1475,30 @@ class FakeProcessManager implements PiProcessManager { return this.lockByCwd.get(cwd) === clientId; } - releaseControl(clientId: string, cwd: string): void { + getControlSnapshot(cwd: string, sessionPath?: string): { cwdOwnerClientId?: string; sessionOwnerClientId?: string } { + return { + cwdOwnerClientId: this.lockByCwd.get(cwd), + sessionOwnerClientId: sessionPath ? this.lockBySession.get(sessionPath) : undefined, + }; + } + + releaseControl(clientId: string, cwd: string, sessionPath?: string): void { if (this.lockByCwd.get(cwd) === clientId) { this.lockByCwd.delete(cwd); } + + if (sessionPath) { + if (this.lockBySession.get(sessionPath) === clientId) { + this.lockBySession.delete(sessionPath); + } + return; + } + + for (const [lockedSessionPath, owner] of this.lockBySession.entries()) { + if (owner === clientId) { + this.lockBySession.delete(lockedSessionPath); + } + } } releaseClient(clientId: string): void { @@ -1375,13 +1507,19 @@ class FakeProcessManager implements PiProcessManager { this.lockByCwd.delete(cwd); } } + + for (const [sessionPath, owner] of this.lockBySession.entries()) { + if (owner === clientId) { + this.lockBySession.delete(sessionPath); + } + } } getStats(): { activeProcessCount: number; lockedCwdCount: number; lockedSessionCount: number } { return { activeProcessCount: 0, lockedCwdCount: this.lockByCwd.size, - lockedSessionCount: 0, + lockedSessionCount: this.lockBySession.size, }; } @@ -1391,5 +1529,6 @@ class FakeProcessManager implements PiProcessManager { async stop(): Promise { this.lockByCwd.clear(); + this.lockBySession.clear(); } } diff --git a/bridge/test/session-indexer.test.ts b/bridge/test/session-indexer.test.ts index 1ba561b..db05a1a 100644 --- a/bridge/test/session-indexer.test.ts +++ b/bridge/test/session-indexer.test.ts @@ -519,6 +519,64 @@ describe("createSessionIndexer", () => { } }); + it("computes session freshness fingerprints and reuses cache for unchanged files", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "pi-session-freshness-cache-")); + const projectDir = path.join(tempRoot, "--tmp-project-freshness--"); + await fs.mkdir(projectDir, { recursive: true }); + + const sessionPath = path.join(projectDir, "2026-02-03T00-00-00-000Z_f1111111.jsonl"); + const lines = [ + JSON.stringify({ + type: "session", + version: 3, + id: "f1111111", + timestamp: "2026-02-03T00:00:00.000Z", + cwd: "/tmp/project-freshness", + }), + JSON.stringify({ + type: "message", + id: "m1", + parentId: null, + timestamp: "2026-02-03T00:00:01.000Z", + message: { role: "user", content: "hello" }, + }), + JSON.stringify({ + type: "message", + id: "m2", + parentId: "m1", + timestamp: "2026-02-03T00:00:02.000Z", + message: { role: "assistant", content: [{ type: "text", text: "reply" }] }, + }), + ]; + await fs.writeFile(sessionPath, lines.join("\n"), "utf-8"); + + const sessionIndexer = createSessionIndexer({ + sessionsDirectory: tempRoot, + logger: createLogger("silent"), + }); + + const readFileSpy = vi.spyOn(fs, "readFile"); + + try { + const freshness = await sessionIndexer.getSessionFreshness(sessionPath); + expect(freshness.sessionPath).toBe(sessionPath); + expect(freshness.cwd).toBe("/tmp/project-freshness"); + expect(freshness.fingerprint.sizeBytes).toBeGreaterThan(0); + expect(freshness.fingerprint.entryCount).toBe(2); + expect(freshness.fingerprint.lastEntryId).toBe("m2"); + expect(freshness.fingerprint.lastEntriesHash.length).toBe(64); + + readFileSpy.mockClear(); + + const cachedFreshness = await sessionIndexer.getSessionFreshness(sessionPath); + expect(cachedFreshness.fingerprint.lastEntriesHash).toBe(freshness.fingerprint.lastEntriesHash); + expect(readFileSpy).not.toHaveBeenCalled(); + } finally { + readFileSpy.mockRestore(); + await fs.rm(tempRoot, { recursive: true, force: true }); + } + }); + it("returns an empty list if session directory does not exist", async () => { const sessionIndexer = createSessionIndexer({ sessionsDirectory: "/tmp/path-does-not-exist-for-tests", diff --git a/docs/bridge-protocol.md b/docs/bridge-protocol.md index a2c5b05..db18878 100644 --- a/docs/bridge-protocol.md +++ b/docs/bridge-protocol.md @@ -96,6 +96,7 @@ If reconnecting with same `clientId`, `resumed` may be `true` and previous `cwd` | `bridge_ping` | `bridge_pong` | Liveness check | | `bridge_list_sessions` | `bridge_sessions` | Returns grouped session metadata | | `bridge_get_session_tree` | `bridge_session_tree` | Requires `sessionPath`; supports filter | +| `bridge_get_session_freshness` | `bridge_session_freshness` | Returns freshness fingerprint + lock owner metadata | | `bridge_navigate_tree` | `bridge_tree_navigation_result` | Requires control lock; uses internal extension command | | `bridge_set_cwd` | `bridge_cwd_set` | Sets active cwd context for client | | `bridge_acquire_control` | `bridge_control_acquired` | Acquires write lock for cwd/session | @@ -113,6 +114,40 @@ Allowed values: Unknown filter -> `bridge_error` (`invalid_tree_filter`). +### `bridge_get_session_freshness` + +Request payload: + +```json +{ + "type": "bridge_get_session_freshness", + "sessionPath": "/.../session.jsonl" +} +``` + +Response payload: + +```json +{ + "type": "bridge_session_freshness", + "sessionPath": "/.../session.jsonl", + "cwd": "/.../project", + "fingerprint": { + "mtimeMs": 1730000000000, + "sizeBytes": 2048, + "entryCount": 42, + "lastEntryId": "m42", + "lastEntriesHash": "..." + }, + "lock": { + "cwdOwnerClientId": "client-a", + "sessionOwnerClientId": "client-a", + "isCurrentClientCwdOwner": true, + "isCurrentClientSessionOwner": true + } +} +``` + ### `bridge_navigate_tree` Request payload: @@ -185,6 +220,7 @@ Common codes: - `tree_navigation_failed` - `session_index_failed` - `session_tree_failed` +- `session_freshness_failed` ## Health Endpoint From 1a3a2b773ec5e4fe8e5f31ca2cc6b4891a48501f Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Mon, 16 Feb 2026 21:56:57 +0000 Subject: [PATCH 08/32] feat(ui): add side nav, inline run progress, and status toggle --- .../ayagmar/pimobile/chat/ChatViewModel.kt | 21 +- .../com/ayagmar/pimobile/ui/PiMobileApp.kt | 144 +++++++----- .../ayagmar/pimobile/ui/chat/ChatScreen.kt | 217 +++++++++++++----- .../ui/settings/SettingsPreferences.kt | 1 + .../pimobile/ui/settings/SettingsScreen.kt | 11 + .../pimobile/ui/settings/SettingsViewModel.kt | 8 + .../ChatViewModelThinkingExpansionTest.kt | 40 ++++ .../testutil/FakeSessionController.kt | 14 +- .../ui/settings/SettingsViewModelTest.kt | 16 ++ 9 files changed, 354 insertions(+), 118 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt index 7a8cf98..8ed4841 100644 --- a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt +++ b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt @@ -189,9 +189,24 @@ class ChatViewModel( fun abort() { viewModelScope.launch { _uiState.update { it.copy(errorMessage = null) } - val result = sessionController.abort() - if (result.isFailure) { - _uiState.update { it.copy(errorMessage = result.exceptionOrNull()?.message) } + + val abortResult = sessionController.abort() + val shouldAttemptAbortRetry = _uiState.value.isRetrying || abortResult.isFailure + val abortRetryResult = + if (shouldAttemptAbortRetry) { + sessionController.abortRetry() + } else { + Result.success(Unit) + } + + if (abortResult.isFailure && abortRetryResult.isFailure) { + _uiState.update { + it.copy( + errorMessage = + abortResult.exceptionOrNull()?.message + ?: abortRetryResult.exceptionOrNull()?.message, + ) + } } } } diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt b/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt index 5989536..f7ca5c4 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt @@ -1,9 +1,21 @@ package com.ayagmar.pimobile.ui import android.content.Context +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.NavigationBar -import androidx.compose.material3.NavigationBarItem +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Chat +import androidx.compose.material.icons.filled.Computer +import androidx.compose.material.icons.filled.Menu +import androidx.compose.material.icons.filled.MenuOpen +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -13,6 +25,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable @@ -31,6 +44,7 @@ import com.ayagmar.pimobile.ui.theme.ThemePreference private data class AppDestination( val route: String, val label: String, + val icon: ImageVector, ) private val destinations = @@ -38,18 +52,22 @@ private val destinations = AppDestination( route = "hosts", label = "Hosts", + icon = Icons.Default.Computer, ), AppDestination( route = "sessions", label = "Sessions", + icon = Icons.Default.Storage, ), AppDestination( route = "chat", label = "Chat", + icon = Icons.Default.Chat, ), AppDestination( route = "settings", label = "Settings", + icon = Icons.Default.Settings, ), ) @@ -87,63 +105,83 @@ fun piMobileApp(appGraph: AppGraph) { val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route - Scaffold( - bottomBar = { - NavigationBar { + var isNavExpanded by remember { mutableStateOf(false) } + + fun navigateTo(route: String) { + navController.navigate(route) { + launchSingleTop = true + restoreState = true + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + } + } + + Scaffold { paddingValues -> + Row( + modifier = Modifier.fillMaxSize().padding(paddingValues), + ) { + NavigationRail( + modifier = Modifier.fillMaxHeight(), + ) { + IconButton(onClick = { isNavExpanded = !isNavExpanded }) { + Icon( + imageVector = if (isNavExpanded) Icons.Default.MenuOpen else Icons.Default.Menu, + contentDescription = if (isNavExpanded) "Collapse navigation" else "Expand navigation", + ) + } + destinations.forEach { destination -> - NavigationBarItem( + NavigationRailItem( selected = currentRoute == destination.route, - onClick = { - navController.navigate(destination.route) { - launchSingleTop = true - restoreState = true - popUpTo(navController.graph.startDestinationId) { - saveState = true - } - } + onClick = { navigateTo(destination.route) }, + icon = { + Icon( + imageVector = destination.icon, + contentDescription = destination.label, + ) }, - icon = { Text(destination.label.take(1)) }, - label = { Text(destination.label) }, + label = + if (isNavExpanded) { + { Text(destination.label) } + } else { + null + }, + alwaysShowLabel = isNavExpanded, ) } } - }, - ) { paddingValues -> - NavHost( - navController = navController, - startDestination = "sessions", - modifier = Modifier.padding(paddingValues), - ) { - composable(route = "hosts") { - HostsRoute( - profileStore = appGraph.hostProfileStore, - tokenStore = appGraph.hostTokenStore, - diagnostics = appGraph.connectionDiagnostics, - ) - } - composable(route = "sessions") { - SessionsRoute( - profileStore = appGraph.hostProfileStore, - tokenStore = appGraph.hostTokenStore, - repository = appGraph.sessionIndexRepository, - sessionController = appGraph.sessionController, - cwdPreferenceStore = appGraph.sessionCwdPreferenceStore, - onNavigateToChat = { - navController.navigate("chat") { - launchSingleTop = true - restoreState = true - popUpTo(navController.graph.startDestinationId) { - saveState = true - } - } - }, - ) - } - composable(route = "chat") { - ChatRoute(sessionController = appGraph.sessionController) - } - composable(route = "settings") { - SettingsRoute(sessionController = appGraph.sessionController) + + NavHost( + navController = navController, + startDestination = "sessions", + modifier = Modifier.weight(1f), + ) { + composable(route = "hosts") { + HostsRoute( + profileStore = appGraph.hostProfileStore, + tokenStore = appGraph.hostTokenStore, + diagnostics = appGraph.connectionDiagnostics, + ) + } + composable(route = "sessions") { + SessionsRoute( + profileStore = appGraph.hostProfileStore, + tokenStore = appGraph.hostTokenStore, + repository = appGraph.sessionIndexRepository, + sessionController = appGraph.sessionController, + cwdPreferenceStore = appGraph.sessionCwdPreferenceStore, + onNavigateToChat = { + navigateTo("chat") + }, + ) + } + composable(route = "chat") { + ChatRoute(sessionController = appGraph.sessionController) + } + composable(route = "settings") { + SettingsRoute(sessionController = appGraph.sessionController) + } } } } diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt index 08c3de7..878e2bf 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt @@ -69,6 +69,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -113,6 +114,8 @@ import com.ayagmar.pimobile.sessions.SessionController import com.ayagmar.pimobile.sessions.SessionTreeEntry import com.ayagmar.pimobile.sessions.SessionTreeSnapshot import com.ayagmar.pimobile.sessions.SlashCommandInfo +import com.ayagmar.pimobile.ui.settings.KEY_SHOW_EXTENSION_STATUS_STRIP +import com.ayagmar.pimobile.ui.settings.SETTINGS_PREFS_NAME import kotlinx.coroutines.delay private data class ChatCallbacks( @@ -194,6 +197,27 @@ fun ChatRoute(sessionController: SessionController) { val chatViewModel: ChatViewModel = viewModel(factory = factory) val uiState by chatViewModel.uiState.collectAsStateWithLifecycle() + val settingsPrefs = + remember(context) { + context.getSharedPreferences(SETTINGS_PREFS_NAME, android.content.Context.MODE_PRIVATE) + } + var showExtensionStatusStrip by remember(settingsPrefs) { + mutableStateOf(settingsPrefs.getBoolean(KEY_SHOW_EXTENSION_STATUS_STRIP, true)) + } + + DisposableEffect(settingsPrefs) { + val listener = + android.content.SharedPreferences.OnSharedPreferenceChangeListener { prefs, key -> + if (key == KEY_SHOW_EXTENSION_STATUS_STRIP) { + showExtensionStatusStrip = prefs.getBoolean(KEY_SHOW_EXTENSION_STATUS_STRIP, true) + } + } + settingsPrefs.registerOnSharedPreferenceChangeListener(listener) + onDispose { + settingsPrefs.unregisterOnSharedPreferenceChangeListener(listener) + } + } + val callbacks = remember(chatViewModel) { ChatCallbacks( @@ -245,6 +269,7 @@ fun ChatRoute(sessionController: SessionController) { ChatScreen( state = uiState, callbacks = callbacks, + showExtensionStatusStrip = showExtensionStatusStrip, ) } @@ -253,6 +278,7 @@ fun ChatRoute(sessionController: SessionController) { private fun ChatScreen( state: ChatUiState, callbacks: ChatCallbacks, + showExtensionStatusStrip: Boolean, ) { StreamingFrameMetrics( isStreaming = state.isStreaming, @@ -268,6 +294,7 @@ private fun ChatScreen( ChatScreenContent( state = state, callbacks = callbacks, + showExtensionStatusStrip = showExtensionStatusStrip, ) ExtensionUiDialogs( @@ -344,15 +371,64 @@ private fun ChatScreen( private fun ChatScreenContent( state: ChatUiState, callbacks: ChatCallbacks, + showExtensionStatusStrip: Boolean, ) { + val hasStreamingTimelineItem = + remember(state.timeline) { + state.timeline.any { item -> + when (item) { + is ChatTimelineItem.Assistant -> item.isStreaming + is ChatTimelineItem.Tool -> item.isStreaming + is ChatTimelineItem.User -> false + } + } + } + val isRunActive = state.isStreaming || state.isRetrying || hasStreamingTimelineItem + + var runStartedAtMs by remember { mutableStateOf(null) } + + LaunchedEffect(isRunActive) { + if (isRunActive) { + if (runStartedAtMs == null) { + runStartedAtMs = System.currentTimeMillis() + } + } else { + runStartedAtMs = null + } + } + + val elapsedSeconds by + produceState( + initialValue = 0L, + key1 = isRunActive, + key2 = runStartedAtMs, + ) { + val startedAt = runStartedAtMs + if (!isRunActive || startedAt == null) { + value = 0L + return@produceState + } + + while (true) { + value = ((System.currentTimeMillis() - startedAt).coerceAtLeast(0L)) / RUN_PROGRESS_TICK_MS + delay(RUN_PROGRESS_TICK_MS) + } + } + + val runPhase = + remember(state.isRetrying, state.timeline) { + inferLiveRunPhase( + isRetrying = state.isRetrying, + timeline = state.timeline, + ) + } + Column( modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).padding(16.dp).imePadding(), - verticalArrangement = Arrangement.spacedBy(if (state.isStreaming) 8.dp else 12.dp), + verticalArrangement = Arrangement.spacedBy(if (isRunActive) 8.dp else 12.dp), ) { ChatHeader( - isStreaming = state.isStreaming, - isRetrying = state.isRetrying, - timeline = state.timeline, + isRunActive = isRunActive, isSyncingSession = state.isSyncingSession, sessionCoherencyWarning = state.sessionCoherencyWarning, extensionTitle = state.extensionTitle, @@ -376,6 +452,9 @@ private fun ChatScreenContent( hasOlderMessages = state.hasOlderMessages, hiddenHistoryCount = state.hiddenHistoryCount, expandedToolArguments = state.expandedToolArguments, + isRunActive = isRunActive, + runPhase = runPhase, + runElapsedSeconds = elapsedSeconds, callbacks = callbacks, ) } @@ -386,10 +465,8 @@ private fun ChatScreenContent( placement = "belowEditor", ) - ExtensionStatusStrip(statuses = state.extensionStatuses) - PromptControls( - isStreaming = state.isStreaming, + isStreaming = isRunActive, isRetrying = state.isRetrying, pendingQueueItems = state.pendingQueueItems, steeringMode = state.steeringMode, @@ -411,15 +488,17 @@ private fun ChatScreenContent( onClearPendingQueueItems = callbacks.onClearPendingQueueItems, ), ) + + if (showExtensionStatusStrip) { + ExtensionStatusStrip(statuses = state.extensionStatuses) + } } } @Suppress("LongMethod", "LongParameterList", "CyclomaticComplexMethod") @Composable private fun ChatHeader( - isStreaming: Boolean, - isRetrying: Boolean, - timeline: List, + isRunActive: Boolean, isSyncingSession: Boolean, sessionCoherencyWarning: String?, extensionTitle: String?, @@ -429,46 +508,9 @@ private fun ChatHeader( errorMessage: String?, callbacks: ChatCallbacks, ) { - val isCompact = isStreaming - var runStartedAtMs by remember { mutableStateOf(null) } + val isCompact = isRunActive var showSecondaryActionsMenu by remember { mutableStateOf(false) } - LaunchedEffect(isStreaming) { - if (isStreaming) { - if (runStartedAtMs == null) { - runStartedAtMs = System.currentTimeMillis() - } - } else { - runStartedAtMs = null - } - } - - val elapsedSeconds by - produceState( - initialValue = 0L, - key1 = isStreaming, - key2 = runStartedAtMs, - ) { - val startedAt = runStartedAtMs - if (!isStreaming || startedAt == null) { - value = 0L - return@produceState - } - - while (true) { - value = ((System.currentTimeMillis() - startedAt).coerceAtLeast(0L)) / RUN_PROGRESS_TICK_MS - delay(RUN_PROGRESS_TICK_MS) - } - } - - val runPhase = - remember(isRetrying, timeline) { - inferLiveRunPhase( - isRetrying = isRetrying, - timeline = timeline, - ) - } - Column(modifier = Modifier.fillMaxWidth()) { // Top row: Title and minimal actions Row( @@ -564,13 +606,6 @@ private fun ChatHeader( } } - if (isStreaming) { - LiveRunProgressIndicator( - phase = runPhase, - elapsedSeconds = elapsedSeconds, - ) - } - sessionCoherencyWarning?.let { warning -> Text( text = warning, @@ -602,10 +637,11 @@ private fun ChatHeader( private fun LiveRunProgressIndicator( phase: LiveRunPhase, elapsedSeconds: Long, + modifier: Modifier = Modifier, ) { Row( modifier = - Modifier + modifier .fillMaxWidth() .padding(top = 4.dp) .testTag(CHAT_RUN_PROGRESS_TAG), @@ -626,6 +662,36 @@ private fun LiveRunProgressIndicator( } } +@Composable +private fun InlineRunProgressCard( + phase: LiveRunPhase, + elapsedSeconds: Long, +) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Assistant", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + LiveRunProgressIndicator( + phase = phase, + elapsedSeconds = elapsedSeconds, + modifier = Modifier, + ) + } + } +} + @Suppress("LongParameterList") @Composable private fun ChatBody( @@ -634,8 +700,23 @@ private fun ChatBody( hasOlderMessages: Boolean, hiddenHistoryCount: Int, expandedToolArguments: Set, + isRunActive: Boolean, + runPhase: LiveRunPhase, + runElapsedSeconds: Long, callbacks: ChatCallbacks, ) { + val hasStreamingTimelineItem = + remember(timeline) { + timeline.any { item -> + when (item) { + is ChatTimelineItem.Assistant -> item.isStreaming + is ChatTimelineItem.Tool -> item.isStreaming + is ChatTimelineItem.User -> false + } + } + } + val showInlineRunProgress = isRunActive && !hasStreamingTimelineItem + if (isLoading) { Row( modifier = Modifier.fillMaxWidth().padding(top = 24.dp), @@ -643,7 +724,7 @@ private fun ChatBody( ) { CircularProgressIndicator() } - } else if (timeline.isEmpty()) { + } else if (timeline.isEmpty() && !showInlineRunProgress) { Text( text = "No chat messages yet. Resume a session and send a prompt.", style = MaterialTheme.typography.bodyLarge, @@ -654,6 +735,9 @@ private fun ChatBody( hasOlderMessages = hasOlderMessages, hiddenHistoryCount = hiddenHistoryCount, expandedToolArguments = expandedToolArguments, + showInlineRunProgress = showInlineRunProgress, + runPhase = runPhase, + runElapsedSeconds = runElapsedSeconds, onLoadOlderMessages = callbacks.onLoadOlderMessages, onToggleToolExpansion = callbacks.onToggleToolExpansion, onToggleThinkingExpansion = callbacks.onToggleThinkingExpansion, @@ -671,6 +755,9 @@ private fun ChatTimeline( hasOlderMessages: Boolean, hiddenHistoryCount: Int, expandedToolArguments: Set, + showInlineRunProgress: Boolean, + runPhase: LiveRunPhase, + runElapsedSeconds: Long, onLoadOlderMessages: () -> Unit, onToggleToolExpansion: (String) -> Unit, onToggleThinkingExpansion: (String) -> Unit, @@ -679,6 +766,7 @@ private fun ChatTimeline( modifier: Modifier = Modifier, ) { val listState = androidx.compose.foundation.lazy.rememberLazyListState() + val totalItems = timeline.size + if (showInlineRunProgress) 1 else 0 val shouldAutoScrollToBottom by remember { derivedStateOf { @@ -692,9 +780,9 @@ private fun ChatTimeline( // Auto-scroll only while the user stays near the bottom. // This avoids jumping when loading older history or reading past messages. - LaunchedEffect(timeline.lastOrNull(), timeline.size, shouldAutoScrollToBottom) { - if (timeline.isNotEmpty() && shouldAutoScrollToBottom) { - listState.scrollToItem(timeline.size - 1) + LaunchedEffect(timeline.lastOrNull(), totalItems, shouldAutoScrollToBottom) { + if (totalItems > 0 && shouldAutoScrollToBottom) { + listState.scrollToItem(totalItems - 1) } } @@ -746,6 +834,15 @@ private fun ChatTimeline( } } } + + if (showInlineRunProgress) { + item(key = "inline-run-progress") { + InlineRunProgressCard( + phase = runPhase, + elapsedSeconds = runElapsedSeconds, + ) + } + } } } diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsPreferences.kt b/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsPreferences.kt index c1b98e0..1a26e1e 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsPreferences.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsPreferences.kt @@ -2,3 +2,4 @@ package com.ayagmar.pimobile.ui.settings const val SETTINGS_PREFS_NAME = "pi_mobile_settings" const val KEY_THEME_PREFERENCE = "theme_preference" +const val KEY_SHOW_EXTENSION_STATUS_STRIP = "show_extension_status_strip" diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsScreen.kt index 57648af..ff08e0c 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsScreen.kt @@ -102,6 +102,7 @@ private fun SettingsScreen( effectiveTransportPreference = uiState.effectiveTransportPreference, transportRuntimeNote = uiState.transportRuntimeNote, themePreference = uiState.themePreference, + showExtensionStatusStrip = uiState.showExtensionStatusStrip, steeringMode = uiState.steeringMode, followUpMode = uiState.followUpMode, isUpdatingSteeringMode = uiState.isUpdatingSteeringMode, @@ -110,6 +111,7 @@ private fun SettingsScreen( onToggleAutoRetry = viewModel::toggleAutoRetry, onTransportPreferenceSelected = viewModel::setTransportPreference, onThemePreferenceSelected = viewModel::setThemePreference, + onToggleExtensionStatusStrip = viewModel::toggleExtensionStatusStrip, onSteeringModeSelected = viewModel::setSteeringMode, onFollowUpModeSelected = viewModel::setFollowUpMode, ) @@ -224,6 +226,7 @@ private fun AgentBehaviorCard( effectiveTransportPreference: TransportPreference, transportRuntimeNote: String, themePreference: ThemePreference, + showExtensionStatusStrip: Boolean, steeringMode: String, followUpMode: String, isUpdatingSteeringMode: Boolean, @@ -232,6 +235,7 @@ private fun AgentBehaviorCard( onToggleAutoRetry: () -> Unit, onTransportPreferenceSelected: (TransportPreference) -> Unit, onThemePreferenceSelected: (ThemePreference) -> Unit, + onToggleExtensionStatusStrip: () -> Unit, onSteeringModeSelected: (String) -> Unit, onFollowUpModeSelected: (String) -> Unit, ) { @@ -269,6 +273,13 @@ private fun AgentBehaviorCard( onPreferenceSelected = onThemePreferenceSelected, ) + SettingsToggleRow( + title = "Show extension status strip", + description = "Display extension status updates below the prompt controls in chat", + checked = showExtensionStatusStrip, + onToggle = onToggleExtensionStatusStrip, + ) + ModeSelectorRow( title = "Steering mode", description = "How steer messages are delivered while streaming", diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsViewModel.kt index 2111b0c..693af0c 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsViewModel.kt @@ -67,6 +67,7 @@ class SettingsViewModel( effectiveTransportPreference = effectiveTransport, transportRuntimeNote = transportRuntimeNote(transportPreference, effectiveTransport), themePreference = themePreference, + showExtensionStatusStrip = prefs.getBoolean(KEY_SHOW_EXTENSION_STATUS_STRIP, true), ) viewModelScope.launch { @@ -214,6 +215,12 @@ class SettingsViewModel( uiState = uiState.copy(themePreference = preference) } + fun toggleExtensionStatusStrip() { + val newValue = !uiState.showExtensionStatusStrip + prefs.edit().putBoolean(KEY_SHOW_EXTENSION_STATUS_STRIP, newValue).apply() + uiState = uiState.copy(showExtensionStatusStrip = newValue) + } + fun setSteeringMode(mode: String) { if (mode == uiState.steeringMode) return @@ -319,6 +326,7 @@ data class SettingsUiState( val effectiveTransportPreference: TransportPreference = TransportPreference.WEBSOCKET, val transportRuntimeNote: String = "", val themePreference: ThemePreference = ThemePreference.SYSTEM, + val showExtensionStatusStrip: Boolean = true, val steeringMode: String = SettingsViewModel.MODE_ALL, val followUpMode: String = SettingsViewModel.MODE_ALL, val isUpdatingSteeringMode: Boolean = false, diff --git a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt index ee0198f..9d04fa7 100644 --- a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt @@ -516,6 +516,46 @@ class ChatViewModelThinkingExpansionTest { assertTrue(viewModel.uiState.value.pendingQueueItems.isEmpty()) } + @Test + fun abortFallsBackToAbortRetryWhenAbortFails() = + runTest(dispatcher) { + val controller = + FakeSessionController().apply { + abortResult = Result.failure(IllegalStateException("abort failed")) + abortRetryResult = Result.success(Unit) + } + val viewModel = ChatViewModel(sessionController = controller) + dispatcher.scheduler.advanceUntilIdle() + awaitInitialLoad(viewModel) + + viewModel.abort() + dispatcher.scheduler.advanceUntilIdle() + + assertEquals(1, controller.abortCallCount) + assertEquals(1, controller.abortRetryCallCount) + assertEquals(null, viewModel.uiState.value.errorMessage) + } + + @Test + fun abortReportsErrorWhenAbortAndAbortRetryFail() = + runTest(dispatcher) { + val controller = + FakeSessionController().apply { + abortResult = Result.failure(IllegalStateException("abort failed")) + abortRetryResult = Result.failure(IllegalStateException("abort retry failed")) + } + val viewModel = ChatViewModel(sessionController = controller) + dispatcher.scheduler.advanceUntilIdle() + awaitInitialLoad(viewModel) + + viewModel.abort() + dispatcher.scheduler.advanceUntilIdle() + + assertEquals(1, controller.abortCallCount) + assertEquals(1, controller.abortRetryCallCount) + assertEquals("abort failed", viewModel.uiState.value.errorMessage) + } + @Test fun initialHistoryLoadsWithWindowAndCanPageOlderMessages() = runTest(dispatcher) { diff --git a/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt b/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt index 0fae233..841b15d 100644 --- a/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt +++ b/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt @@ -39,6 +39,10 @@ class FakeSessionController : SessionController { var lastPromptMessage: String? = null var lastFreshnessSessionPath: String? = null var sendPromptResult: Result = Result.success(Unit) + var abortResult: Result = Result.success(Unit) + var abortRetryResult: Result = Result.success(Unit) + var abortCallCount: Int = 0 + var abortRetryCallCount: Int = 0 var messagesPayload: JsonObject? = null var sessionFreshnessResult: Result = Result.failure(IllegalStateException("Not used")) @@ -134,7 +138,10 @@ class FakeSessionController : SessionController { return sendPromptResult } - override suspend fun abort(): Result = Result.success(Unit) + override suspend fun abort(): Result { + abortCallCount += 1 + return abortResult + } override suspend fun steer(message: String): Result = Result.success(Unit) @@ -172,7 +179,10 @@ class FakeSessionController : SessionController { override suspend fun setThinkingLevel(level: String): Result = Result.success(level) - override suspend fun abortRetry(): Result = Result.success(Unit) + override suspend fun abortRetry(): Result { + abortRetryCallCount += 1 + return abortRetryResult + } override suspend fun sendExtensionUiResponse( requestId: String, diff --git a/app/src/test/java/com/ayagmar/pimobile/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/ayagmar/pimobile/ui/settings/SettingsViewModelTest.kt index 91b4cee..a579e70 100644 --- a/app/src/test/java/com/ayagmar/pimobile/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/ui/settings/SettingsViewModelTest.kt @@ -112,6 +112,22 @@ class SettingsViewModelTest { assertEquals(ThemePreference.DARK, viewModel.uiState.themePreference) } + @Test + fun toggleExtensionStatusStripUpdatesState() = + runTest(dispatcher) { + val controller = FakeSessionController() + val viewModel = createViewModel(controller) + + dispatcher.scheduler.advanceUntilIdle() + assertTrue(viewModel.uiState.showExtensionStatusStrip) + + viewModel.toggleExtensionStatusStrip() + assertFalse(viewModel.uiState.showExtensionStatusStrip) + + viewModel.toggleExtensionStatusStrip() + assertTrue(viewModel.uiState.showExtensionStatusStrip) + } + private fun createViewModel(controller: FakeSessionController): SettingsViewModel { return SettingsViewModel( sessionController = controller, From 1847baec3199120f8ad202f6e1869da96b554ba9 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Mon, 16 Feb 2026 22:22:28 +0000 Subject: [PATCH 09/32] feat(ui): implement approved UX quick wins --- .../com/ayagmar/pimobile/ui/PiMobileApp.kt | 79 ++-- .../ayagmar/pimobile/ui/chat/ChatScreen.kt | 408 +++++++++++++----- .../ui/settings/SettingsPreferences.kt | 1 + .../chat/ExtensionStatusPresentationTest.kt | 62 +++ 4 files changed, 421 insertions(+), 129 deletions(-) create mode 100644 app/src/test/java/com/ayagmar/pimobile/ui/chat/ExtensionStatusPresentationTest.kt diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt b/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt index f7ca5c4..83e96b1 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt @@ -1,10 +1,13 @@ package com.ayagmar.pimobile.ui import android.content.Context +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Chat import androidx.compose.material.icons.filled.Computer @@ -27,6 +30,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState @@ -35,6 +39,7 @@ import com.ayagmar.pimobile.di.AppGraph import com.ayagmar.pimobile.ui.chat.ChatRoute import com.ayagmar.pimobile.ui.hosts.HostsRoute import com.ayagmar.pimobile.ui.sessions.SessionsRoute +import com.ayagmar.pimobile.ui.settings.KEY_NAV_RAIL_EXPANDED import com.ayagmar.pimobile.ui.settings.KEY_THEME_PREFERENCE import com.ayagmar.pimobile.ui.settings.SETTINGS_PREFS_NAME import com.ayagmar.pimobile.ui.settings.SettingsRoute @@ -86,12 +91,21 @@ fun piMobileApp(appGraph: AppGraph) { ), ) } + var isNavExpanded by remember(settingsPrefs) { + mutableStateOf(settingsPrefs.getBoolean(KEY_NAV_RAIL_EXPANDED, false)) + } DisposableEffect(settingsPrefs) { val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { prefs, key -> - if (key == KEY_THEME_PREFERENCE) { - themePreference = ThemePreference.fromValue(prefs.getString(KEY_THEME_PREFERENCE, null)) + when (key) { + KEY_THEME_PREFERENCE -> { + themePreference = ThemePreference.fromValue(prefs.getString(KEY_THEME_PREFERENCE, null)) + } + + KEY_NAV_RAIL_EXPANDED -> { + isNavExpanded = prefs.getBoolean(KEY_NAV_RAIL_EXPANDED, false) + } } } settingsPrefs.registerOnSharedPreferenceChangeListener(listener) @@ -105,8 +119,6 @@ fun piMobileApp(appGraph: AppGraph) { val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route - var isNavExpanded by remember { mutableStateOf(false) } - fun navigateTo(route: String) { navController.navigate(route) { launchSingleTop = true @@ -122,33 +134,44 @@ fun piMobileApp(appGraph: AppGraph) { modifier = Modifier.fillMaxSize().padding(paddingValues), ) { NavigationRail( - modifier = Modifier.fillMaxHeight(), + modifier = Modifier.fillMaxHeight().widthIn(min = if (isNavExpanded) 112.dp else 72.dp), ) { - IconButton(onClick = { isNavExpanded = !isNavExpanded }) { - Icon( - imageVector = if (isNavExpanded) Icons.Default.MenuOpen else Icons.Default.Menu, - contentDescription = if (isNavExpanded) "Collapse navigation" else "Expand navigation", - ) - } - - destinations.forEach { destination -> - NavigationRailItem( - selected = currentRoute == destination.route, - onClick = { navigateTo(destination.route) }, - icon = { - Icon( - imageVector = destination.icon, - contentDescription = destination.label, - ) + Column( + modifier = Modifier.fillMaxHeight(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + IconButton( + onClick = { + val nextValue = !isNavExpanded + isNavExpanded = nextValue + settingsPrefs.edit().putBoolean(KEY_NAV_RAIL_EXPANDED, nextValue).apply() }, - label = - if (isNavExpanded) { - { Text(destination.label) } - } else { - null + ) { + Icon( + imageVector = if (isNavExpanded) Icons.Default.MenuOpen else Icons.Default.Menu, + contentDescription = if (isNavExpanded) "Collapse navigation" else "Expand navigation", + ) + } + + destinations.forEach { destination -> + NavigationRailItem( + selected = currentRoute == destination.route, + onClick = { navigateTo(destination.route) }, + icon = { + Icon( + imageVector = destination.icon, + contentDescription = destination.label, + ) }, - alwaysShowLabel = isNavExpanded, - ) + label = + if (isNavExpanded) { + { Text(destination.label) } + } else { + null + }, + alwaysShowLabel = isNavExpanded, + ) + } } } diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt index 878e2bf..730019b 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt @@ -117,6 +117,7 @@ import com.ayagmar.pimobile.sessions.SlashCommandInfo import com.ayagmar.pimobile.ui.settings.KEY_SHOW_EXTENSION_STATUS_STRIP import com.ayagmar.pimobile.ui.settings.SETTINGS_PREFS_NAME import kotlinx.coroutines.delay +import kotlinx.coroutines.launch private data class ChatCallbacks( val onToggleToolExpansion: (String) -> Unit, @@ -736,6 +737,7 @@ private fun ChatBody( hiddenHistoryCount = hiddenHistoryCount, expandedToolArguments = expandedToolArguments, showInlineRunProgress = showInlineRunProgress, + isRunActive = isRunActive, runPhase = runPhase, runElapsedSeconds = runElapsedSeconds, onLoadOlderMessages = callbacks.onLoadOlderMessages, @@ -756,6 +758,7 @@ private fun ChatTimeline( hiddenHistoryCount: Int, expandedToolArguments: Set, showInlineRunProgress: Boolean, + isRunActive: Boolean, runPhase: LiveRunPhase, runElapsedSeconds: Long, onLoadOlderMessages: () -> Unit, @@ -766,6 +769,7 @@ private fun ChatTimeline( modifier: Modifier = Modifier, ) { val listState = androidx.compose.foundation.lazy.rememberLazyListState() + val coroutineScope = androidx.compose.runtime.rememberCoroutineScope() val totalItems = timeline.size + if (showInlineRunProgress) 1 else 0 val shouldAutoScrollToBottom by remember { @@ -777,6 +781,7 @@ private fun ChatTimeline( lastItemIndex <= 0 || lastVisibleIndex >= lastItemIndex - AUTO_SCROLL_BOTTOM_THRESHOLD_ITEMS } } + val shouldShowJumpToLatest = isRunActive && totalItems > 0 && !shouldAutoScrollToBottom // Auto-scroll only while the user stays near the bottom. // This avoids jumping when loading older history or reading past messages. @@ -786,61 +791,82 @@ private fun ChatTimeline( } } - LazyColumn( - state = listState, - modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (hasOlderMessages) { - item(key = "load-older-messages") { - TextButton( - onClick = onLoadOlderMessages, - modifier = Modifier.fillMaxWidth(), - ) { - Text("Load older messages ($hiddenHistoryCount hidden)") + Box(modifier = modifier.fillMaxWidth()) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (hasOlderMessages) { + item(key = "load-older-messages") { + TextButton( + onClick = onLoadOlderMessages, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Load older messages ($hiddenHistoryCount hidden)") + } } } - } - items(items = timeline, key = { item -> item.id }) { item -> - when (item) { - is ChatTimelineItem.User -> { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - UserCard( - text = item.text, - imageCount = item.imageCount, - imageUris = item.imageUris, + items(items = timeline, key = { item -> item.id }) { item -> + when (item) { + is ChatTimelineItem.User -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + UserCard( + text = item.text, + imageCount = item.imageCount, + imageUris = item.imageUris, + ) + } + } + + is ChatTimelineItem.Assistant -> { + AssistantCard( + item = item, + onToggleThinkingExpansion = onToggleThinkingExpansion, + ) + } + + is ChatTimelineItem.Tool -> { + ToolCard( + item = item, + isArgumentsExpanded = item.id in expandedToolArguments, + onToggleToolExpansion = onToggleToolExpansion, + onToggleDiffExpansion = onToggleDiffExpansion, + onToggleArgumentsExpansion = onToggleToolArgumentsExpansion, ) } } - is ChatTimelineItem.Assistant -> { - AssistantCard( - item = item, - onToggleThinkingExpansion = onToggleThinkingExpansion, - ) - } + } - is ChatTimelineItem.Tool -> { - ToolCard( - item = item, - isArgumentsExpanded = item.id in expandedToolArguments, - onToggleToolExpansion = onToggleToolExpansion, - onToggleDiffExpansion = onToggleDiffExpansion, - onToggleArgumentsExpansion = onToggleToolArgumentsExpansion, + if (showInlineRunProgress) { + item(key = "inline-run-progress") { + InlineRunProgressCard( + phase = runPhase, + elapsedSeconds = runElapsedSeconds, ) } } } - if (showInlineRunProgress) { - item(key = "inline-run-progress") { - InlineRunProgressCard( - phase = runPhase, - elapsedSeconds = runElapsedSeconds, - ) + AnimatedVisibility( + visible = shouldShowJumpToLatest, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp), + ) { + OutlinedButton( + onClick = { + coroutineScope.launch { + listState.animateScrollToItem(totalItems - 1) + } + }, + modifier = Modifier.testTag(CHAT_JUMP_TO_LATEST_TAG), + ) { + Text("Jump to latest") } } } @@ -1583,6 +1609,7 @@ internal fun PromptControls( } } +@Suppress("LongMethod") @Composable private fun StreamingControls( isRetrying: Boolean, @@ -1591,57 +1618,66 @@ private fun StreamingControls( onSteerClick: () -> Unit, onFollowUpClick: () -> Unit, ) { - Row( + Column( modifier = Modifier.fillMaxWidth().testTag(CHAT_STREAMING_CONTROLS_TAG), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Button( - onClick = onAbort, - modifier = Modifier.weight(1f), - colors = - androidx.compose.material3.ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error, - ), - contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp, vertical = 8.dp), + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Icon( - imageVector = Icons.Default.Close, - contentDescription = "Abort", - modifier = Modifier.padding(end = 4.dp), - ) - Text( - text = "Abort", - maxLines = 1, - softWrap = false, - overflow = TextOverflow.Ellipsis, - ) - } - - if (isRetrying) { Button( - onClick = onAbortRetry, + onClick = onAbort, modifier = Modifier.weight(1f), colors = androidx.compose.material3.ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.error, ), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp, vertical = 8.dp), ) { - Text(text = "Abort Retry", maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis) + Icon( + imageVector = Icons.Default.Stop, + contentDescription = null, + modifier = Modifier.padding(end = 4.dp), + ) + Text( + text = "Abort", + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + ) } - } else { - Button( - onClick = onSteerClick, - modifier = Modifier.weight(1f), - ) { - Text(text = "Steer", maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis) + + if (isRetrying) { + OutlinedButton( + onClick = onAbortRetry, + modifier = Modifier.weight(1f), + ) { + Text(text = "Abort Retry", maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis) + } } + } - Button( - onClick = onFollowUpClick, - modifier = Modifier.weight(1f), + if (!isRetrying) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Text(text = "Follow Up", maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis) + OutlinedButton( + onClick = onSteerClick, + modifier = Modifier.weight(1f), + ) { + Text(text = "Steer", maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis) + } + + OutlinedButton( + onClick = onFollowUpClick, + modifier = Modifier.weight(1f), + ) { + Text(text = "Follow Up", maxLines = 1, softWrap = false, overflow = TextOverflow.Ellipsis) + } } } } @@ -1898,9 +1934,9 @@ private fun ImageThumbnail( modifier = Modifier .align(Alignment.TopEnd) - .size(20.dp) - .clip(RoundedCornerShape(10.dp)) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.8f)), + .size(32.dp) + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.85f)), ) { Icon( imageVector = Icons.Default.Close, @@ -2067,11 +2103,35 @@ private fun ModelThinkingControls( } } +@Suppress("LongMethod") @Composable private fun ExtensionStatusStrip(statuses: Map) { if (statuses.isEmpty()) return var expanded by rememberSaveable { mutableStateOf(false) } + var previousStatuses by remember { mutableStateOf>(emptyMap()) } + var hasPreviousSnapshot by remember { mutableStateOf(false) } + + val comparisonSnapshot = + if (hasPreviousSnapshot) { + previousStatuses + } else { + statuses.mapValues { (_, value) -> value.trim() } + } + + val presentation = + remember(statuses, comparisonSnapshot, expanded) { + buildExtensionStatusPresentation( + statuses = statuses, + previousStatuses = comparisonSnapshot, + expanded = expanded, + ) + } + + LaunchedEffect(statuses) { + previousStatuses = statuses.mapValues { (_, value) -> value.trim() } + hasPreviousSnapshot = true + } Card( modifier = Modifier.fillMaxWidth(), @@ -2079,45 +2139,178 @@ private fun ExtensionStatusStrip(statuses: Map) { ) { Column( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = "Extension status (${statuses.size})", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = "Extension status (${statuses.size})", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${presentation.activeCount} active · ${presentation.quietCount} quiet", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + TextButton(onClick = { expanded = !expanded }) { Text(if (expanded) "Hide" else "Show") } } - val orderedStatuses = statuses.toSortedMap() - val visibleStatuses = if (expanded) orderedStatuses.entries else orderedStatuses.entries.take(2) - visibleStatuses.forEach { (key, value) -> + if (!expanded) { + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + items( + items = presentation.visibleEntries, + key = { entry -> entry.key }, + ) { entry -> + StatusPill(entry = entry) + } + } + } else { + presentation.visibleEntries.forEach { entry -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = if (entry.isChanged) "•" else "", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.padding(top = 2.dp), + ) + Text( + text = "${entry.key}: ${entry.value.take(STATUS_VALUE_MAX_LENGTH)}", + style = MaterialTheme.typography.bodySmall, + maxLines = 4, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + } + } + + if (!expanded && presentation.hiddenCount > 0) { Text( - text = "$key: ${value.take(STATUS_VALUE_MAX_LENGTH)}", - style = MaterialTheme.typography.bodySmall, - maxLines = if (expanded) 4 else 1, - overflow = TextOverflow.Ellipsis, + text = "+${presentation.hiddenCount} more", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - if (!expanded && statuses.size > 2) { + if (presentation.changedCount > 0) { Text( - text = "+${statuses.size - 2} more", + text = "${presentation.changedCount} update(s) since last refresh", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.colorScheme.tertiary, ) } } } } +@Composable +private fun StatusPill(entry: ExtensionStatusEntry) { + Surface( + shape = RoundedCornerShape(14.dp), + color = + if (entry.isLowSignal) { + MaterialTheme.colorScheme.surface + } else { + MaterialTheme.colorScheme.secondaryContainer + }, + ) { + Text( + text = "${entry.key}: ${entry.value.take(EXTENSION_STATUS_PILL_MAX_LENGTH)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + ) + } +} + +internal data class ExtensionStatusEntry( + val key: String, + val value: String, + val isLowSignal: Boolean, + val isChanged: Boolean, +) + +internal data class ExtensionStatusPresentation( + val visibleEntries: List, + val hiddenCount: Int, + val activeCount: Int, + val quietCount: Int, + val changedCount: Int, +) + +internal fun buildExtensionStatusPresentation( + statuses: Map, + previousStatuses: Map, + expanded: Boolean, +): ExtensionStatusPresentation { + if (statuses.isEmpty()) { + return ExtensionStatusPresentation( + visibleEntries = emptyList(), + hiddenCount = 0, + activeCount = 0, + quietCount = 0, + changedCount = 0, + ) + } + + val entries = + statuses + .toSortedMap() + .map { (key, rawValue) -> + val value = rawValue.trim().ifEmpty { "(empty)" } + ExtensionStatusEntry( + key = key, + value = value, + isLowSignal = isLowSignalExtensionStatus(value), + isChanged = previousStatuses[key] != value, + ) + } + + val changed = entries.filter { it.isChanged } + val active = entries.filterNot { it.isLowSignal } + val quietCount = entries.size - active.size + + val compactCandidates = + when { + changed.isNotEmpty() -> changed + active.isNotEmpty() -> active + else -> entries + } + + val visibleEntries = if (expanded) entries else compactCandidates.take(MAX_COMPACT_EXTENSION_STATUS_ITEMS) + + return ExtensionStatusPresentation( + visibleEntries = visibleEntries, + hiddenCount = if (expanded) 0 else (entries.size - visibleEntries.size).coerceAtLeast(0), + activeCount = active.size, + quietCount = quietCount, + changedCount = changed.size, + ) +} + +internal fun isLowSignalExtensionStatus(value: String): Boolean { + val normalized = value.trim().lowercase() + return LOW_SIGNAL_STATUS_TOKENS.any { token -> normalized.contains(token) } +} + @Composable private fun ExtensionWidgets( widgets: Map, @@ -2151,6 +2344,7 @@ internal const val CHAT_PROMPT_CONTROLS_TAG = "chat_prompt_controls" internal const val CHAT_STREAMING_CONTROLS_TAG = "chat_streaming_controls" internal const val CHAT_PROMPT_INPUT_ROW_TAG = "chat_prompt_input_row" internal const val CHAT_RUN_PROGRESS_TAG = "chat_run_progress" +internal const val CHAT_JUMP_TO_LATEST_TAG = "chat_jump_to_latest" private const val COLLAPSED_OUTPUT_LENGTH = 280 private const val THINKING_COLLAPSE_THRESHOLD = 280 @@ -2160,6 +2354,8 @@ private const val USER_IMAGE_PREVIEW_SIZE_DP = 56 private const val AUTO_SCROLL_BOTTOM_THRESHOLD_ITEMS = 2 private const val TOOL_HIGHLIGHT_MAX_LENGTH = 1_000 private const val STATUS_VALUE_MAX_LENGTH = 180 +private const val EXTENSION_STATUS_PILL_MAX_LENGTH = 56 +private const val MAX_COMPACT_EXTENSION_STATUS_ITEMS = 2 private const val RUN_PROGRESS_TICK_MS = 1_000L private const val STREAMING_FRAME_LOG_TAG = "StreamingFrameMetrics" private val THINKING_LEVEL_OPTIONS = listOf("off", "minimal", "low", "medium", "high", "xhigh") @@ -2217,6 +2413,16 @@ private val TOOL_OUTPUT_LANGUAGE_BY_EXTENSION = "rs" to "rust", "md" to "markdown", ) +private val LOW_SIGNAL_STATUS_TOKENS = + setOf( + "idle", + "ready", + "ok", + "connected", + "none", + "no updates", + "synced", + ) @Suppress("LongParameterList", "LongMethod") @Composable @@ -2331,12 +2537,12 @@ private fun BashDialog( if (output.isNotEmpty()) { IconButton( onClick = { clipboardManager.setText(AnnotatedString(output)) }, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(40.dp), ) { Icon( imageVector = Icons.Default.ContentCopy, contentDescription = "Copy output", - modifier = Modifier.size(16.dp), + modifier = Modifier.size(18.dp), ) } } diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsPreferences.kt b/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsPreferences.kt index 1a26e1e..526b22c 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsPreferences.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsPreferences.kt @@ -3,3 +3,4 @@ package com.ayagmar.pimobile.ui.settings const val SETTINGS_PREFS_NAME = "pi_mobile_settings" const val KEY_THEME_PREFERENCE = "theme_preference" const val KEY_SHOW_EXTENSION_STATUS_STRIP = "show_extension_status_strip" +const val KEY_NAV_RAIL_EXPANDED = "nav_rail_expanded" diff --git a/app/src/test/java/com/ayagmar/pimobile/ui/chat/ExtensionStatusPresentationTest.kt b/app/src/test/java/com/ayagmar/pimobile/ui/chat/ExtensionStatusPresentationTest.kt new file mode 100644 index 0000000..cc3583c --- /dev/null +++ b/app/src/test/java/com/ayagmar/pimobile/ui/chat/ExtensionStatusPresentationTest.kt @@ -0,0 +1,62 @@ +package com.ayagmar.pimobile.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ExtensionStatusPresentationTest { + @Test + fun compactPresentationPrioritizesChangedStatuses() { + val presentation = + buildExtensionStatusPresentation( + statuses = + mapOf( + "git" to "idle", + "lsp" to "indexing workspace", + "search" to "ready", + ), + previousStatuses = + mapOf( + "git" to "idle", + "lsp" to "idle", + "search" to "ready", + ), + expanded = false, + ) + + assertEquals(1, presentation.changedCount) + assertEquals(1, presentation.visibleEntries.size) + assertEquals("lsp", presentation.visibleEntries.first().key) + assertEquals(2, presentation.hiddenCount) + assertEquals(1, presentation.activeCount) + assertEquals(2, presentation.quietCount) + } + + @Test + fun expandedPresentationShowsAllStatuses() { + val presentation = + buildExtensionStatusPresentation( + statuses = + mapOf( + "alpha" to "busy", + "beta" to "ready", + "gamma" to "running", + ), + previousStatuses = emptyMap(), + expanded = true, + ) + + assertEquals(3, presentation.visibleEntries.size) + assertEquals(0, presentation.hiddenCount) + assertEquals(listOf("alpha", "beta", "gamma"), presentation.visibleEntries.map { it.key }) + } + + @Test + fun lowSignalStatusHeuristicsClassifyIdleStates() { + assertTrue(isLowSignalExtensionStatus("ready")) + assertTrue(isLowSignalExtensionStatus("Connected and synced")) + assertFalse(isLowSignalExtensionStatus("running command")) + assertFalse(isLowSignalExtensionStatus("error: failed to start")) + } +} From e2537a3fcf72747c85e7a4122bfc9f22b1387c5c Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Mon, 16 Feb 2026 23:08:39 +0000 Subject: [PATCH 10/32] fix(ui): replace blocking rail with collapsible left drawer --- .../com/ayagmar/pimobile/ui/PiMobileApp.kt | 191 ++++++++++-------- 1 file changed, 109 insertions(+), 82 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt b/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt index 83e96b1..53968fe 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt @@ -1,13 +1,14 @@ package com.ayagmar.pimobile.ui import android.content.Context -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Chat import androidx.compose.material.icons.filled.Computer @@ -15,11 +16,16 @@ import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MenuOpen import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Storage +import androidx.compose.material3.Divider +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.FilledTonalIconButton import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.NavigationRail -import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect @@ -39,12 +45,12 @@ import com.ayagmar.pimobile.di.AppGraph import com.ayagmar.pimobile.ui.chat.ChatRoute import com.ayagmar.pimobile.ui.hosts.HostsRoute import com.ayagmar.pimobile.ui.sessions.SessionsRoute -import com.ayagmar.pimobile.ui.settings.KEY_NAV_RAIL_EXPANDED import com.ayagmar.pimobile.ui.settings.KEY_THEME_PREFERENCE import com.ayagmar.pimobile.ui.settings.SETTINGS_PREFS_NAME import com.ayagmar.pimobile.ui.settings.SettingsRoute import com.ayagmar.pimobile.ui.theme.PiMobileTheme import com.ayagmar.pimobile.ui.theme.ThemePreference +import kotlinx.coroutines.launch private data class AppDestination( val route: String, @@ -91,21 +97,12 @@ fun piMobileApp(appGraph: AppGraph) { ), ) } - var isNavExpanded by remember(settingsPrefs) { - mutableStateOf(settingsPrefs.getBoolean(KEY_NAV_RAIL_EXPANDED, false)) - } DisposableEffect(settingsPrefs) { val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { prefs, key -> - when (key) { - KEY_THEME_PREFERENCE -> { - themePreference = ThemePreference.fromValue(prefs.getString(KEY_THEME_PREFERENCE, null)) - } - - KEY_NAV_RAIL_EXPANDED -> { - isNavExpanded = prefs.getBoolean(KEY_NAV_RAIL_EXPANDED, false) - } + if (key == KEY_THEME_PREFERENCE) { + themePreference = ThemePreference.fromValue(prefs.getString(KEY_THEME_PREFERENCE, null)) } } settingsPrefs.registerOnSharedPreferenceChangeListener(listener) @@ -118,6 +115,8 @@ fun piMobileApp(appGraph: AppGraph) { val navController = rememberNavController() val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route + val drawerState = androidx.compose.material3.rememberDrawerState(DrawerValue.Closed) + val scope = androidx.compose.runtime.rememberCoroutineScope() fun navigateTo(route: String) { navController.navigate(route) { @@ -129,81 +128,109 @@ fun piMobileApp(appGraph: AppGraph) { } } - Scaffold { paddingValues -> - Row( - modifier = Modifier.fillMaxSize().padding(paddingValues), - ) { - NavigationRail( - modifier = Modifier.fillMaxHeight().widthIn(min = if (isNavExpanded) 112.dp else 72.dp), + ModalNavigationDrawer( + drawerState = drawerState, + gesturesEnabled = drawerState.isOpen, + drawerContent = { + ModalDrawerSheet( + modifier = Modifier.widthIn(min = 248.dp, max = 300.dp), ) { - Column( - modifier = Modifier.fillMaxHeight(), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - IconButton( + Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 20.dp)) { + Text( + text = "Navigation", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "Opens from the left side. Tap outside to close.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Divider() + Spacer(modifier = Modifier.height(8.dp)) + + destinations.forEach { destination -> + NavigationDrawerItem( + selected = currentRoute == destination.route, onClick = { - val nextValue = !isNavExpanded - isNavExpanded = nextValue - settingsPrefs.edit().putBoolean(KEY_NAV_RAIL_EXPANDED, nextValue).apply() + navigateTo(destination.route) + scope.launch { drawerState.close() } }, - ) { - Icon( - imageVector = if (isNavExpanded) Icons.Default.MenuOpen else Icons.Default.Menu, - contentDescription = if (isNavExpanded) "Collapse navigation" else "Expand navigation", + label = { Text(destination.label) }, + icon = { + Icon( + imageVector = destination.icon, + contentDescription = destination.label, + ) + }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), + ) + } + } + }, + ) { + Scaffold { paddingValues -> + Box( + modifier = Modifier.fillMaxSize().padding(paddingValues), + ) { + NavHost( + navController = navController, + startDestination = "sessions", + modifier = Modifier.fillMaxSize(), + ) { + composable(route = "hosts") { + HostsRoute( + profileStore = appGraph.hostProfileStore, + tokenStore = appGraph.hostTokenStore, + diagnostics = appGraph.connectionDiagnostics, ) } - - destinations.forEach { destination -> - NavigationRailItem( - selected = currentRoute == destination.route, - onClick = { navigateTo(destination.route) }, - icon = { - Icon( - imageVector = destination.icon, - contentDescription = destination.label, - ) + composable(route = "sessions") { + SessionsRoute( + profileStore = appGraph.hostProfileStore, + tokenStore = appGraph.hostTokenStore, + repository = appGraph.sessionIndexRepository, + sessionController = appGraph.sessionController, + cwdPreferenceStore = appGraph.sessionCwdPreferenceStore, + onNavigateToChat = { + navigateTo("chat") }, - label = - if (isNavExpanded) { - { Text(destination.label) } - } else { - null - }, - alwaysShowLabel = isNavExpanded, ) } + composable(route = "chat") { + ChatRoute(sessionController = appGraph.sessionController) + } + composable(route = "settings") { + SettingsRoute(sessionController = appGraph.sessionController) + } } - } - NavHost( - navController = navController, - startDestination = "sessions", - modifier = Modifier.weight(1f), - ) { - composable(route = "hosts") { - HostsRoute( - profileStore = appGraph.hostProfileStore, - tokenStore = appGraph.hostTokenStore, - diagnostics = appGraph.connectionDiagnostics, - ) - } - composable(route = "sessions") { - SessionsRoute( - profileStore = appGraph.hostProfileStore, - tokenStore = appGraph.hostTokenStore, - repository = appGraph.sessionIndexRepository, - sessionController = appGraph.sessionController, - cwdPreferenceStore = appGraph.sessionCwdPreferenceStore, - onNavigateToChat = { - navigateTo("chat") + Surface( + shape = CircleShape, + tonalElevation = 4.dp, + modifier = Modifier.padding(start = 12.dp, top = 8.dp), + ) { + FilledTonalIconButton( + onClick = { + scope.launch { + if (drawerState.isOpen) { + drawerState.close() + } else { + drawerState.open() + } + } }, - ) - } - composable(route = "chat") { - ChatRoute(sessionController = appGraph.sessionController) - } - composable(route = "settings") { - SettingsRoute(sessionController = appGraph.sessionController) + ) { + Icon( + imageVector = if (drawerState.isOpen) Icons.Default.MenuOpen else Icons.Default.Menu, + contentDescription = + if (drawerState.isOpen) { + "Close left navigation" + } else { + "Open left navigation" + }, + ) + } } } } From d78db222f5f68152581f6120b62d237e3302592e Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Mon, 16 Feb 2026 23:09:39 +0000 Subject: [PATCH 11/32] fix(chat): show jump-to-latest when scrolled away from bottom --- app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt index 730019b..98f555b 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt @@ -737,7 +737,6 @@ private fun ChatBody( hiddenHistoryCount = hiddenHistoryCount, expandedToolArguments = expandedToolArguments, showInlineRunProgress = showInlineRunProgress, - isRunActive = isRunActive, runPhase = runPhase, runElapsedSeconds = runElapsedSeconds, onLoadOlderMessages = callbacks.onLoadOlderMessages, @@ -758,7 +757,6 @@ private fun ChatTimeline( hiddenHistoryCount: Int, expandedToolArguments: Set, showInlineRunProgress: Boolean, - isRunActive: Boolean, runPhase: LiveRunPhase, runElapsedSeconds: Long, onLoadOlderMessages: () -> Unit, @@ -781,7 +779,7 @@ private fun ChatTimeline( lastItemIndex <= 0 || lastVisibleIndex >= lastItemIndex - AUTO_SCROLL_BOTTOM_THRESHOLD_ITEMS } } - val shouldShowJumpToLatest = isRunActive && totalItems > 0 && !shouldAutoScrollToBottom + val shouldShowJumpToLatest = totalItems > 0 && !shouldAutoScrollToBottom // Auto-scroll only while the user stays near the bottom. // This avoids jumping when loading older history or reading past messages. From a7474cdb1af730698ecef4b594d5d30c829e5f60 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Mon, 16 Feb 2026 23:11:28 +0000 Subject: [PATCH 12/32] fix(ui): polish drawer layout and active nav styling --- .../com/ayagmar/pimobile/ui/PiMobileApp.kt | 122 +++++++++++++----- 1 file changed, 91 insertions(+), 31 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt b/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt index 53968fe..16827c7 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt @@ -1,14 +1,17 @@ package com.ayagmar.pimobile.ui import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Chat import androidx.compose.material.icons.filled.Computer @@ -16,14 +19,15 @@ import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MenuOpen import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Storage -import androidx.compose.material3.Divider import androidx.compose.material3.DrawerValue import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.NavigationDrawerItem +import androidx.compose.material3.NavigationDrawerItemDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -33,6 +37,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext @@ -134,37 +139,90 @@ fun piMobileApp(appGraph: AppGraph) { drawerContent = { ModalDrawerSheet( modifier = Modifier.widthIn(min = 248.dp, max = 300.dp), + drawerShape = RoundedCornerShape(topEnd = 24.dp, bottomEnd = 24.dp), ) { - Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 20.dp)) { - Text( - text = "Navigation", - style = MaterialTheme.typography.titleMedium, - ) + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.55f), + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = "Navigation", + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = "Slides from the left. Tap outside to close.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + HorizontalDivider() + Text( - text = "Opens from the left side. Tap outside to close.", - style = MaterialTheme.typography.bodySmall, + text = "WORKSPACE", + style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp), ) - } - Divider() - Spacer(modifier = Modifier.height(8.dp)) - destinations.forEach { destination -> - NavigationDrawerItem( - selected = currentRoute == destination.route, - onClick = { - navigateTo(destination.route) - scope.launch { drawerState.close() } - }, - label = { Text(destination.label) }, - icon = { - Icon( - imageVector = destination.icon, - contentDescription = destination.label, - ) - }, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp), - ) + destinations.forEach { destination -> + val selected = currentRoute == destination.route + NavigationDrawerItem( + selected = selected, + onClick = { + navigateTo(destination.route) + scope.launch { drawerState.close() } + }, + label = { + Text( + text = destination.label, + style = MaterialTheme.typography.bodyLarge, + ) + }, + icon = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = + Modifier + .size(6.dp) + .background( + color = + if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant + }, + shape = CircleShape, + ), + ) + Icon( + imageVector = destination.icon, + contentDescription = destination.label, + ) + } + }, + shape = RoundedCornerShape(14.dp), + colors = + NavigationDrawerItemDefaults.colors( + selectedContainerColor = + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.52f), + unselectedContainerColor = MaterialTheme.colorScheme.surface, + ), + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + ) + } } } }, @@ -207,8 +265,10 @@ fun piMobileApp(appGraph: AppGraph) { Surface( shape = CircleShape, - tonalElevation = 4.dp, - modifier = Modifier.padding(start = 12.dp, top = 8.dp), + tonalElevation = 6.dp, + shadowElevation = 8.dp, + color = MaterialTheme.colorScheme.surface, + modifier = Modifier.padding(start = 12.dp, top = 10.dp), ) { FilledTonalIconButton( onClick = { From f6b86da1798e19c3a05ebeaf0eb7e2b8632f4919 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Mon, 16 Feb 2026 23:13:11 +0000 Subject: [PATCH 13/32] feat(ui): animate drawer active indicator and selection --- .../com/ayagmar/pimobile/ui/PiMobileApp.kt | 131 ++++++++++++------ 1 file changed, 89 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt b/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt index 16827c7..96dba73 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt @@ -1,6 +1,9 @@ package com.ayagmar.pimobile.ui import android.content.Context +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -39,6 +42,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp @@ -87,6 +91,88 @@ private val destinations = ), ) +@Suppress("LongMethod") +@Composable +private fun DrawerDestinationItem( + destination: AppDestination, + selected: Boolean, + onClick: () -> Unit, +) { + val itemShape = RoundedCornerShape(14.dp) + val itemColor by + animateColorAsState( + targetValue = + if (selected) { + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.58f) + } else { + MaterialTheme.colorScheme.surface + }, + animationSpec = tween(durationMillis = 180), + label = "drawer_item_color", + ) + val dotColor by + animateColorAsState( + targetValue = + if (selected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant + }, + animationSpec = tween(durationMillis = 180), + label = "drawer_dot_color", + ) + val dotSize by + animateDpAsState( + targetValue = if (selected) 8.dp else 6.dp, + animationSpec = tween(durationMillis = 180), + label = "drawer_dot_size", + ) + + Surface( + shape = itemShape, + color = itemColor, + tonalElevation = if (selected) 2.dp else 0.dp, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + ) { + NavigationDrawerItem( + selected = selected, + onClick = onClick, + label = { + Text( + text = destination.label, + style = MaterialTheme.typography.bodyLarge, + ) + }, + icon = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = + Modifier + .size(dotSize) + .background( + color = dotColor, + shape = CircleShape, + ), + ) + Icon( + imageVector = destination.icon, + contentDescription = destination.label, + ) + } + }, + shape = itemShape, + colors = + NavigationDrawerItemDefaults.colors( + selectedContainerColor = Color.Transparent, + unselectedContainerColor = Color.Transparent, + ), + ) + } +} + @Suppress("LongMethod") @Composable fun piMobileApp(appGraph: AppGraph) { @@ -175,52 +261,13 @@ fun piMobileApp(appGraph: AppGraph) { ) destinations.forEach { destination -> - val selected = currentRoute == destination.route - NavigationDrawerItem( - selected = selected, + DrawerDestinationItem( + destination = destination, + selected = currentRoute == destination.route, onClick = { navigateTo(destination.route) scope.launch { drawerState.close() } }, - label = { - Text( - text = destination.label, - style = MaterialTheme.typography.bodyLarge, - ) - }, - icon = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box( - modifier = - Modifier - .size(6.dp) - .background( - color = - if (selected) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outlineVariant - }, - shape = CircleShape, - ), - ) - Icon( - imageVector = destination.icon, - contentDescription = destination.label, - ) - } - }, - shape = RoundedCornerShape(14.dp), - colors = - NavigationDrawerItemDefaults.colors( - selectedContainerColor = - MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.52f), - unselectedContainerColor = MaterialTheme.colorScheme.surface, - ), - modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), ) } } From 501b7e744147af7532f7f136331a9c888116923c Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Mon, 16 Feb 2026 23:24:38 +0000 Subject: [PATCH 14/32] fix(ui): unblock drawer and refine status/settings UX --- .../com/ayagmar/pimobile/ui/PiMobileApp.kt | 33 ++++- .../ayagmar/pimobile/ui/chat/ChatOverlays.kt | 30 ++-- .../ayagmar/pimobile/ui/chat/ChatScreen.kt | 134 +++++++++--------- .../pimobile/ui/settings/SettingsScreen.kt | 94 ++++++++---- 4 files changed, 177 insertions(+), 114 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt b/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt index 96dba73..7d2f0d2 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt @@ -10,8 +10,10 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -54,6 +56,7 @@ import com.ayagmar.pimobile.di.AppGraph import com.ayagmar.pimobile.ui.chat.ChatRoute import com.ayagmar.pimobile.ui.hosts.HostsRoute import com.ayagmar.pimobile.ui.sessions.SessionsRoute +import com.ayagmar.pimobile.ui.settings.KEY_SHOW_EXTENSION_STATUS_STRIP import com.ayagmar.pimobile.ui.settings.KEY_THEME_PREFERENCE import com.ayagmar.pimobile.ui.settings.SETTINGS_PREFS_NAME import com.ayagmar.pimobile.ui.settings.SettingsRoute @@ -188,12 +191,21 @@ fun piMobileApp(appGraph: AppGraph) { ), ) } + var showExtensionStatusStrip by remember(settingsPrefs) { + mutableStateOf(settingsPrefs.getBoolean(KEY_SHOW_EXTENSION_STATUS_STRIP, true)) + } DisposableEffect(settingsPrefs) { val listener = android.content.SharedPreferences.OnSharedPreferenceChangeListener { prefs, key -> - if (key == KEY_THEME_PREFERENCE) { - themePreference = ThemePreference.fromValue(prefs.getString(KEY_THEME_PREFERENCE, null)) + when (key) { + KEY_THEME_PREFERENCE -> { + themePreference = ThemePreference.fromValue(prefs.getString(KEY_THEME_PREFERENCE, null)) + } + + KEY_SHOW_EXTENSION_STATUS_STRIP -> { + showExtensionStatusStrip = prefs.getBoolean(KEY_SHOW_EXTENSION_STATUS_STRIP, true) + } } } settingsPrefs.registerOnSharedPreferenceChangeListener(listener) @@ -222,9 +234,10 @@ fun piMobileApp(appGraph: AppGraph) { ModalNavigationDrawer( drawerState = drawerState, gesturesEnabled = drawerState.isOpen, + scrimColor = Color.Transparent, drawerContent = { ModalDrawerSheet( - modifier = Modifier.widthIn(min = 248.dp, max = 300.dp), + modifier = Modifier.widthIn(min = 220.dp, max = 270.dp), drawerShape = RoundedCornerShape(topEnd = 24.dp, bottomEnd = 24.dp), ) { Column( @@ -303,7 +316,10 @@ fun piMobileApp(appGraph: AppGraph) { ) } composable(route = "chat") { - ChatRoute(sessionController = appGraph.sessionController) + ChatRoute( + sessionController = appGraph.sessionController, + showExtensionStatusStrip = showExtensionStatusStrip, + ) } composable(route = "settings") { SettingsRoute(sessionController = appGraph.sessionController) @@ -312,12 +328,17 @@ fun piMobileApp(appGraph: AppGraph) { Surface( shape = CircleShape, - tonalElevation = 6.dp, + tonalElevation = 5.dp, shadowElevation = 8.dp, color = MaterialTheme.colorScheme.surface, - modifier = Modifier.padding(start = 12.dp, top = 10.dp), + modifier = + Modifier + .align(Alignment.TopStart) + .statusBarsPadding() + .offset(x = (-6).dp, y = 2.dp), ) { FilledTonalIconButton( + modifier = Modifier.size(42.dp), onClick = { scope.launch { if (drawerState.isOpen) { diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatOverlays.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatOverlays.kt index d1e5b8b..d91d673 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatOverlays.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatOverlays.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding @@ -233,19 +234,24 @@ internal fun NotificationsDisplay( else -> MaterialTheme.colorScheme.primaryContainer } - Snackbar( - action = { - TextButton(onClick = { onClear(index) }) { - Text("Dismiss") - } - }, - containerColor = containerColor, - modifier = Modifier.padding(8.dp), + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.BottomCenter, ) { - Text( - text = latestNotification.message, - color = color, - ) + Snackbar( + action = { + TextButton(onClick = { onClear(index) }) { + Text("Dismiss") + } + }, + containerColor = containerColor, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 16.dp), + ) { + Text( + text = latestNotification.message, + color = color, + ) + } } } diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt index 98f555b..b2c6e6f 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt @@ -69,7 +69,6 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -114,8 +113,6 @@ import com.ayagmar.pimobile.sessions.SessionController import com.ayagmar.pimobile.sessions.SessionTreeEntry import com.ayagmar.pimobile.sessions.SessionTreeSnapshot import com.ayagmar.pimobile.sessions.SlashCommandInfo -import com.ayagmar.pimobile.ui.settings.KEY_SHOW_EXTENSION_STATUS_STRIP -import com.ayagmar.pimobile.ui.settings.SETTINGS_PREFS_NAME import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -185,7 +182,10 @@ internal data class PromptControlsCallbacks( @Suppress("LongMethod") @Composable -fun ChatRoute(sessionController: SessionController) { +fun ChatRoute( + sessionController: SessionController, + showExtensionStatusStrip: Boolean, +) { val context = LocalContext.current val imageEncoder = remember { ImageEncoder(context) } val factory = @@ -198,27 +198,6 @@ fun ChatRoute(sessionController: SessionController) { val chatViewModel: ChatViewModel = viewModel(factory = factory) val uiState by chatViewModel.uiState.collectAsStateWithLifecycle() - val settingsPrefs = - remember(context) { - context.getSharedPreferences(SETTINGS_PREFS_NAME, android.content.Context.MODE_PRIVATE) - } - var showExtensionStatusStrip by remember(settingsPrefs) { - mutableStateOf(settingsPrefs.getBoolean(KEY_SHOW_EXTENSION_STATUS_STRIP, true)) - } - - DisposableEffect(settingsPrefs) { - val listener = - android.content.SharedPreferences.OnSharedPreferenceChangeListener { prefs, key -> - if (key == KEY_SHOW_EXTENSION_STATUS_STRIP) { - showExtensionStatusStrip = prefs.getBoolean(KEY_SHOW_EXTENSION_STATUS_STRIP, true) - } - } - settingsPrefs.registerOnSharedPreferenceChangeListener(listener) - onDispose { - settingsPrefs.unregisterOnSharedPreferenceChangeListener(listener) - } - } - val callbacks = remember(chatViewModel) { ChatCallbacks( @@ -2131,12 +2110,14 @@ private fun ExtensionStatusStrip(statuses: Map) { hasPreviousSnapshot = true } - Card( + Surface( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f), + tonalElevation = 1.dp, ) { Column( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { Row( @@ -2144,17 +2125,29 @@ private fun ExtensionStatusStrip(statuses: Map) { horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = "Extension status (${statuses.size})", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Text( - text = "${presentation.activeCount} active · ${presentation.quietCount} quiet", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f), + ) { + Icon( + imageVector = Icons.Default.Terminal, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, ) + Column(verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + text = "Extension status", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${presentation.activeCount} active · ${presentation.quietCount} quiet", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } TextButton(onClick = { expanded = !expanded }) { @@ -2163,47 +2156,50 @@ private fun ExtensionStatusStrip(statuses: Map) { } if (!expanded) { - LazyRow( + Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, ) { - items( - items = presentation.visibleEntries, - key = { entry -> entry.key }, - ) { entry -> - StatusPill(entry = entry) + val first = presentation.visibleEntries.firstOrNull() + if (first != null) { + StatusPill(entry = first) } - } - } else { - presentation.visibleEntries.forEach { entry -> - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.Top, - ) { + if (presentation.hiddenCount > 0) { Text( - text = if (entry.isChanged) "•" else "", + text = "+${presentation.hiddenCount} more", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.tertiary, - modifier = Modifier.padding(top = 2.dp), - ) - Text( - text = "${entry.key}: ${entry.value.take(STATUS_VALUE_MAX_LENGTH)}", - style = MaterialTheme.typography.bodySmall, - maxLines = 4, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, ) } } } - if (!expanded && presentation.hiddenCount > 0) { - Text( - text = "+${presentation.hiddenCount} more", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + AnimatedVisibility(visible = expanded) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + presentation.visibleEntries.forEach { entry -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = if (entry.isChanged) "•" else "", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.padding(top = 2.dp), + ) + Text( + text = "${entry.key}: ${entry.value.take(STATUS_VALUE_MAX_LENGTH)}", + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + } + } + } } if (presentation.changedCount > 0) { diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsScreen.kt index ff08e0c..378f63d 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/settings/SettingsScreen.kt @@ -95,23 +95,32 @@ private fun SettingsScreen( onPing = viewModel::pingBridge, ) - AgentBehaviorCard( + AgentAutomationCard( autoCompactionEnabled = uiState.autoCompactionEnabled, autoRetryEnabled = uiState.autoRetryEnabled, + onToggleAutoCompaction = viewModel::toggleAutoCompaction, + onToggleAutoRetry = viewModel::toggleAutoRetry, + ) + + TransportCard( transportPreference = uiState.transportPreference, effectiveTransportPreference = uiState.effectiveTransportPreference, transportRuntimeNote = uiState.transportRuntimeNote, + onTransportPreferenceSelected = viewModel::setTransportPreference, + ) + + AppearanceCard( themePreference = uiState.themePreference, showExtensionStatusStrip = uiState.showExtensionStatusStrip, + onThemePreferenceSelected = viewModel::setThemePreference, + onToggleExtensionStatusStrip = viewModel::toggleExtensionStatusStrip, + ) + + DeliveryModesCard( steeringMode = uiState.steeringMode, followUpMode = uiState.followUpMode, isUpdatingSteeringMode = uiState.isUpdatingSteeringMode, isUpdatingFollowUpMode = uiState.isUpdatingFollowUpMode, - onToggleAutoCompaction = viewModel::toggleAutoCompaction, - onToggleAutoRetry = viewModel::toggleAutoRetry, - onTransportPreferenceSelected = viewModel::setTransportPreference, - onThemePreferenceSelected = viewModel::setThemePreference, - onToggleExtensionStatusStrip = viewModel::toggleExtensionStatusStrip, onSteeringModeSelected = viewModel::setSteeringMode, onFollowUpModeSelected = viewModel::setFollowUpMode, ) @@ -217,33 +226,16 @@ private fun ConnectionMessages( } } -@Suppress("LongParameterList") @Composable -private fun AgentBehaviorCard( +private fun AgentAutomationCard( autoCompactionEnabled: Boolean, autoRetryEnabled: Boolean, - transportPreference: TransportPreference, - effectiveTransportPreference: TransportPreference, - transportRuntimeNote: String, - themePreference: ThemePreference, - showExtensionStatusStrip: Boolean, - steeringMode: String, - followUpMode: String, - isUpdatingSteeringMode: Boolean, - isUpdatingFollowUpMode: Boolean, onToggleAutoCompaction: () -> Unit, onToggleAutoRetry: () -> Unit, - onTransportPreferenceSelected: (TransportPreference) -> Unit, - onThemePreferenceSelected: (ThemePreference) -> Unit, - onToggleExtensionStatusStrip: () -> Unit, - onSteeringModeSelected: (String) -> Unit, - onFollowUpModeSelected: (String) -> Unit, ) { - PiCard( - modifier = Modifier.fillMaxWidth(), - ) { + PiCard(modifier = Modifier.fillMaxWidth()) { Text( - text = "Agent Behavior", + text = "Automation", style = MaterialTheme.typography.titleMedium, ) @@ -260,6 +252,21 @@ private fun AgentBehaviorCard( checked = autoRetryEnabled, onToggle = onToggleAutoRetry, ) + } +} + +@Composable +private fun TransportCard( + transportPreference: TransportPreference, + effectiveTransportPreference: TransportPreference, + transportRuntimeNote: String, + onTransportPreferenceSelected: (TransportPreference) -> Unit, +) { + PiCard(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Connection routing", + style = MaterialTheme.typography.titleMedium, + ) TransportPreferenceRow( selectedPreference = transportPreference, @@ -267,6 +274,21 @@ private fun AgentBehaviorCard( runtimeNote = transportRuntimeNote, onPreferenceSelected = onTransportPreferenceSelected, ) + } +} + +@Composable +private fun AppearanceCard( + themePreference: ThemePreference, + showExtensionStatusStrip: Boolean, + onThemePreferenceSelected: (ThemePreference) -> Unit, + onToggleExtensionStatusStrip: () -> Unit, +) { + PiCard(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Appearance", + style = MaterialTheme.typography.titleMedium, + ) ThemePreferenceRow( selectedPreference = themePreference, @@ -275,10 +297,28 @@ private fun AgentBehaviorCard( SettingsToggleRow( title = "Show extension status strip", - description = "Display extension status updates below the prompt controls in chat", + description = "Show compact extension runtime updates in chat", checked = showExtensionStatusStrip, onToggle = onToggleExtensionStatusStrip, ) + } +} + +@Suppress("LongParameterList") +@Composable +private fun DeliveryModesCard( + steeringMode: String, + followUpMode: String, + isUpdatingSteeringMode: Boolean, + isUpdatingFollowUpMode: Boolean, + onSteeringModeSelected: (String) -> Unit, + onFollowUpModeSelected: (String) -> Unit, +) { + PiCard(modifier = Modifier.fillMaxWidth()) { + Text( + text = "Streaming delivery", + style = MaterialTheme.typography.titleMedium, + ) ModeSelectorRow( title = "Steering mode", From f9187f9f901a6ac6e8b40593211266da57cfe0f6 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Mon, 16 Feb 2026 23:51:21 +0000 Subject: [PATCH 15/32] feat(chat): add context usage chip and compact-now action --- .../ayagmar/pimobile/chat/ChatViewModel.kt | 23 +++++- .../pimobile/sessions/RpcSessionController.kt | 1 + .../pimobile/sessions/SessionController.kt | 1 + .../com/ayagmar/pimobile/ui/PiMobileApp.kt | 14 ++-- .../ayagmar/pimobile/ui/chat/ChatScreen.kt | 76 ++++++++++++++++++- 5 files changed, 105 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt index 8ed4841..fee7b94 100644 --- a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt +++ b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt @@ -48,6 +48,7 @@ import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -90,6 +91,7 @@ class ChatViewModel( observeStreamingState() observeEvents() loadInitialMessages(reason = TimelineReloadReason.INITIAL) + loadSessionStats() } fun onInputTextChanged(text: String) { @@ -938,7 +940,8 @@ class ChatViewModel( } private fun handleTurnEnd() { - // Silently track turn end - no UI notification to reduce spam + // Refresh stats at turn end so context/cost indicators stay current. + loadSessionStats() } private fun handleExtensionError(event: ExtensionErrorEvent) { @@ -1290,6 +1293,19 @@ class ChatViewModel( loadInitialMessages(reason = TimelineReloadReason.MANUAL_SYNC) } + fun compactNow() { + viewModelScope.launch { + _uiState.update { it.copy(errorMessage = null) } + val result = sessionController.compactSession() + if (result.isSuccess) { + addSystemNotification("Compaction requested", "info") + loadSessionStats() + } else { + _uiState.update { it.copy(errorMessage = result.exceptionOrNull()?.message) } + } + } + } + fun loadOlderMessages() { when { visibleTimelineSize < fullTimeline.size -> { @@ -2724,6 +2740,10 @@ private fun JsonObject.booleanField(fieldName: String): Boolean? { return this[fieldName]?.jsonPrimitive?.contentOrNull?.toBooleanStrictOrNull() } +private fun JsonObject.intField(fieldName: String): Int? { + return this[fieldName]?.jsonPrimitive?.intOrNull +} + private fun JsonObject?.deliveryModeField( camelCaseKey: String, snakeCaseKey: String, @@ -2744,6 +2764,7 @@ private fun parseModelInfo(data: JsonObject?): ModelInfo? { name = model.stringField("name") ?: "Unknown Model", provider = model.stringField("provider") ?: "unknown", thinkingLevel = data.stringField("thinkingLevel") ?: "off", + contextWindow = model.intField("contextWindow"), ) } diff --git a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt index 15fc0ba..c716407 100644 --- a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt @@ -1111,6 +1111,7 @@ private fun parseModelInfo(data: JsonObject?): ModelInfo? { name = model.stringField("name") ?: "Unknown Model", provider = model.stringField("provider") ?: "unknown", thinkingLevel = data.stringField("thinkingLevel") ?: "off", + contextWindow = model.intField("contextWindow"), ) } diff --git a/app/src/main/java/com/ayagmar/pimobile/sessions/SessionController.kt b/app/src/main/java/com/ayagmar/pimobile/sessions/SessionController.kt index 811d126..6535925 100644 --- a/app/src/main/java/com/ayagmar/pimobile/sessions/SessionController.kt +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/SessionController.kt @@ -189,6 +189,7 @@ data class ModelInfo( val name: String, val provider: String, val thinkingLevel: String, + val contextWindow: Int? = null, ) /** diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt b/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt index 7d2f0d2..f765ead 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/PiMobileApp.kt @@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -328,17 +327,16 @@ fun piMobileApp(appGraph: AppGraph) { Surface( shape = CircleShape, - tonalElevation = 5.dp, - shadowElevation = 8.dp, - color = MaterialTheme.colorScheme.surface, + tonalElevation = 3.dp, + shadowElevation = 6.dp, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.86f), modifier = Modifier - .align(Alignment.TopStart) - .statusBarsPadding() - .offset(x = (-6).dp, y = 2.dp), + .align(Alignment.CenterStart) + .offset(x = (-8).dp), ) { FilledTonalIconButton( - modifier = Modifier.size(42.dp), + modifier = Modifier.size(34.dp), onClick = { scope.launch { if (drawerState.isOpen) { diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt index b2c6e6f..8676004 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt @@ -155,6 +155,7 @@ private data class ChatCallbacks( val onModelsQueryChanged: (String) -> Unit, val onSelectModel: (AvailableModel) -> Unit, val onSyncNow: () -> Unit, + val onCompactSession: () -> Unit, // Tree navigation callbacks val onShowTreeSheet: () -> Unit, val onHideTreeSheet: () -> Unit, @@ -236,6 +237,7 @@ fun ChatRoute( onModelsQueryChanged = chatViewModel::onModelsQueryChanged, onSelectModel = chatViewModel::selectModel, onSyncNow = chatViewModel::syncNow, + onCompactSession = chatViewModel::compactNow, onShowTreeSheet = chatViewModel::showTreeSheet, onHideTreeSheet = chatViewModel::hideTreeSheet, onForkFromTreeEntry = chatViewModel::forkFromTreeEntry, @@ -415,6 +417,7 @@ private fun ChatScreenContent( connectionState = state.connectionState, currentModel = state.currentModel, thinkingLevel = state.thinkingLevel, + contextUsageLabel = formatContextUsageLabel(state.sessionStats, state.currentModel), errorMessage = state.errorMessage, callbacks = callbacks, ) @@ -485,6 +488,7 @@ private fun ChatHeader( connectionState: com.ayagmar.pimobile.corenet.ConnectionState, currentModel: ModelInfo?, thinkingLevel: String?, + contextUsageLabel: String, errorMessage: String?, callbacks: ChatCallbacks, ) { @@ -582,6 +586,13 @@ private fun ChatHeader( callbacks.onShowStatsSheet() }, ) + DropdownMenuItem( + text = { Text("Compact now") }, + onClick = { + showSecondaryActionsMenu = false + callbacks.onCompactSession() + }, + ) } } } @@ -602,6 +613,20 @@ private fun ChatHeader( onShowModelPicker = callbacks.onShowModelPicker, ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + AssistChip( + onClick = callbacks.onShowStatsSheet, + label = { Text(contextUsageLabel) }, + ) + TextButton(onClick = callbacks.onRefreshStats) { + Text("Refresh") + } + } + // Error message if any errorMessage?.let { message -> Text( @@ -2350,6 +2375,8 @@ private const val TOOL_HIGHLIGHT_MAX_LENGTH = 1_000 private const val STATUS_VALUE_MAX_LENGTH = 180 private const val EXTENSION_STATUS_PILL_MAX_LENGTH = 56 private const val MAX_COMPACT_EXTENSION_STATUS_ITEMS = 2 +private const val CONTEXT_PERCENT_FACTOR = 100.0 +private const val MODEL_PICKER_SCROLL_OFFSET_ITEMS = 1 private const val RUN_PROGRESS_TICK_MS = 1_000L private const val STREAMING_FRAME_LOG_TAG = "StreamingFrameMetrics" private val THINKING_LEVEL_OPTIONS = listOf("off", "minimal", "low", "medium", "high", "xhigh") @@ -2795,12 +2822,28 @@ private fun formatNumber(value: Long): String { } } +private fun formatContextUsageLabel( + stats: SessionStats?, + currentModel: ModelInfo?, +): String { + val statsSnapshot = stats ?: return "Ctx --" + val consumedTokens = (statsSnapshot.inputTokens + statsSnapshot.outputTokens).coerceAtLeast(0L) + val contextWindow = currentModel?.contextWindow?.takeIf { it > 0 } + + return if (contextWindow == null) { + "Ctx ${formatNumber(consumedTokens)}" + } else { + val percent = ((consumedTokens * CONTEXT_PERCENT_FACTOR) / contextWindow.toDouble()).toInt().coerceAtLeast(0) + "Ctx $percent% · ${formatNumber(consumedTokens)}/${formatNumber(contextWindow.toLong())}" + } +} + @Suppress("MagicNumber") private fun formatCost(value: Double): String { return String.format(java.util.Locale.US, "$%.4f", value) } -@Suppress("LongParameterList", "LongMethod") +@Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod") @Composable private fun ModelPickerSheet( isVisible: Boolean, @@ -2831,6 +2874,36 @@ private fun ModelPickerSheet( remember(filteredModels) { filteredModels.groupBy { it.provider } } + val listState = androidx.compose.foundation.lazy.rememberLazyListState() + val selectedModelIndex = + remember(groupedModels, currentModel) { + if (currentModel == null) { + -1 + } else { + var index = 0 + var foundIndex = -1 + groupedModels.forEach { (_, modelsInGroup) -> + index += 1 // provider header item + modelsInGroup.forEach { model -> + if ( + foundIndex < 0 && + model.id == currentModel.id && + model.provider == currentModel.provider + ) { + foundIndex = index + } + index += 1 + } + } + foundIndex + } + } + + LaunchedEffect(selectedModelIndex, isVisible) { + if (isVisible && selectedModelIndex >= 0) { + listState.scrollToItem((selectedModelIndex - MODEL_PICKER_SCROLL_OFFSET_ITEMS).coerceAtLeast(0)) + } + } androidx.compose.material3.AlertDialog( onDismissRequest = onDismiss, @@ -2868,6 +2941,7 @@ private fun ModelPickerSheet( ) } else { LazyColumn( + state = listState, modifier = Modifier.fillMaxWidth(), ) { groupedModels.forEach { (provider, modelsInGroup) -> From e90d36eaefe511d750e6dfbc37a58ad3a0597964 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 17 Feb 2026 09:35:13 +0000 Subject: [PATCH 16/32] fix(chat): harden slash flows and bridge tree state sync --- .../ayagmar/pimobile/chat/ChatViewModel.kt | 231 ++++++++++++++++-- .../ChatViewModelThinkingExpansionTest.kt | 64 +++++ .../testutil/FakeSessionController.kt | 29 ++- bridge/src/server.ts | 31 ++- bridge/test/server.test.ts | 107 +++++++- 5 files changed, 425 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt index fee7b94..89a28d2 100644 --- a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt +++ b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt @@ -83,6 +83,7 @@ class ChatViewModel( private var lastKnownSessionFreshness: SessionFreshnessFingerprint? = null private var localSessionMutationGraceUntilMs: Long = 0 private var isSessionFreshnessUnsupported = false + private var lastFreshnessWarningAtMs: Long = 0 val uiState: StateFlow = _uiState.asStateFlow() @@ -127,15 +128,23 @@ class ChatViewModel( } } + @Suppress("ReturnCount") fun sendPrompt() { val currentState = _uiState.value val message = currentState.inputText.trim() val pendingImages = currentState.pendingImages if (message.isEmpty() && pendingImages.isEmpty()) return - val builtinCommand = message.extractBuiltinCommand() - if (builtinCommand != null) { - handleNonRpcBuiltinCommand(builtinCommand) + val slashInvocation = message.extractKnownSlashInvocation() + if (slashInvocation != null) { + if (pendingImages.isNotEmpty()) { + _uiState.update { + it.copy(errorMessage = "Image attachments are only supported for normal prompts") + } + return + } + + handleKnownSlashCommand(slashInvocation) return } @@ -323,7 +332,7 @@ class ChatViewModel( when (command.source) { COMMAND_SOURCE_BUILTIN_BRIDGE_BACKED, COMMAND_SOURCE_BUILTIN_UNSUPPORTED, - -> handleNonRpcBuiltinCommand(command.name) + -> handleKnownSlashCommand(SlashCommandInvocation(name = command.name, args = null)) else -> { val currentText = _uiState.value.inputText @@ -366,9 +375,20 @@ class ChatViewModel( } } - private fun handleNonRpcBuiltinCommand(commandName: String) { - val normalized = commandName.lowercase() + @Suppress("ReturnCount") + private fun String.extractKnownSlashInvocation(): SlashCommandInvocation? { + val trimmed = trim() + if (!trimmed.startsWith('/')) return null + + val token = trimmed.removePrefix("/") + val name = token.substringBefore(' ').trim().lowercase() + if (name.isBlank() || name !in KNOWN_SLASH_COMMAND_NAMES) return null + + val args = token.substringAfter(' ', missingDelimiterValue = "").trim().ifBlank { null } + return SlashCommandInvocation(name = name, args = args) + } + private fun handleKnownSlashCommand(invocation: SlashCommandInvocation) { _uiState.update { it.copy( isCommandPaletteVisible = false, @@ -377,34 +397,103 @@ class ChatViewModel( ) } - when (normalized) { + when (invocation.name) { BUILTIN_TREE_COMMAND -> showTreeSheet() BUILTIN_STATS_COMMAND -> { invokeInternalWorkflowCommand(INTERNAL_STATS_WORKFLOW_COMMAND) { showStatsSheet() } } - + BUILTIN_MODEL_COMMAND -> showModelPicker() + BUILTIN_SESSION_COMMAND -> showStatsSheet() + BUILTIN_COMPACT_COMMAND -> compactNow() + BUILTIN_FORK_COMMAND -> { + showTreeSheet() + addSystemNotification( + message = "Select an entry and tap Fork to create a new branch session", + type = "info", + ) + } + BUILTIN_EXPORT_COMMAND -> runExportSlashCommand() + BUILTIN_NEW_COMMAND -> runNewSessionSlashCommand() + BUILTIN_NAME_COMMAND -> runRenameSlashCommand(invocation.args) BUILTIN_SETTINGS_COMMAND -> { _uiState.update { it.copy(errorMessage = "Use the Settings tab for /settings on mobile") } } - BUILTIN_HOTKEYS_COMMAND -> { _uiState.update { it.copy(errorMessage = "/hotkeys is not supported on mobile yet") } } - + BUILTIN_RESUME_COMMAND, + BUILTIN_COPY_COMMAND, + BUILTIN_SHARE_COMMAND, + BUILTIN_RELOAD_COMMAND, + BUILTIN_CHANGELOG_COMMAND, + BUILTIN_SCOPED_MODELS_COMMAND, + -> { + _uiState.update { + it.copy(errorMessage = "/${invocation.name} is not available on mobile yet") + } + } else -> { _uiState.update { - it.copy(errorMessage = "/$normalized is interactive-only and unavailable via RPC prompt") + it.copy(errorMessage = "/${invocation.name} is interactive-only and unavailable via RPC prompt") } } } } + private fun runRenameSlashCommand(args: String?) { + val newName = args?.trim().orEmpty() + if (newName.isBlank()) { + _uiState.update { it.copy(errorMessage = "Usage: /name ") } + return + } + + viewModelScope.launch { + _uiState.update { it.copy(errorMessage = null) } + markLocalSessionMutationExpected() + val result = sessionController.renameSession(newName) + if (result.isSuccess) { + addSystemNotification(message = "Session renamed to \"$newName\"", type = "info") + } else { + _uiState.update { it.copy(errorMessage = result.exceptionOrNull()?.message) } + } + } + } + + private fun runExportSlashCommand() { + viewModelScope.launch { + _uiState.update { it.copy(errorMessage = null) } + val result = sessionController.exportSession() + if (result.isSuccess) { + val exportPath = result.getOrNull() + addSystemNotification( + message = "Session exported${if (exportPath.isNullOrBlank()) "" else " to $exportPath"}", + type = "info", + ) + } else { + _uiState.update { it.copy(errorMessage = result.exceptionOrNull()?.message) } + } + } + } + + private fun runNewSessionSlashCommand() { + viewModelScope.launch { + _uiState.update { it.copy(errorMessage = null) } + markLocalSessionMutationExpected() + val result = sessionController.newSession() + if (result.isSuccess) { + addSystemNotification(message = "Started a new session", type = "info") + } else { + _uiState.update { it.copy(errorMessage = result.exceptionOrNull()?.message) } + } + } + } + private fun invokeInternalWorkflowCommand( commandName: String, onFailure: (() -> Unit)? = null, @@ -479,18 +568,6 @@ class ChatViewModel( return visibleRpcCommands + missingBuiltins } - private fun String.extractBuiltinCommand(): String? { - val commandName = - trim().substringBefore(' ') - .takeIf { token -> token.startsWith('/') } - ?.removePrefix("/") - ?.trim() - ?.lowercase() - .orEmpty() - - return commandName.takeIf { name -> name.isNotBlank() && BUILTIN_COMMAND_NAMES.contains(name) } - } - fun toggleToolExpansion(itemId: String) { updateTimelineState { state -> ChatTimelineReducer.toggleToolExpansion(state, itemId) @@ -606,7 +683,7 @@ class ChatViewModel( it.copy(sessionCoherencyWarning = SESSION_COHERENCY_WARNING_MESSAGE) } - if (trigger == FreshnessCheckTrigger.POLL) { + if (trigger == FreshnessCheckTrigger.POLL && shouldEmitFreshnessWarning()) { addSystemNotification( message = buildSessionFreshnessWarningMessage(freshness), type = "warning", @@ -630,6 +707,24 @@ class ChatViewModel( return "Potential cross-device edits detected$ownerHint. Use Sync now before continuing." } + private fun shouldEmitFreshnessWarning(): Boolean { + val now = System.currentTimeMillis() + + val elapsedMs = now - lastFreshnessWarningAtMs + val shouldEmit = + lastFreshnessWarningAtMs == 0L || elapsedMs >= SESSION_FRESHNESS_WARNING_COOLDOWN_MS + + if (shouldEmit) { + lastFreshnessWarningAtMs = now + } + + return shouldEmit + } + + private fun resetFreshnessWarningThrottle() { + lastFreshnessWarningAtMs = 0L + } + private fun markLocalSessionMutationExpected() { localSessionMutationGraceUntilMs = System.currentTimeMillis() + LOCAL_SESSION_MUTATION_GRACE_MS } @@ -717,6 +812,7 @@ class ChatViewModel( latestSessionPath = null lastKnownSessionFreshness = null localSessionMutationGraceUntilMs = 0 + resetFreshnessWarningThrottle() _uiState.update { it.copy( sessionCoherencyWarning = null, @@ -1421,6 +1517,7 @@ class ChatViewModel( } if (messagesResult.isSuccess) { + resetFreshnessWarningThrottle() val message = if (hasPotentialExternalChanges) { "Potential cross-device edits detected. Timeline refreshed." @@ -1436,6 +1533,7 @@ class ChatViewModel( } if (messagesResult.isSuccess) { + resetFreshnessWarningThrottle() val message = if (hasPotentialExternalChanges) { "Session changed externally. Timeline auto-refreshed." @@ -2227,6 +2325,11 @@ class ChatViewModel( } } + private data class SlashCommandInvocation( + val name: String, + val args: String?, + ) + private enum class AssistantUpdateSource { IMMEDIATE_DELTA, FLUSHED_DELTA, @@ -2255,6 +2358,7 @@ class ChatViewModel( resetStreamingUpdateState() resetThinkingDiagnostics(startNewRun = false) resetStreamingDiagnostics(startNewRun = false) + resetFreshnessWarningThrottle() pendingLocalUserIds.clear() super.onCleared() } @@ -2275,6 +2379,19 @@ class ChatViewModel( private const val BUILTIN_SETTINGS_COMMAND = "settings" private const val BUILTIN_TREE_COMMAND = "tree" private const val BUILTIN_STATS_COMMAND = "stats" + private const val BUILTIN_MODEL_COMMAND = "model" + private const val BUILTIN_SESSION_COMMAND = "session" + private const val BUILTIN_COMPACT_COMMAND = "compact" + private const val BUILTIN_EXPORT_COMMAND = "export" + private const val BUILTIN_FORK_COMMAND = "fork" + private const val BUILTIN_NEW_COMMAND = "new" + private const val BUILTIN_NAME_COMMAND = "name" + private const val BUILTIN_RESUME_COMMAND = "resume" + private const val BUILTIN_COPY_COMMAND = "copy" + private const val BUILTIN_SHARE_COMMAND = "share" + private const val BUILTIN_RELOAD_COMMAND = "reload" + private const val BUILTIN_CHANGELOG_COMMAND = "changelog" + private const val BUILTIN_SCOPED_MODELS_COMMAND = "scoped-models" private const val BUILTIN_HOTKEYS_COMMAND = "hotkeys" private const val INTERNAL_TREE_NAVIGATION_COMMAND = "pi-mobile-tree" @@ -2286,25 +2403,74 @@ class ChatViewModel( listOf( SlashCommandInfo( name = BUILTIN_SETTINGS_COMMAND, - description = "Open mobile settings UI (interactive-only in TUI)", + description = "Open mobile settings tab", source = COMMAND_SOURCE_BUILTIN_BRIDGE_BACKED, location = null, path = null, ), SlashCommandInfo( name = BUILTIN_TREE_COMMAND, - description = "Open session tree sheet (interactive-only in TUI)", + description = "Open session tree sheet", source = COMMAND_SOURCE_BUILTIN_BRIDGE_BACKED, location = null, path = null, ), SlashCommandInfo( name = BUILTIN_STATS_COMMAND, - description = "Open session stats sheet (interactive-only in TUI)", + description = "Open session stats sheet", + source = COMMAND_SOURCE_BUILTIN_BRIDGE_BACKED, + location = null, + path = null, + ), + SlashCommandInfo( + name = BUILTIN_MODEL_COMMAND, + description = "Open model picker", + source = COMMAND_SOURCE_BUILTIN_BRIDGE_BACKED, + location = null, + path = null, + ), + SlashCommandInfo( + name = BUILTIN_SESSION_COMMAND, + description = "Open session stats overview", + source = COMMAND_SOURCE_BUILTIN_BRIDGE_BACKED, + location = null, + path = null, + ), + SlashCommandInfo( + name = BUILTIN_COMPACT_COMMAND, + description = "Compact the active session context", + source = COMMAND_SOURCE_BUILTIN_BRIDGE_BACKED, + location = null, + path = null, + ), + SlashCommandInfo( + name = BUILTIN_EXPORT_COMMAND, + description = "Export session to HTML", + source = COMMAND_SOURCE_BUILTIN_BRIDGE_BACKED, + location = null, + path = null, + ), + SlashCommandInfo( + name = BUILTIN_FORK_COMMAND, + description = "Open tree and fork from a selected entry", + source = COMMAND_SOURCE_BUILTIN_BRIDGE_BACKED, + location = null, + path = null, + ), + SlashCommandInfo( + name = BUILTIN_NEW_COMMAND, + description = "Start a new session", source = COMMAND_SOURCE_BUILTIN_BRIDGE_BACKED, location = null, path = null, ), + SlashCommandInfo( + name = BUILTIN_RESUME_COMMAND, + description = "Not available in chat on mobile (use Sessions tab)", + source = COMMAND_SOURCE_BUILTIN_UNSUPPORTED, + location = null, + path = null, + ), SlashCommandInfo( name = BUILTIN_HOTKEYS_COMMAND, description = "Not available on mobile yet", @@ -2315,6 +2481,16 @@ class ChatViewModel( ) private val BUILTIN_COMMAND_NAMES = BUILTIN_COMMANDS.map { it.name }.toSet() + private val KNOWN_SLASH_COMMAND_NAMES = + BUILTIN_COMMAND_NAMES + + setOf( + BUILTIN_NAME_COMMAND, + BUILTIN_COPY_COMMAND, + BUILTIN_SHARE_COMMAND, + BUILTIN_RELOAD_COMMAND, + BUILTIN_CHANGELOG_COMMAND, + BUILTIN_SCOPED_MODELS_COMMAND, + ) private val INTERNAL_HIDDEN_COMMAND_NAMES = setOf( INTERNAL_TREE_NAVIGATION_COMMAND, @@ -2339,6 +2515,7 @@ class ChatViewModel( private const val SESSION_COHERENCY_WARNING_MESSAGE = "Potential cross-device session edits detected. Use Sync now before continuing." private const val SESSION_FRESHNESS_POLL_INTERVAL_MS = 4_000L + private const val SESSION_FRESHNESS_WARNING_COOLDOWN_MS = 20_000L private const val LOCAL_SESSION_MUTATION_GRACE_MS = 90_000L } } diff --git a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt index 9d04fa7..9e538a2 100644 --- a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt @@ -440,6 +440,70 @@ class ChatViewModelThinkingExpansionTest { assertTrue(viewModel.uiState.value.errorMessage?.contains("Settings tab") == true) } + @Test + fun sendingModelSlashCommandOpensModelPickerWithoutRpcPrompt() = + runTest(dispatcher) { + val controller = FakeSessionController() + val viewModel = ChatViewModel(sessionController = controller) + dispatcher.scheduler.advanceUntilIdle() + awaitInitialLoad(viewModel) + + viewModel.onInputTextChanged("/model") + viewModel.sendPrompt() + dispatcher.scheduler.advanceUntilIdle() + + assertEquals(0, controller.sendPromptCallCount) + assertTrue(viewModel.uiState.value.isModelPickerVisible) + } + + @Test + fun sendingNameSlashCommandRenamesSessionWithoutRpcPrompt() = + runTest(dispatcher) { + val controller = FakeSessionController() + val viewModel = ChatViewModel(sessionController = controller) + dispatcher.scheduler.advanceUntilIdle() + awaitInitialLoad(viewModel) + + viewModel.onInputTextChanged("/name Sprint planning") + viewModel.sendPrompt() + dispatcher.scheduler.advanceUntilIdle() + + assertEquals(0, controller.sendPromptCallCount) + assertEquals(1, controller.renameSessionCallCount) + assertEquals("Sprint planning", controller.lastRenamedSessionName) + } + + @Test + fun slashCommandsClearExistingErrorsOnSuccessfulExecution() = + runTest(dispatcher) { + val controller = FakeSessionController().apply { + abortResult = Result.failure(IllegalStateException("abort failed")) + abortRetryResult = Result.failure(IllegalStateException("abort retry failed")) + } + val viewModel = ChatViewModel(sessionController = controller) + dispatcher.scheduler.advanceUntilIdle() + awaitInitialLoad(viewModel) + + viewModel.abort() + dispatcher.scheduler.advanceUntilIdle() + assertEquals("abort failed", viewModel.uiState.value.errorMessage) + + viewModel.onInputTextChanged("/name Sprint planning") + viewModel.sendPrompt() + dispatcher.scheduler.advanceUntilIdle() + assertEquals(null, viewModel.uiState.value.errorMessage) + + viewModel.onInputTextChanged("/export") + viewModel.sendPrompt() + dispatcher.scheduler.advanceUntilIdle() + assertEquals(null, viewModel.uiState.value.errorMessage) + + viewModel.onInputTextChanged("/new") + viewModel.sendPrompt() + dispatcher.scheduler.advanceUntilIdle() + assertEquals(null, viewModel.uiState.value.errorMessage) + } + @Test fun selectingBridgeBackedBuiltinTreeOpensTreeSheet() = runTest(dispatcher) { diff --git a/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt b/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt index 841b15d..72761bc 100644 --- a/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt +++ b/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt @@ -59,6 +59,14 @@ class FakeSessionController : SessionController { var steeringModeResult: Result = Result.success(Unit) var followUpModeResult: Result = Result.success(Unit) var newSessionResult: Result = Result.success(Unit) + var renameSessionResult: Result = Result.success(null) + var compactSessionResult: Result = Result.success(null) + var exportSessionResult: Result = Result.success("/tmp/export.html") + var renameSessionCallCount: Int = 0 + var compactSessionCallCount: Int = 0 + var exportSessionCallCount: Int = 0 + var newSessionCallCount: Int = 0 + var lastRenamedSessionName: String? = null var lastSteeringMode: String? = null var lastFollowUpMode: String? = null var lastTransportPreference: TransportPreference = TransportPreference.AUTO @@ -147,11 +155,21 @@ class FakeSessionController : SessionController { override suspend fun followUp(message: String): Result = Result.success(Unit) - override suspend fun renameSession(name: String): Result = Result.success(null) + override suspend fun renameSession(name: String): Result { + renameSessionCallCount += 1 + lastRenamedSessionName = name + return renameSessionResult + } - override suspend fun compactSession(): Result = Result.success(null) + override suspend fun compactSession(): Result { + compactSessionCallCount += 1 + return compactSessionResult + } - override suspend fun exportSession(): Result = Result.success("/tmp/export.html") + override suspend fun exportSession(): Result { + exportSessionCallCount += 1 + return exportSessionResult + } override suspend fun forkSessionFromEntryId(entryId: String): Result = Result.success(null) @@ -191,7 +209,10 @@ class FakeSessionController : SessionController { cancelled: Boolean?, ): Result = Result.success(Unit) - override suspend fun newSession(): Result = newSessionResult + override suspend fun newSession(): Result { + newSessionCallCount += 1 + return newSessionResult + } override suspend fun getCommands(): Result> { getCommandsCallCount += 1 diff --git a/bridge/src/server.ts b/bridge/src/server.ts index 3050e78..03c1a91 100644 --- a/bridge/src/server.ts +++ b/bridge/src/server.ts @@ -61,6 +61,11 @@ interface PendingRpcEventWaiter { timeoutHandle: NodeJS.Timeout; } +interface RuntimeLeafOverride { + currentLeafId: string | null; + cwd: string; +} + const PI_MOBILE_TREE_EXTENSION_PATH = path.resolve( fileURLToPath(new URL("./extensions/pi-mobile-tree.ts", import.meta.url)), ); @@ -114,9 +119,17 @@ export function createBridgeServer( const clientContexts = new Map(); const disconnectedClients = new Map(); - const runtimeLeafBySessionPath = new Map(); + const runtimeLeafBySessionPath = new Map(); const pendingRpcWaiters = new Set(); + const clearRuntimeLeafOverridesForCwd = (cwd: string): void => { + for (const [sessionPath, override] of runtimeLeafBySessionPath.entries()) { + if (override.cwd === cwd) { + runtimeLeafBySessionPath.delete(sessionPath); + } + } + }; + const awaitRpcEvent = ( cwd: string, predicate: (payload: Record) => boolean, @@ -214,6 +227,10 @@ export function createBridgeServer( runtimeLeafBySessionPath.clear(); } + if (isSuccessfulRpcResponse(event.payload, "prompt")) { + clearRuntimeLeafOverridesForCwd(event.cwd); + } + if (consumedByInternalWaiter) { return; } @@ -389,7 +406,7 @@ async function handleClientMessage( predicate: (payload: Record) => boolean, options?: { timeoutMs?: number; consume?: boolean }, ) => Promise>, - runtimeLeafBySessionPath: Map, + runtimeLeafBySessionPath: Map, ): Promise { const dataAsString = asUtf8String(data); const parsedEnvelope = parseBridgeEnvelope(dataAsString); @@ -445,7 +462,7 @@ async function handleBridgeControlMessage( predicate: (payload: Record) => boolean, options?: { timeoutMs?: number; consume?: boolean }, ) => Promise>, - runtimeLeafBySessionPath: Map, + runtimeLeafBySessionPath: Map, ): Promise { const messageType = payload.type; @@ -519,7 +536,8 @@ async function handleBridgeControlMessage( try { const tree = await sessionIndexer.getSessionTree(sessionPath, requestedFilter); - const runtimeLeafId = runtimeLeafBySessionPath.get(tree.sessionPath); + const runtimeLeafOverride = runtimeLeafBySessionPath.get(tree.sessionPath); + const runtimeLeafId = runtimeLeafOverride?.currentLeafId; client.send( JSON.stringify( @@ -646,7 +664,10 @@ async function handleBridgeControlMessage( if (navigationResult.sessionPath) { runtimeLeafBySessionPath.set( navigationResult.sessionPath, - navigationResult.currentLeafId, + { + currentLeafId: navigationResult.currentLeafId, + cwd, + }, ); } diff --git a/bridge/test/server.test.ts b/bridge/test/server.test.ts index fe55507..d00da2d 100644 --- a/bridge/test/server.test.ts +++ b/bridge/test/server.test.ts @@ -472,6 +472,108 @@ describe("bridge websocket server", () => { ws.close(); }); + it("clears runtime tree leaf override after a prompt run starts", async () => { + const fakeProcessManager = new FakeProcessManager(); + fakeProcessManager.treeNavigationResult = { + cancelled: false, + editorText: "Retry with more context", + currentLeafId: "entry-42", + sessionPath: "/tmp/session-tree.jsonl", + }; + + const fakeSessionIndexer = new FakeSessionIndexer( + [], + { + sessionPath: "/tmp/session-tree.jsonl", + rootIds: ["m1"], + currentLeafId: "stale-leaf", + entries: [], + }, + ); + + const { baseUrl, server } = await startBridgeServer({ + processManager: fakeProcessManager, + sessionIndexer: fakeSessionIndexer, + }); + bridgeServer = server; + + const ws = await connectWebSocket(baseUrl, { + headers: { + authorization: "Bearer bridge-token", + }, + }); + + const waitForCwdSet = waitForEnvelope(ws, (envelope) => envelope.payload?.type === "bridge_cwd_set"); + ws.send( + JSON.stringify({ + channel: "bridge", + payload: { + type: "bridge_set_cwd", + cwd: "/tmp/project", + }, + }), + ); + await waitForCwdSet; + + const waitForControl = waitForEnvelope(ws, (envelope) => envelope.payload?.type === "bridge_control_acquired"); + ws.send( + JSON.stringify({ + channel: "bridge", + payload: { + type: "bridge_acquire_control", + cwd: "/tmp/project", + }, + }), + ); + await waitForControl; + + const waitForNavigate = + waitForEnvelope(ws, (envelope) => envelope.payload?.type === "bridge_tree_navigation_result"); + ws.send( + JSON.stringify({ + channel: "bridge", + payload: { + type: "bridge_navigate_tree", + entryId: "entry-42", + }, + }), + ); + await waitForNavigate; + + const waitForPromptResponse = waitForEnvelope( + ws, + (envelope) => envelope.channel === "rpc" && envelope.payload?.id === "req-prompt" && + envelope.payload?.type === "response", + ); + ws.send( + JSON.stringify({ + channel: "rpc", + payload: { + id: "req-prompt", + type: "prompt", + message: "Continue from here", + }, + }), + ); + await waitForPromptResponse; + + const waitForTree = waitForEnvelope(ws, (envelope) => envelope.payload?.type === "bridge_session_tree"); + ws.send( + JSON.stringify({ + channel: "bridge", + payload: { + type: "bridge_get_session_tree", + sessionPath: "/tmp/session-tree.jsonl", + }, + }), + ); + + const treeEnvelope = await waitForTree; + expect(treeEnvelope.payload?.currentLeafId).toBe("stale-leaf"); + + ws.close(); + }); + it("returns bridge_error when tree navigation command is unavailable", async () => { const fakeProcessManager = new FakeProcessManager(); fakeProcessManager.availableCommandNames = []; @@ -1177,6 +1279,7 @@ async function startBridgeServer( } const envelopeBuffers = new WeakMap(); +const envelopeCursors = new WeakMap(); async function connectWebSocket(url: string, options?: ClientOptions): Promise { return await new Promise((resolve, reject) => { @@ -1198,6 +1301,7 @@ async function connectWebSocket(url: string, options?: ClientOptions): Promise { clearTimeout(timeoutHandle); envelopeBuffers.set(ws, buffer); + envelopeCursors.set(ws, 0); resolve(ws); }); @@ -1241,13 +1345,14 @@ async function waitForEnvelope( throw new Error("Missing envelope buffer for websocket"); } - let cursor = 0; + let cursor = envelopeCursors.get(ws) ?? 0; const timeoutAt = Date.now() + timeoutMs; while (Date.now() < timeoutAt) { while (cursor < buffer.length) { const envelope = buffer[cursor]; cursor += 1; + envelopeCursors.set(ws, cursor); if (predicate(envelope)) { return envelope; From 3ba35b373c69cad98abefe3aae8918187b9291ba Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 17 Feb 2026 09:58:23 +0000 Subject: [PATCH 17/32] fix(chat): preserve draft and clear errors in slash flows --- .../ayagmar/pimobile/chat/ChatViewModel.kt | 29 ++++++++++++++----- .../ChatViewModelThinkingExpansionTest.kt | 29 +++++++++++++++++++ .../testutil/FakeSessionController.kt | 5 ++++ 3 files changed, 55 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt index 89a28d2..c5961d7 100644 --- a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt +++ b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt @@ -182,14 +182,29 @@ class ChatViewModel( return@launch } - _uiState.update { it.copy(inputText = "", pendingImages = emptyList(), errorMessage = null) } + var composerWasCleared = false + _uiState.update { state -> + val shouldClearComposer = + state.inputText == currentState.inputText && + state.pendingImages == currentState.pendingImages + composerWasCleared = shouldClearComposer + + if (shouldClearComposer) { + state.copy(inputText = "", pendingImages = emptyList(), errorMessage = null) + } else { + state.copy(errorMessage = null) + } + } + val result = sessionController.sendPrompt(message, imagePayloads) if (result.isFailure) { discardPendingLocalUserItem(optimisticUserId) - _uiState.update { - it.copy( - inputText = currentState.inputText, - pendingImages = currentState.pendingImages, + _uiState.update { state -> + val shouldRestoreDraft = + composerWasCleared && state.inputText.isEmpty() && state.pendingImages.isEmpty() + state.copy( + inputText = if (shouldRestoreDraft) currentState.inputText else state.inputText, + pendingImages = if (shouldRestoreDraft) currentState.pendingImages else state.pendingImages, errorMessage = result.exceptionOrNull()?.message, ) } @@ -394,6 +409,7 @@ class ChatViewModel( isCommandPaletteVisible = false, isCommandPaletteAutoOpened = false, commandsQuery = "", + errorMessage = null, ) } @@ -454,7 +470,6 @@ class ChatViewModel( } viewModelScope.launch { - _uiState.update { it.copy(errorMessage = null) } markLocalSessionMutationExpected() val result = sessionController.renameSession(newName) if (result.isSuccess) { @@ -467,7 +482,6 @@ class ChatViewModel( private fun runExportSlashCommand() { viewModelScope.launch { - _uiState.update { it.copy(errorMessage = null) } val result = sessionController.exportSession() if (result.isSuccess) { val exportPath = result.getOrNull() @@ -483,7 +497,6 @@ class ChatViewModel( private fun runNewSessionSlashCommand() { viewModelScope.launch { - _uiState.update { it.copy(errorMessage = null) } markLocalSessionMutationExpected() val result = sessionController.newSession() if (result.isSuccess) { diff --git a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt index 9e538a2..8dbd7ba 100644 --- a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt @@ -488,6 +488,11 @@ class ChatViewModelThinkingExpansionTest { dispatcher.scheduler.advanceUntilIdle() assertEquals("abort failed", viewModel.uiState.value.errorMessage) + viewModel.onInputTextChanged("/model") + viewModel.sendPrompt() + dispatcher.scheduler.advanceUntilIdle() + assertEquals(null, viewModel.uiState.value.errorMessage) + viewModel.onInputTextChanged("/name Sprint planning") viewModel.sendPrompt() dispatcher.scheduler.advanceUntilIdle() @@ -816,6 +821,30 @@ class ChatViewModelThinkingExpansionTest { assertEquals("rpc failed", viewModel.uiState.value.errorMessage) } + @Test + fun sendPromptFailureDoesNotOverwriteNewerDraftInput() = + runTest(dispatcher) { + val controller = FakeSessionController().apply { + sendPromptResult = Result.failure(IllegalStateException("rpc failed")) + sendPromptDelayMs = 50L + } + val viewModel = ChatViewModel(sessionController = controller) + dispatcher.scheduler.advanceUntilIdle() + awaitInitialLoad(viewModel) + + viewModel.onInputTextChanged("original draft") + viewModel.sendPrompt() + + viewModel.onInputTextChanged("new draft") + + waitForState(viewModel) { state -> + state.errorMessage == "rpc failed" + } + + assertEquals("new draft", viewModel.uiState.value.inputText) + assertEquals("rpc failed", viewModel.uiState.value.errorMessage) + } + @Test fun serverUserMessagePreservesPendingImageUris() = runTest(dispatcher) { diff --git a/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt b/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt index 72761bc..cd58179 100644 --- a/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt +++ b/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt @@ -17,6 +17,7 @@ import com.ayagmar.pimobile.sessions.SessionTreeSnapshot import com.ayagmar.pimobile.sessions.SlashCommandInfo import com.ayagmar.pimobile.sessions.TransportPreference import com.ayagmar.pimobile.sessions.TreeNavigationResult +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -39,6 +40,7 @@ class FakeSessionController : SessionController { var lastPromptMessage: String? = null var lastFreshnessSessionPath: String? = null var sendPromptResult: Result = Result.success(Unit) + var sendPromptDelayMs: Long = 0L var abortResult: Result = Result.success(Unit) var abortRetryResult: Result = Result.success(Unit) var abortCallCount: Int = 0 @@ -143,6 +145,9 @@ class FakeSessionController : SessionController { ): Result { sendPromptCallCount += 1 lastPromptMessage = message + if (sendPromptDelayMs > 0) { + delay(sendPromptDelayMs) + } return sendPromptResult } From d73b7b6d5eb074c0cbd36937fa0bfb6c46ab2f1e Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 17 Feb 2026 10:01:51 +0000 Subject: [PATCH 18/32] fix(docs): update extension status visibility and enhance final acceptance report --- docs/extensions.md | 2 +- docs/final-acceptance.md | 2 +- docs/perf-baseline.md | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/extensions.md b/docs/extensions.md index 8f667b0..3d5d39b 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -133,7 +133,7 @@ They are hidden from visible slash-command results by filtering internal names. If payload action is `open_stats`, it opens the stats sheet. -Non-workflow status keys are currently ignored to avoid UI noise. +Non-workflow status keys are surfaced in the extension status strip and can be hidden via Settings. ## Extension UI Method Support diff --git a/docs/final-acceptance.md b/docs/final-acceptance.md index a91617c..bc60de7 100644 --- a/docs/final-acceptance.md +++ b/docs/final-acceptance.md @@ -98,7 +98,7 @@ Measured values require device testing and are tracked via `adb logcat | grep Pe ## Known Limitations - No offline mode - requires live laptop connection -- Text-only - image attachments not supported +- Image attachments supported for prompt/send flows; editing/viewing remains text-first - Large tool outputs truncated (configurable threshold) - Session history loads completely on resume (not paginated) diff --git a/docs/perf-baseline.md b/docs/perf-baseline.md index 0c96ac8..571fc15 100644 --- a/docs/perf-baseline.md +++ b/docs/perf-baseline.md @@ -25,8 +25,8 @@ This document defines the performance metrics, measurement methodology, and base | Metric | Target | Status | |--------|--------|--------| | Streaming buffer per message | < 50KB | ✅ Implemented | -| Tracked messages limit | 16 | ✅ Implemented | -| Event buffer capacity | 128 events | ✅ Implemented | +| Tracked assistant message buffers | 8 | ✅ Implemented | +| Event buffer capacity (RPC / bridge) | 256 / 128 events | ✅ Implemented | ## Measurement Infrastructure From 9851be2518656d8ded4f24658e7f61a08390c2b2 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 17 Feb 2026 10:06:46 +0000 Subject: [PATCH 19/32] fix(chat): clear sent images during in-flight draft edits --- .../ayagmar/pimobile/chat/ChatViewModel.kt | 25 ++++++++------- .../ChatViewModelThinkingExpansionTest.kt | 32 +++++++++++++++++++ 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt index c5961d7..cf82232 100644 --- a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt +++ b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt @@ -182,18 +182,19 @@ class ChatViewModel( return@launch } - var composerWasCleared = false + var inputWasCleared = false + var imagesWereCleared = false _uiState.update { state -> - val shouldClearComposer = - state.inputText == currentState.inputText && - state.pendingImages == currentState.pendingImages - composerWasCleared = shouldClearComposer + val shouldClearInput = state.inputText == currentState.inputText + val shouldClearImages = state.pendingImages == currentState.pendingImages + inputWasCleared = shouldClearInput + imagesWereCleared = shouldClearImages - if (shouldClearComposer) { - state.copy(inputText = "", pendingImages = emptyList(), errorMessage = null) - } else { - state.copy(errorMessage = null) - } + state.copy( + inputText = if (shouldClearInput) "" else state.inputText, + pendingImages = if (shouldClearImages) emptyList() else state.pendingImages, + errorMessage = null, + ) } val result = sessionController.sendPrompt(message, imagePayloads) @@ -201,7 +202,9 @@ class ChatViewModel( discardPendingLocalUserItem(optimisticUserId) _uiState.update { state -> val shouldRestoreDraft = - composerWasCleared && state.inputText.isEmpty() && state.pendingImages.isEmpty() + (inputWasCleared || imagesWereCleared) && + state.inputText.isEmpty() && + state.pendingImages.isEmpty() state.copy( inputText = if (shouldRestoreDraft) currentState.inputText else state.inputText, pendingImages = if (shouldRestoreDraft) currentState.pendingImages else state.pendingImages, diff --git a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt index 8dbd7ba..3144d6b 100644 --- a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt @@ -845,6 +845,38 @@ class ChatViewModelThinkingExpansionTest { assertEquals("rpc failed", viewModel.uiState.value.errorMessage) } + @Test + fun successfulPromptClearsSentImagesEvenWhenUserTypesNewDraftMidFlight() = + runTest(dispatcher) { + val controller = FakeSessionController().apply { + sendPromptDelayMs = 50L + } + val viewModel = ChatViewModel(sessionController = controller) + dispatcher.scheduler.advanceUntilIdle() + awaitInitialLoad(viewModel) + + viewModel.addImage( + PendingImage( + uri = "content://test/image-mid-flight", + mimeType = "image/png", + sizeBytes = 128, + displayName = "mid-flight.png", + ), + ) + viewModel.onInputTextChanged("first prompt") + viewModel.sendPrompt() + + viewModel.onInputTextChanged("new draft") + + waitForState(viewModel) { state -> + state.inputText == "new draft" && state.pendingImages.isEmpty() + } + + assertEquals("new draft", viewModel.uiState.value.inputText) + assertTrue(viewModel.uiState.value.pendingImages.isEmpty()) + assertEquals(null, viewModel.uiState.value.errorMessage) + } + @Test fun serverUserMessagePreservesPendingImageUris() = runTest(dispatcher) { From c720f3eb5e71ac9b1250a267d0b6b066d17dabcb Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 17 Feb 2026 10:50:24 +0000 Subject: [PATCH 20/32] fix(chat): clear stale streaming controls on turn end --- .../ayagmar/pimobile/chat/ChatViewModel.kt | 41 +++++++++++++++++++ .../ChatViewModelThinkingExpansionTest.kt | 34 +++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt index cf82232..6dc1657 100644 --- a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt +++ b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt @@ -768,6 +768,10 @@ class ChatViewModel( pendingQueueItems = if (isStreaming) current.pendingQueueItems else emptyList(), ) } + + if (!isStreaming) { + clearStreamingTimelineFlags() + } } } } @@ -1052,6 +1056,14 @@ class ChatViewModel( } private fun handleTurnEnd() { + clearStreamingTimelineFlags() + _uiState.update { + it.copy( + isStreaming = false, + pendingQueueItems = emptyList(), + ) + } + // Refresh stats at turn end so context/cost indicators stay current. loadSessionStats() } @@ -2174,6 +2186,35 @@ class ChatViewModel( toolUpdateThrottlers.clear() } + private fun clearStreamingTimelineFlags() { + updateTimelineState { state -> + state.copy( + timeline = + state.timeline.map { item -> + when (item) { + is ChatTimelineItem.Assistant -> { + if (item.isStreaming) { + item.copy(isStreaming = false) + } else { + item + } + } + + is ChatTimelineItem.Tool -> { + if (item.isStreaming) { + item.copy(isStreaming = false) + } else { + item + } + } + + is ChatTimelineItem.User -> item + } + }, + ) + } + } + private fun upsertTimelineItem(item: ChatTimelineItem) { val timelineState = ChatUiState(timeline = fullTimeline) fullTimeline = diff --git a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt index 3144d6b..823da57 100644 --- a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt @@ -6,6 +6,7 @@ import com.ayagmar.pimobile.corerpc.AgentEndEvent import com.ayagmar.pimobile.corerpc.AssistantMessageEvent import com.ayagmar.pimobile.corerpc.MessageEndEvent import com.ayagmar.pimobile.corerpc.MessageUpdateEvent +import com.ayagmar.pimobile.corerpc.TurnEndEvent import com.ayagmar.pimobile.sessions.SlashCommandInfo import com.ayagmar.pimobile.sessions.TreeNavigationResult import com.ayagmar.pimobile.testutil.FakeSessionController @@ -585,6 +586,39 @@ class ChatViewModelThinkingExpansionTest { assertTrue(viewModel.uiState.value.pendingQueueItems.isEmpty()) } + @Test + fun turnEndClearsStreamingIndicatorsAndPendingQueue() = + runTest(dispatcher) { + val controller = FakeSessionController() + val viewModel = ChatViewModel(sessionController = controller) + dispatcher.scheduler.advanceUntilIdle() + awaitInitialLoad(viewModel) + + controller.setStreaming(true) + controller.emitEvent( + textUpdate( + assistantType = "text_delta", + delta = "Streaming reply", + messageTimestamp = "1733234567000", + ), + ) + dispatcher.scheduler.advanceUntilIdle() + + viewModel.steer("Keep concise") + dispatcher.scheduler.advanceUntilIdle() + + assertTrue(viewModel.singleAssistantItem().isStreaming) + assertTrue(viewModel.uiState.value.pendingQueueItems.isNotEmpty()) + + controller.emitEvent(TurnEndEvent(type = "turn_end")) + dispatcher.scheduler.advanceUntilIdle() + + val finalState = viewModel.uiState.value + assertFalse(finalState.isStreaming) + assertTrue(finalState.pendingQueueItems.isEmpty()) + assertFalse(viewModel.singleAssistantItem().isStreaming) + } + @Test fun abortFallsBackToAbortRetryWhenAbortFails() = runTest(dispatcher) { From 98f2a39fddaf74479f8c64e1d5af309c8482e588 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 17 Feb 2026 10:53:13 +0000 Subject: [PATCH 21/32] feat(chat-ui): add tap-to-preview for attached images --- .../ayagmar/pimobile/ui/chat/ChatScreen.kt | 79 ++++++++++++++++++- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt index 8676004..293c602 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt @@ -93,6 +93,8 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil.compose.AsyncImage @@ -770,6 +772,7 @@ private fun ChatTimeline( onToggleToolArgumentsExpansion: (String) -> Unit, modifier: Modifier = Modifier, ) { + var previewImageUri by rememberSaveable { mutableStateOf(null) } val listState = androidx.compose.foundation.lazy.rememberLazyListState() val coroutineScope = androidx.compose.runtime.rememberCoroutineScope() val totalItems = timeline.size + if (showInlineRunProgress) 1 else 0 @@ -821,6 +824,9 @@ private fun ChatTimeline( text = item.text, imageCount = item.imageCount, imageUris = item.imageUris, + onImageClick = { uri -> + previewImageUri = uri + }, ) } } @@ -871,6 +877,13 @@ private fun ChatTimeline( Text("Jump to latest") } } + + previewImageUri?.let { uri -> + ImagePreviewDialog( + uriString = uri, + onDismiss = { previewImageUri = null }, + ) + } } } @@ -880,6 +893,7 @@ private fun UserCard( text: String, imageCount: Int, imageUris: List, + onImageClick: (String) -> Unit, modifier: Modifier = Modifier, ) { Card( @@ -913,7 +927,10 @@ private fun UserCard( items = imageUris.take(MAX_INLINE_USER_IMAGE_PREVIEWS), key = { index, uri -> "$uri-$index" }, ) { _, uriString -> - UserImagePreview(uriString = uriString) + UserImagePreview( + uriString = uriString, + onClick = { onImageClick(uriString) }, + ) } val remaining = imageUris.size - MAX_INLINE_USER_IMAGE_PREVIEWS @@ -946,7 +963,10 @@ private fun UserCard( } @Composable -private fun UserImagePreview(uriString: String) { +private fun UserImagePreview( + uriString: String, + onClick: () -> Unit, +) { val uri = remember(uriString) { Uri.parse(uriString) } var loadFailed by remember(uriString) { mutableStateOf(false) } @@ -974,7 +994,8 @@ private fun UserImagePreview(uriString: String) { modifier = Modifier .size(USER_IMAGE_PREVIEW_SIZE_DP.dp) - .clip(RoundedCornerShape(8.dp)), + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onClick), contentScale = ContentScale.Crop, onError = { loadFailed = true @@ -1791,6 +1812,7 @@ internal fun PromptInputRow( ) { val context = LocalContext.current val imageEncoder = remember { ImageEncoder(context) } + var previewImageUri by rememberSaveable { mutableStateOf(null) } val submitPrompt = { onSendPrompt() @@ -1811,6 +1833,9 @@ internal fun PromptInputRow( ImageAttachmentStrip( images = pendingImages, onRemove = onRemoveImage, + onImageClick = { uri -> + previewImageUri = uri + }, ) } @@ -1866,6 +1891,13 @@ internal fun PromptInputRow( ) } } + + previewImageUri?.let { uri -> + ImagePreviewDialog( + uriString = uri, + onDismiss = { previewImageUri = null }, + ) + } } } @@ -1873,6 +1905,7 @@ internal fun PromptInputRow( private fun ImageAttachmentStrip( images: List, onRemove: (Int) -> Unit, + onImageClick: (String) -> Unit, ) { LazyRow( modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), @@ -1885,6 +1918,7 @@ private fun ImageAttachmentStrip( ImageThumbnail( image = image, onRemove = { onRemove(index) }, + onClick = { onImageClick(image.uri) }, ) } } @@ -1895,6 +1929,7 @@ private fun ImageAttachmentStrip( private fun ImageThumbnail( image: PendingImage, onRemove: () -> Unit, + onClick: () -> Unit, ) { Box( modifier = @@ -1907,7 +1942,7 @@ private fun ImageThumbnail( AsyncImage( model = uri, contentDescription = image.displayName, - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().clickable(onClick = onClick), contentScale = ContentScale.Crop, ) @@ -1975,6 +2010,42 @@ private fun formatFileSize(bytes: Long): String { } } +@Composable +private fun ImagePreviewDialog( + uriString: String, + onDismiss: () -> Unit, +) { + val uri = remember(uriString) { Uri.parse(uriString) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Box( + modifier = Modifier.fillMaxSize().background(Color.Black), + contentAlignment = Alignment.Center, + ) { + AsyncImage( + model = uri, + contentDescription = "Image preview", + modifier = Modifier.fillMaxSize().padding(16.dp), + contentScale = ContentScale.Fit, + ) + + IconButton( + onClick = onDismiss, + modifier = Modifier.align(Alignment.TopEnd).padding(12.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "Close image preview", + tint = Color.White, + ) + } + } + } +} + @Composable private fun SteerFollowUpDialog( title: String, From 5e3ce08abbfe96c1c36adf8c4c4efddb6dabb1ce Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 17 Feb 2026 10:54:48 +0000 Subject: [PATCH 22/32] fix(chat-ui): make timeline autoscroll smoother during streams --- .../ayagmar/pimobile/ui/chat/ChatScreen.kt | 52 ++++++++++++++++--- 1 file changed, 46 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt index 293c602..70be382 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt @@ -17,8 +17,10 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding @@ -775,7 +777,28 @@ private fun ChatTimeline( var previewImageUri by rememberSaveable { mutableStateOf(null) } val listState = androidx.compose.foundation.lazy.rememberLazyListState() val coroutineScope = androidx.compose.runtime.rememberCoroutineScope() - val totalItems = timeline.size + if (showInlineRunProgress) 1 else 0 + val contentItemsCount = timeline.size + if (showInlineRunProgress) 1 else 0 + val renderedItemsCount = contentItemsCount + 1 // includes bottom anchor item + val latestTimelineActivityKey = + remember(timeline, showInlineRunProgress) { + val tail = timeline.lastOrNull() + val tailKey = + when (tail) { + is ChatTimelineItem.Assistant -> { + "assistant:${tail.id}:${tail.text.length}:${tail.thinking?.length ?: 0}:${tail.isStreaming}" + } + + is ChatTimelineItem.Tool -> { + "tool:${tail.id}:${tail.output.length}:${tail.isStreaming}:${tail.isCollapsed}" + } + + is ChatTimelineItem.User -> "user:${tail.id}:${tail.text.length}:${tail.imageCount}" + null -> "empty" + } + "$tailKey:inline=$showInlineRunProgress:count=${timeline.size}" + } + var lastAutoScrollAtMs by remember { mutableStateOf(0L) } + val shouldAutoScrollToBottom by remember { derivedStateOf { @@ -786,13 +809,24 @@ private fun ChatTimeline( lastItemIndex <= 0 || lastVisibleIndex >= lastItemIndex - AUTO_SCROLL_BOTTOM_THRESHOLD_ITEMS } } - val shouldShowJumpToLatest = totalItems > 0 && !shouldAutoScrollToBottom + val shouldShowJumpToLatest = renderedItemsCount > 1 && !shouldAutoScrollToBottom // Auto-scroll only while the user stays near the bottom. // This avoids jumping when loading older history or reading past messages. - LaunchedEffect(timeline.lastOrNull(), totalItems, shouldAutoScrollToBottom) { - if (totalItems > 0 && shouldAutoScrollToBottom) { - listState.scrollToItem(totalItems - 1) + LaunchedEffect(latestTimelineActivityKey, renderedItemsCount, shouldAutoScrollToBottom) { + if (renderedItemsCount > 0 && shouldAutoScrollToBottom) { + val targetIndex = renderedItemsCount - 1 + val now = System.currentTimeMillis() + + when { + lastAutoScrollAtMs == 0L -> listState.scrollToItem(targetIndex) + now - lastAutoScrollAtMs >= AUTO_SCROLL_ANIMATION_MIN_INTERVAL_MS -> + listState.animateScrollToItem(targetIndex) + + else -> listState.scrollToItem(targetIndex) + } + + lastAutoScrollAtMs = now } } @@ -858,6 +892,10 @@ private fun ChatTimeline( ) } } + + item(key = CHAT_TIMELINE_BOTTOM_ANCHOR_KEY) { + Spacer(modifier = Modifier.height(1.dp)) + } } AnimatedVisibility( @@ -869,7 +907,7 @@ private fun ChatTimeline( OutlinedButton( onClick = { coroutineScope.launch { - listState.animateScrollToItem(totalItems - 1) + listState.animateScrollToItem(renderedItemsCount - 1) } }, modifier = Modifier.testTag(CHAT_JUMP_TO_LATEST_TAG), @@ -2442,6 +2480,8 @@ private const val MAX_ARG_DISPLAY_LENGTH = 100 private const val MAX_INLINE_USER_IMAGE_PREVIEWS = 4 private const val USER_IMAGE_PREVIEW_SIZE_DP = 56 private const val AUTO_SCROLL_BOTTOM_THRESHOLD_ITEMS = 2 +private const val AUTO_SCROLL_ANIMATION_MIN_INTERVAL_MS = 120L +private const val CHAT_TIMELINE_BOTTOM_ANCHOR_KEY = "chat_timeline_bottom_anchor" private const val TOOL_HIGHLIGHT_MAX_LENGTH = 1_000 private const val STATUS_VALUE_MAX_LENGTH = 180 private const val EXTENSION_STATUS_PILL_MAX_LENGTH = 56 From d5ad447a69dbfa2819daf1b0b98416dcc83945ef Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 17 Feb 2026 10:57:39 +0000 Subject: [PATCH 23/32] feat(chat-ui): show compactions and cost in context chip --- .../pimobile/sessions/RpcSessionController.kt | 7 ++ .../ayagmar/pimobile/ui/chat/ChatScreen.kt | 80 ++++++++++++++++--- .../ayagmar/pimobile/corerpc/RpcCommand.kt | 1 + 3 files changed, 75 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt index c716407..d8aa468 100644 --- a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt @@ -1196,6 +1196,12 @@ private fun parseSessionStats(data: JsonObject?): SessionStats { data?.stringField("sessionFile"), data?.stringField("sessionPath"), ) + val compactionCount = + coalesceInt( + data?.intField("compactions"), + data?.intField("compactionCount"), + data?.intField("autoCompactions"), + ) return SessionStats( inputTokens = inputTokens, @@ -1208,6 +1214,7 @@ private fun parseSessionStats(data: JsonObject?): SessionStats { assistantMessageCount = assistantMessageCount, toolResultCount = toolResultCount, sessionPath = sessionPath, + compactionCount = compactionCount, ) } diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt index 70be382..0ca41e5 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt @@ -33,7 +33,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.verticalScroll @@ -744,6 +743,7 @@ private fun ChatBody( hasOlderMessages = hasOlderMessages, hiddenHistoryCount = hiddenHistoryCount, expandedToolArguments = expandedToolArguments, + isRunActive = isRunActive, showInlineRunProgress = showInlineRunProgress, runPhase = runPhase, runElapsedSeconds = runElapsedSeconds, @@ -764,6 +764,7 @@ private fun ChatTimeline( hasOlderMessages: Boolean, hiddenHistoryCount: Int, expandedToolArguments: Set, + isRunActive: Boolean, showInlineRunProgress: Boolean, runPhase: LiveRunPhase, runElapsedSeconds: Long, @@ -780,7 +781,7 @@ private fun ChatTimeline( val contentItemsCount = timeline.size + if (showInlineRunProgress) 1 else 0 val renderedItemsCount = contentItemsCount + 1 // includes bottom anchor item val latestTimelineActivityKey = - remember(timeline, showInlineRunProgress) { + remember(timeline, showInlineRunProgress, isRunActive) { val tail = timeline.lastOrNull() val tailKey = when (tail) { @@ -799,7 +800,7 @@ private fun ChatTimeline( } var lastAutoScrollAtMs by remember { mutableStateOf(0L) } - val shouldAutoScrollToBottom by + val isNearBottom by remember { derivedStateOf { val layoutInfo = listState.layoutInfo @@ -809,10 +810,24 @@ private fun ChatTimeline( lastItemIndex <= 0 || lastVisibleIndex >= lastItemIndex - AUTO_SCROLL_BOTTOM_THRESHOLD_ITEMS } } + var shouldStickToBottom by remember { mutableStateOf(true) } + + LaunchedEffect(listState.isScrollInProgress, isNearBottom, renderedItemsCount) { + if (renderedItemsCount <= 1) { + shouldStickToBottom = true + return@LaunchedEffect + } + + if (listState.isScrollInProgress) { + shouldStickToBottom = isNearBottom + } + } + + val shouldAutoScrollToBottom = shouldStickToBottom || isNearBottom val shouldShowJumpToLatest = renderedItemsCount > 1 && !shouldAutoScrollToBottom - // Auto-scroll only while the user stays near the bottom. - // This avoids jumping when loading older history or reading past messages. + // Auto-scroll while the user is in follow mode (sticky near-bottom state). + // This keeps streaming/thinking updates pinned without forcing jumps after manual scroll-up. LaunchedEffect(latestTimelineActivityKey, renderedItemsCount, shouldAutoScrollToBottom) { if (renderedItemsCount > 0 && shouldAutoScrollToBottom) { val targetIndex = renderedItemsCount - 1 @@ -830,6 +845,18 @@ private fun ChatTimeline( } } + LaunchedEffect(isRunActive, shouldAutoScrollToBottom, renderedItemsCount) { + if (!isRunActive || !shouldAutoScrollToBottom || renderedItemsCount <= 0) { + return@LaunchedEffect + } + + while (true) { + val targetIndex = renderedItemsCount - 1 + listState.scrollToItem(targetIndex) + delay(STREAMING_AUTO_SCROLL_CHECK_INTERVAL_MS) + } + } + Box(modifier = modifier.fillMaxWidth()) { LazyColumn( state = listState, @@ -906,6 +933,7 @@ private fun ChatTimeline( ) { OutlinedButton( onClick = { + shouldStickToBottom = true coroutineScope.launch { listState.animateScrollToItem(renderedItemsCount - 1) } @@ -1904,8 +1932,7 @@ internal fun PromptInputRow( placeholder = { Text("Type a message...") }, singleLine = false, maxLines = 4, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - keyboardActions = KeyboardActions(onSend = { submitPrompt() }), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default), enabled = !isStreaming, trailingIcon = { if (inputText.isEmpty() && !isStreaming) { @@ -2481,6 +2508,7 @@ private const val MAX_INLINE_USER_IMAGE_PREVIEWS = 4 private const val USER_IMAGE_PREVIEW_SIZE_DP = 56 private const val AUTO_SCROLL_BOTTOM_THRESHOLD_ITEMS = 2 private const val AUTO_SCROLL_ANIMATION_MIN_INTERVAL_MS = 120L +private const val STREAMING_AUTO_SCROLL_CHECK_INTERVAL_MS = 90L private const val CHAT_TIMELINE_BOTTOM_ANCHOR_KEY = "chat_timeline_bottom_anchor" private const val TOOL_HIGHLIGHT_MAX_LENGTH = 1_000 private const val STATUS_VALUE_MAX_LENGTH = 180 @@ -2941,12 +2969,27 @@ private fun formatContextUsageLabel( val consumedTokens = (statsSnapshot.inputTokens + statsSnapshot.outputTokens).coerceAtLeast(0L) val contextWindow = currentModel?.contextWindow?.takeIf { it > 0 } - return if (contextWindow == null) { - "Ctx ${formatNumber(consumedTokens)}" - } else { - val percent = ((consumedTokens * CONTEXT_PERCENT_FACTOR) / contextWindow.toDouble()).toInt().coerceAtLeast(0) - "Ctx $percent% · ${formatNumber(consumedTokens)}/${formatNumber(contextWindow.toLong())}" - } + val contextUsage = + if (contextWindow == null) { + "Ctx ${formatNumber(consumedTokens)}" + } else { + val percent = ((consumedTokens * CONTEXT_PERCENT_FACTOR) / contextWindow.toDouble()).toInt().coerceAtLeast(0) + "Ctx $percent% · ${formatNumber(consumedTokens)}/${formatNumber(contextWindow.toLong())}" + } + + val compactionLabel = + statsSnapshot.compactionCount + .takeIf { it > 0 } + ?.let { count -> " · C$count" } + .orEmpty() + + val costLabel = + statsSnapshot.totalCost + .takeIf { it > 0.0 } + ?.let { cost -> " · ${formatCompactCost(cost)}" } + .orEmpty() + + return contextUsage + compactionLabel + costLabel } @Suppress("MagicNumber") @@ -2954,6 +2997,17 @@ private fun formatCost(value: Double): String { return String.format(java.util.Locale.US, "$%.4f", value) } +@Suppress("MagicNumber") +private fun formatCompactCost(value: Double): String { + val pattern = + when { + value >= 1.0 -> "$%.2f" + value >= 0.1 -> "$%.3f" + else -> "$%.4f" + } + return String.format(java.util.Locale.US, pattern, value) +} + @Suppress("LongParameterList", "LongMethod", "CyclomaticComplexMethod") @Composable private fun ModelPickerSheet( diff --git a/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcCommand.kt b/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcCommand.kt index 5c499f1..d5a9c4c 100644 --- a/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcCommand.kt +++ b/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcCommand.kt @@ -234,6 +234,7 @@ data class SessionStats( val assistantMessageCount: Int, val toolResultCount: Int, val sessionPath: String?, + val compactionCount: Int = 0, ) /** From 44031d6b1bc4c9371c180e20dc94f7cf7f301aed Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 17 Feb 2026 11:00:33 +0000 Subject: [PATCH 24/32] fix(network): improve reconnect recovery after socket drops --- .../pimobile/sessions/RpcSessionController.kt | 62 ++++++++++++++++++- .../pimobile/corenet/PiRpcConnection.kt | 30 +++++++++ .../pimobile/corenet/WebSocketTransport.kt | 6 +- 3 files changed, 94 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt index d8aa468..9b276ae 100644 --- a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt @@ -48,6 +48,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow @@ -91,6 +92,7 @@ class RpcSessionController( private var connectionStateJob: Job? = null private var streamingMonitorJob: Job? = null private var resyncMonitorJob: Job? = null + private var reconnectRecoveryJob: Job? = null override val rpcEvents: SharedFlow = _rpcEvents override val connectionState: StateFlow = _connectionState.asStateFlow() @@ -837,10 +839,12 @@ class RpcSessionController( connectionStateJob?.cancel() streamingMonitorJob?.cancel() resyncMonitorJob?.cancel() + reconnectRecoveryJob?.cancel() rpcEventsJob = null connectionStateJob = null streamingMonitorJob = null resyncMonitorJob = null + reconnectRecoveryJob = null activeConnection?.disconnect() activeConnection = null @@ -856,6 +860,8 @@ class RpcSessionController( connectionStateJob?.cancel() streamingMonitorJob?.cancel() resyncMonitorJob?.cancel() + reconnectRecoveryJob?.cancel() + reconnectRecoveryJob = null rpcEventsJob = scope.launch { @@ -867,7 +873,28 @@ class RpcSessionController( connectionStateJob = scope.launch { connection.connectionState.collect { state -> - _connectionState.value = state + when (state) { + ConnectionState.DISCONNECTED -> { + if (activeConnection === connection && activeContext != null) { + _connectionState.value = ConnectionState.RECONNECTING + scheduleReconnectRecovery(connection) + } else { + cancelReconnectRecovery() + _connectionState.value = ConnectionState.DISCONNECTED + } + } + + ConnectionState.CONNECTED -> { + cancelReconnectRecovery() + _connectionState.value = ConnectionState.CONNECTED + } + + ConnectionState.CONNECTING, + ConnectionState.RECONNECTING, + -> { + _connectionState.value = state + } + } } } @@ -891,6 +918,38 @@ class RpcSessionController( } } + private fun scheduleReconnectRecovery(connection: PiRpcConnection) { + if (reconnectRecoveryJob?.isActive == true) { + return + } + + reconnectRecoveryJob = + scope.launch { + delay(DISCONNECT_RECOVERY_DELAY_MS) + + if (activeConnection !== connection || activeContext == null) { + return@launch + } + + runCatching { + connection.reconnect() + }.onFailure { error -> + Log.w( + TRANSPORT_LOG_TAG, + "Automatic reconnect after disconnect failed: ${error.message ?: "unknown"}", + ) + if (activeConnection === connection) { + _connectionState.value = ConnectionState.DISCONNECTED + } + } + } + } + + private fun cancelReconnectRecovery() { + reconnectRecoveryJob?.cancel() + reconnectRecoveryJob = null + } + private fun ensureActiveConnection(): PiRpcConnection { return requireNotNull(activeConnection) { "No active session. Resume a session first." @@ -952,6 +1011,7 @@ class RpcSessionController( private const val EVENT_BUFFER_CAPACITY = 256 private const val DEFAULT_TIMEOUT_MS = 10_000L private const val BASH_TIMEOUT_MS = 60_000L + private const val DISCONNECT_RECOVERY_DELAY_MS = 700L private const val TRANSPORT_LOG_TAG = "RpcTransport" } } diff --git a/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/PiRpcConnection.kt b/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/PiRpcConnection.kt index 754d672..65e9496 100644 --- a/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/PiRpcConnection.kt +++ b/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/PiRpcConnection.kt @@ -93,6 +93,36 @@ class PiRpcConnection( resyncIfActive(connectionEpoch) } + suspend fun reconnect() { + val (reconnectConfig, reconnectEpoch) = + lifecycleMutex.withLock { + val config = activeConfig ?: error("Connection is not active") + lifecycleEpoch += 1 + startBackgroundJobs() + config to lifecycleEpoch + } + + val helloChannel = bridgeChannel(bridgeChannels, BRIDGE_HELLO_TYPE) + + transport.reconnect() + withTimeout(reconnectConfig.connectTimeoutMs) { + connectionState.first { state -> state == ConnectionState.CONNECTED } + } + + withTimeout(reconnectConfig.requestTimeoutMs) { + helloChannel.receive() + } + + ensureBridgeControl( + transport = transport, + json = json, + channels = bridgeChannels, + config = reconnectConfig, + ) + + resyncIfActive(reconnectEpoch) + } + suspend fun disconnect() { val configToRelease = lifecycleMutex.withLock { diff --git a/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/WebSocketTransport.kt b/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/WebSocketTransport.kt index 0538aa7..2c73d0d 100644 --- a/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/WebSocketTransport.kt +++ b/core-net/src/main/kotlin/com/ayagmar/pimobile/corenet/WebSocketTransport.kt @@ -391,7 +391,7 @@ class WebSocketTransport( .build() } - private const val PING_INTERVAL_SECONDS = 30L + private const val PING_INTERVAL_SECONDS = 12L private const val CONNECT_TIMEOUT_SECONDS = 10L private const val NO_TIMEOUT = 0L } @@ -416,8 +416,8 @@ data class WebSocketTarget( val url: String, val headers: Map = emptyMap(), val connectTimeoutMs: Long = 10_000, - val reconnectInitialDelayMs: Long = 250, - val reconnectMaxDelayMs: Long = 5_000, + val reconnectInitialDelayMs: Long = 120, + val reconnectMaxDelayMs: Long = 2_000, ) { fun toRequest(): Request { val builder = Request.Builder().url(url) From e91f77aa8b09afad2e16630606aed4810e9293eb Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 17 Feb 2026 11:01:17 +0000 Subject: [PATCH 25/32] test(sessions): assert compaction count parsing in stats --- .../com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt b/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt index dc108c6..cabf5d1 100644 --- a/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt @@ -36,6 +36,7 @@ class RpcSessionControllerTest { put("userMessages", 4) put("assistantMessages", 5) put("toolResults", 3) + put("compactions", 2) put("sessionFile", "/tmp/current.session.jsonl") }, ) @@ -55,6 +56,7 @@ class RpcSessionControllerTest { put("userMessageCount", 3) put("assistantMessageCount", 4) put("toolResultCount", 2) + put("compactionCount", 1) put("sessionPath", "/tmp/legacy.session.jsonl") }, ) @@ -366,6 +368,7 @@ class RpcSessionControllerTest { assertEquals(5, current.assistantMessageCount) assertEquals(3, current.toolResultCount) assertEquals("/tmp/current.session.jsonl", current.sessionPath) + assertEquals(2, current.compactionCount) } private fun assertLegacyStats(legacy: SessionStats) { @@ -379,6 +382,7 @@ class RpcSessionControllerTest { assertEquals(4, legacy.assistantMessageCount) assertEquals(2, legacy.toolResultCount) assertEquals("/tmp/legacy.session.jsonl", legacy.sessionPath) + assertEquals(1, legacy.compactionCount) } @Suppress("UNCHECKED_CAST") From d624f9ef400c4a875db154b48d18b6b878f48bd4 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 17 Feb 2026 11:03:47 +0000 Subject: [PATCH 26/32] fix(chat-ui): improve multiline input on mobile --- app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt index 0ca41e5..f01a083 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt @@ -1931,7 +1931,7 @@ internal fun PromptInputRow( modifier = Modifier.weight(1f), placeholder = { Text("Type a message...") }, singleLine = false, - maxLines = 4, + maxLines = 8, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Default), enabled = !isStreaming, trailingIcon = { From 0dc9dcba076d7a86b0c819701467e4c9b4bd1a22 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 17 Feb 2026 11:09:58 +0000 Subject: [PATCH 27/32] fix(chat): improve context accuracy and stuck-run recovery --- .../pimobile/sessions/RpcSessionController.kt | 42 ++++++++++++++++++- .../ayagmar/pimobile/ui/chat/ChatScreen.kt | 41 +++++++++++++++--- .../sessions/RpcSessionControllerTest.kt | 17 ++++++++ .../ayagmar/pimobile/corerpc/RpcCommand.kt | 3 ++ 4 files changed, 96 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt index 9b276ae..9cbe6da 100644 --- a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt @@ -40,6 +40,7 @@ import com.ayagmar.pimobile.corerpc.SetSteeringModeCommand import com.ayagmar.pimobile.corerpc.SetThinkingLevelCommand import com.ayagmar.pimobile.corerpc.SteerCommand import com.ayagmar.pimobile.corerpc.SwitchSessionCommand +import com.ayagmar.pimobile.corerpc.TurnEndEvent import com.ayagmar.pimobile.coresessions.SessionRecord import com.ayagmar.pimobile.hosts.HostProfile import kotlinx.coroutines.CoroutineScope @@ -881,6 +882,7 @@ class RpcSessionController( } else { cancelReconnectRecovery() _connectionState.value = ConnectionState.DISCONNECTED + _isStreaming.value = false } } @@ -903,7 +905,10 @@ class RpcSessionController( connection.rpcEvents.collect { event -> when (event) { is AgentStartEvent -> _isStreaming.value = true - is AgentEndEvent -> _isStreaming.value = false + is AgentEndEvent, + is TurnEndEvent, + -> _isStreaming.value = false + else -> Unit } } @@ -940,6 +945,7 @@ class RpcSessionController( ) if (activeConnection === connection) { _connectionState.value = ConnectionState.DISCONNECTED + _isStreaming.value = false } } } @@ -1263,6 +1269,29 @@ private fun parseSessionStats(data: JsonObject?): SessionStats { data?.intField("autoCompactions"), ) + val context = data?.get("context")?.jsonObject + val contextUsedTokens = + coalesceLongOrNull( + context?.longField("used"), + context?.longField("tokens"), + context?.longField("current"), + data?.longField("contextUsedTokens"), + data?.longField("contextTokens"), + data?.longField("activeContextTokens"), + ) + val contextWindowTokens = + coalesceLongOrNull( + context?.longField("window"), + context?.longField("max"), + data?.longField("contextWindow"), + ) + val contextUsagePercent = + coalesceIntOrNull( + context?.intField("percent"), + data?.intField("contextPercent"), + data?.intField("contextUsagePercent"), + ) + return SessionStats( inputTokens = inputTokens, outputTokens = outputTokens, @@ -1275,6 +1304,9 @@ private fun parseSessionStats(data: JsonObject?): SessionStats { toolResultCount = toolResultCount, sessionPath = sessionPath, compactionCount = compactionCount, + contextUsedTokens = contextUsedTokens, + contextWindowTokens = contextWindowTokens, + contextUsagePercent = contextUsagePercent, ) } @@ -1306,10 +1338,18 @@ private fun coalesceLong(vararg values: Long?): Long { return values.firstOrNull { it != null } ?: 0L } +private fun coalesceLongOrNull(vararg values: Long?): Long? { + return values.firstOrNull { it != null } +} + private fun coalesceInt(vararg values: Int?): Int { return values.firstOrNull { it != null } ?: 0 } +private fun coalesceIntOrNull(vararg values: Int?): Int? { + return values.firstOrNull { it != null } +} + private fun coalesceDouble(vararg values: Double?): Double { return values.firstOrNull { it != null } ?: 0.0 } diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt index f01a083..8388a26 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt @@ -661,7 +661,12 @@ private fun LiveRunProgressIndicator( strokeWidth = 2.dp, ) Text( - text = "Working · ${phase.label} · ${formatRunElapsed(elapsedSeconds)}", + text = + if (phase == LiveRunPhase.WORKING) { + "Working · waiting for activity · ${formatRunElapsed(elapsedSeconds)}" + } else { + "Working · ${phase.label} · ${formatRunElapsed(elapsedSeconds)}" + }, style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -2966,15 +2971,39 @@ private fun formatContextUsageLabel( currentModel: ModelInfo?, ): String { val statsSnapshot = stats ?: return "Ctx --" - val consumedTokens = (statsSnapshot.inputTokens + statsSnapshot.outputTokens).coerceAtLeast(0L) - val contextWindow = currentModel?.contextWindow?.takeIf { it > 0 } + + val explicitUsedTokens = statsSnapshot.contextUsedTokens?.coerceAtLeast(0L) + val explicitWindowTokens = statsSnapshot.contextWindowTokens?.takeIf { it > 0 } + val explicitPercent = statsSnapshot.contextUsagePercent?.coerceIn(0, 100) + + val fallbackUsedTokens = (statsSnapshot.inputTokens + statsSnapshot.outputTokens).coerceAtLeast(0L) + val fallbackWindowTokens = currentModel?.contextWindow?.takeIf { it > 0 }?.toLong() + + val hasExplicitContextUsage = explicitUsedTokens != null || explicitPercent != null + val usedTokens = explicitUsedTokens ?: fallbackUsedTokens + val contextWindow = explicitWindowTokens ?: fallbackWindowTokens val contextUsage = if (contextWindow == null) { - "Ctx ${formatNumber(consumedTokens)}" + if (hasExplicitContextUsage) { + "Ctx ${formatNumber(usedTokens)}" + } else { + "Ctx ~${formatNumber(usedTokens)}" + } } else { - val percent = ((consumedTokens * CONTEXT_PERCENT_FACTOR) / contextWindow.toDouble()).toInt().coerceAtLeast(0) - "Ctx $percent% · ${formatNumber(consumedTokens)}/${formatNumber(contextWindow.toLong())}" + val percent = + explicitPercent + ?: if (hasExplicitContextUsage) { + ((usedTokens * CONTEXT_PERCENT_FACTOR) / contextWindow.toDouble()).toInt().coerceIn(0, 100) + } else { + null + } + + if (percent == null) { + "Ctx ~${formatNumber(usedTokens)}/${formatNumber(contextWindow)}" + } else { + "Ctx $percent% · ${formatNumber(usedTokens)}/${formatNumber(contextWindow)}" + } } val compactionLabel = diff --git a/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt b/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt index cabf5d1..3c2613e 100644 --- a/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt @@ -37,6 +37,14 @@ class RpcSessionControllerTest { put("assistantMessages", 5) put("toolResults", 3) put("compactions", 2) + put( + "context", + buildJsonObject { + put("used", 3072) + put("window", 128000) + put("percent", 2) + }, + ) put("sessionFile", "/tmp/current.session.jsonl") }, ) @@ -57,6 +65,9 @@ class RpcSessionControllerTest { put("assistantMessageCount", 4) put("toolResultCount", 2) put("compactionCount", 1) + put("contextTokens", 4096) + put("contextWindow", 200000) + put("contextPercent", 2) put("sessionPath", "/tmp/legacy.session.jsonl") }, ) @@ -369,6 +380,9 @@ class RpcSessionControllerTest { assertEquals(3, current.toolResultCount) assertEquals("/tmp/current.session.jsonl", current.sessionPath) assertEquals(2, current.compactionCount) + assertEquals(3072L, current.contextUsedTokens) + assertEquals(128000L, current.contextWindowTokens) + assertEquals(2, current.contextUsagePercent) } private fun assertLegacyStats(legacy: SessionStats) { @@ -383,6 +397,9 @@ class RpcSessionControllerTest { assertEquals(2, legacy.toolResultCount) assertEquals("/tmp/legacy.session.jsonl", legacy.sessionPath) assertEquals(1, legacy.compactionCount) + assertEquals(4096L, legacy.contextUsedTokens) + assertEquals(200000L, legacy.contextWindowTokens) + assertEquals(2, legacy.contextUsagePercent) } @Suppress("UNCHECKED_CAST") diff --git a/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcCommand.kt b/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcCommand.kt index d5a9c4c..202153a 100644 --- a/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcCommand.kt +++ b/core-rpc/src/main/kotlin/com/ayagmar/pimobile/corerpc/RpcCommand.kt @@ -235,6 +235,9 @@ data class SessionStats( val toolResultCount: Int, val sessionPath: String?, val compactionCount: Int = 0, + val contextUsedTokens: Long? = null, + val contextWindowTokens: Long? = null, + val contextUsagePercent: Int? = null, ) /** From d19b18bbe099908f057aad2ddc2f1941c4c3a625 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 17 Feb 2026 11:24:03 +0000 Subject: [PATCH 28/32] fix(chat): harden context label and streaming state handling --- .../ayagmar/pimobile/chat/ChatViewModel.kt | 61 +++++----- .../pimobile/sessions/RpcSessionController.kt | 1 + .../ayagmar/pimobile/ui/chat/ChatScreen.kt | 112 +++++++++++++----- .../sessions/RpcSessionControllerTest.kt | 16 +++ .../chat/ChatScreenContextUsageLabelTest.kt | 107 +++++++++++++++++ 5 files changed, 240 insertions(+), 57 deletions(-) create mode 100644 app/src/test/java/com/ayagmar/pimobile/ui/chat/ChatScreenContextUsageLabelTest.kt diff --git a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt index 6dc1657..5e57d20 100644 --- a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt +++ b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt @@ -760,6 +760,7 @@ class ChatViewModel( } else if (wasStreaming && !isStreaming) { logThinkingDiagnostics(reason = "streaming_state_complete") logStreamingDiagnostics(reason = "streaming_state_complete") + clearStreamingTimelineFlags() } _uiState.update { current -> @@ -768,10 +769,6 @@ class ChatViewModel( pendingQueueItems = if (isStreaming) current.pendingQueueItems else emptyList(), ) } - - if (!isStreaming) { - clearStreamingTimelineFlags() - } } } } @@ -2187,32 +2184,42 @@ class ChatViewModel( } private fun clearStreamingTimelineFlags() { - updateTimelineState { state -> - state.copy( - timeline = - state.timeline.map { item -> - when (item) { - is ChatTimelineItem.Assistant -> { - if (item.isStreaming) { - item.copy(isStreaming = false) - } else { - item - } - } + if ( + fullTimeline.none { item -> + when (item) { + is ChatTimelineItem.Assistant -> item.isStreaming + is ChatTimelineItem.Tool -> item.isStreaming + is ChatTimelineItem.User -> false + } + } + ) { + return + } - is ChatTimelineItem.Tool -> { - if (item.isStreaming) { - item.copy(isStreaming = false) - } else { - item - } - } + fullTimeline = + fullTimeline.map { item -> + when (item) { + is ChatTimelineItem.Assistant -> { + if (item.isStreaming) { + item.copy(isStreaming = false) + } else { + item + } + } - is ChatTimelineItem.User -> item + is ChatTimelineItem.Tool -> { + if (item.isStreaming) { + item.copy(isStreaming = false) + } else { + item } - }, - ) - } + } + + is ChatTimelineItem.User -> item + } + } + + publishVisibleTimeline() } private fun upsertTimelineItem(item: ChatTimelineItem) { diff --git a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt index 9cbe6da..21fc608 100644 --- a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt @@ -1256,6 +1256,7 @@ private fun parseSessionStats(data: JsonObject?): SessionStats { coalesceInt( data?.intField("toolResults"), data?.intField("toolResultCount"), + data?.intField("toolCalls"), ) val sessionPath = coalesceString( diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt index 8388a26..48edfb6 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt @@ -850,10 +850,16 @@ private fun ChatTimeline( } } - LaunchedEffect(isRunActive, shouldAutoScrollToBottom, renderedItemsCount) { - if (!isRunActive || !shouldAutoScrollToBottom || renderedItemsCount <= 0) { - return@LaunchedEffect - } + LaunchedEffect( + isRunActive, + shouldAutoScrollToBottom, + renderedItemsCount, + listState.isScrollInProgress, + ) { + if (!isRunActive) return@LaunchedEffect + if (!shouldAutoScrollToBottom) return@LaunchedEffect + if (renderedItemsCount <= 0) return@LaunchedEffect + if (listState.isScrollInProgress) return@LaunchedEffect while (true) { val targetIndex = renderedItemsCount - 1 @@ -2520,6 +2526,8 @@ private const val STATUS_VALUE_MAX_LENGTH = 180 private const val EXTENSION_STATUS_PILL_MAX_LENGTH = 56 private const val MAX_COMPACT_EXTENSION_STATUS_ITEMS = 2 private const val CONTEXT_PERCENT_FACTOR = 100.0 +private const val CONTEXT_PERCENT_MIN = 0 +private const val CONTEXT_PERCENT_MAX = 100 private const val MODEL_PICKER_SCROLL_OFFSET_ITEMS = 1 private const val RUN_PROGRESS_TICK_MS = 1_000L private const val STREAMING_FRAME_LOG_TAG = "StreamingFrameMetrics" @@ -2974,37 +2982,19 @@ private fun formatContextUsageLabel( val explicitUsedTokens = statsSnapshot.contextUsedTokens?.coerceAtLeast(0L) val explicitWindowTokens = statsSnapshot.contextWindowTokens?.takeIf { it > 0 } - val explicitPercent = statsSnapshot.contextUsagePercent?.coerceIn(0, 100) + val explicitPercent = statsSnapshot.contextUsagePercent?.coerceIn(CONTEXT_PERCENT_MIN, CONTEXT_PERCENT_MAX) val fallbackUsedTokens = (statsSnapshot.inputTokens + statsSnapshot.outputTokens).coerceAtLeast(0L) val fallbackWindowTokens = currentModel?.contextWindow?.takeIf { it > 0 }?.toLong() - val hasExplicitContextUsage = explicitUsedTokens != null || explicitPercent != null - val usedTokens = explicitUsedTokens ?: fallbackUsedTokens - val contextWindow = explicitWindowTokens ?: fallbackWindowTokens - val contextUsage = - if (contextWindow == null) { - if (hasExplicitContextUsage) { - "Ctx ${formatNumber(usedTokens)}" - } else { - "Ctx ~${formatNumber(usedTokens)}" - } - } else { - val percent = - explicitPercent - ?: if (hasExplicitContextUsage) { - ((usedTokens * CONTEXT_PERCENT_FACTOR) / contextWindow.toDouble()).toInt().coerceIn(0, 100) - } else { - null - } - - if (percent == null) { - "Ctx ~${formatNumber(usedTokens)}/${formatNumber(contextWindow)}" - } else { - "Ctx $percent% · ${formatNumber(usedTokens)}/${formatNumber(contextWindow)}" - } - } + buildContextUsageCoreLabel( + explicitUsedTokens = explicitUsedTokens, + explicitWindowTokens = explicitWindowTokens, + explicitPercent = explicitPercent, + fallbackUsedTokens = fallbackUsedTokens, + fallbackWindowTokens = fallbackWindowTokens, + ) val compactionLabel = statsSnapshot.compactionCount @@ -3021,6 +3011,68 @@ private fun formatContextUsageLabel( return contextUsage + compactionLabel + costLabel } +private fun buildContextUsageCoreLabel( + explicitUsedTokens: Long?, + explicitWindowTokens: Long?, + explicitPercent: Int?, + fallbackUsedTokens: Long, + fallbackWindowTokens: Long?, +): String { + val explicitPercentLabel = + when { + explicitPercent == null -> null + explicitUsedTokens != null && explicitWindowTokens != null -> + formatExactContextUsage( + percent = explicitPercent, + usedTokens = explicitUsedTokens, + windowTokens = explicitWindowTokens, + ) + + else -> "Ctx $explicitPercent%" + } + + if (explicitPercentLabel != null) { + return explicitPercentLabel + } + + val explicitUsageLabel = + when { + explicitUsedTokens != null && explicitWindowTokens != null -> { + val computedPercent = computeContextPercent(explicitUsedTokens, explicitWindowTokens) + formatExactContextUsage( + percent = computedPercent, + usedTokens = explicitUsedTokens, + windowTokens = explicitWindowTokens, + ) + } + + explicitUsedTokens != null -> "Ctx ${formatNumber(explicitUsedTokens)}" + fallbackWindowTokens != null -> + "Ctx ~${formatNumber(fallbackUsedTokens)}/${formatNumber(fallbackWindowTokens)}" + + else -> "Ctx ~${formatNumber(fallbackUsedTokens)}" + } + + return explicitUsageLabel +} + +private fun computeContextPercent( + usedTokens: Long, + windowTokens: Long, +): Int { + return ((usedTokens * CONTEXT_PERCENT_FACTOR) / windowTokens.toDouble()) + .toInt() + .coerceIn(CONTEXT_PERCENT_MIN, CONTEXT_PERCENT_MAX) +} + +private fun formatExactContextUsage( + percent: Int, + usedTokens: Long, + windowTokens: Long, +): String { + return "Ctx $percent% · ${formatNumber(usedTokens)}/${formatNumber(windowTokens)}" +} + @Suppress("MagicNumber") private fun formatCost(value: Double): String { return String.format(java.util.Locale.US, "$%.4f", value) diff --git a/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt b/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt index 3c2613e..6e9b7ca 100644 --- a/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt @@ -74,6 +74,22 @@ class RpcSessionControllerTest { assertLegacyStats(legacy) } + @Test + fun parseSessionStatsFallsBackToToolCallsWhenToolResultsMissing() { + val stats = + invokeParser( + functionName = "parseSessionStats", + data = + buildJsonObject { + put("totalMessages", 3) + put("toolCalls", 7) + }, + ) + + assertEquals(3, stats.messageCount) + assertEquals(7, stats.toolResultCount) + } + @Test fun parseBashResultMapsCurrentAndLegacyFields() { val current = diff --git a/app/src/test/java/com/ayagmar/pimobile/ui/chat/ChatScreenContextUsageLabelTest.kt b/app/src/test/java/com/ayagmar/pimobile/ui/chat/ChatScreenContextUsageLabelTest.kt new file mode 100644 index 0000000..1f11290 --- /dev/null +++ b/app/src/test/java/com/ayagmar/pimobile/ui/chat/ChatScreenContextUsageLabelTest.kt @@ -0,0 +1,107 @@ +package com.ayagmar.pimobile.ui.chat + +import com.ayagmar.pimobile.corerpc.SessionStats +import com.ayagmar.pimobile.sessions.ModelInfo +import org.junit.Assert.assertEquals +import org.junit.Test + +class ChatScreenContextUsageLabelTest { + @Test + fun usesApproximateLabelWhenOnlyCumulativeTokenStatsExist() { + val label = + formatContextLabel( + stats = + SessionStats( + inputTokens = 2700, + outputTokens = 0, + cacheReadTokens = 0, + cacheWriteTokens = 0, + totalCost = 0.0, + messageCount = 0, + userMessageCount = 0, + assistantMessageCount = 0, + toolResultCount = 0, + sessionPath = null, + ), + currentModel = modelWithContextWindow(128_000), + ) + + assertEquals("Ctx ~2.7K/128.0K", label) + } + + @Test + fun usesExplicitContextFieldsWhenProvidedAndKeepsMinimalBadges() { + val label = + formatContextLabel( + stats = + SessionStats( + inputTokens = 50000, + outputTokens = 10000, + cacheReadTokens = 0, + cacheWriteTokens = 0, + totalCost = 0.45, + messageCount = 0, + userMessageCount = 0, + assistantMessageCount = 0, + toolResultCount = 0, + sessionPath = null, + compactionCount = 2, + contextUsedTokens = 3072, + contextWindowTokens = 128000, + contextUsagePercent = 2, + ), + currentModel = modelWithContextWindow(200_000), + ) + + assertEquals("Ctx 2% · 3.1K/128.0K · C2 · $0.450", label) + } + + @Test + fun usesPercentOnlyWhenOnlyPercentIsKnown() { + val label = + formatContextLabel( + stats = + SessionStats( + inputTokens = 90000, + outputTokens = 20000, + cacheReadTokens = 0, + cacheWriteTokens = 0, + totalCost = 0.0, + messageCount = 0, + userMessageCount = 0, + assistantMessageCount = 0, + toolResultCount = 0, + sessionPath = null, + contextUsagePercent = 90, + ), + currentModel = modelWithContextWindow(128_000), + ) + + assertEquals("Ctx 90%", label) + } + + private fun modelWithContextWindow(window: Int): ModelInfo { + return ModelInfo( + id = "m1", + name = "Model", + provider = "test", + thinkingLevel = "off", + contextWindow = window, + ) + } + + private fun formatContextLabel( + stats: SessionStats?, + currentModel: ModelInfo?, + ): String { + val method = + Class.forName(CHAT_SCREEN_FILE_CLASS) + .getDeclaredMethod("formatContextUsageLabel", SessionStats::class.java, ModelInfo::class.java) + method.isAccessible = true + return method.invoke(null, stats, currentModel) as String + } + + companion object { + private const val CHAT_SCREEN_FILE_CLASS = "com.ayagmar.pimobile.ui.chat.ChatScreenKt" + } +} From eef5da37b0238df6dc58dbe453203a84656b17d0 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 17 Feb 2026 11:38:23 +0000 Subject: [PATCH 29/32] fix(sessions): harden rpc stats/model parsing edge cases --- .../pimobile/sessions/RpcSessionController.kt | 10 ++-- .../sessions/RpcSessionControllerTest.kt | 52 +++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt index 21fc608..ff0ea7b 100644 --- a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt @@ -936,6 +936,10 @@ class RpcSessionController( return@launch } + // Clear job reference before reconnect to avoid cancelling this coroutine + // via the CONNECTED state observer while reconnect() is in-flight. + reconnectRecoveryJob = null + runCatching { connection.reconnect() }.onFailure { error -> @@ -1209,7 +1213,7 @@ private fun parseBashResult(data: JsonObject?): BashResult { @Suppress("MagicNumber", "LongMethod") private fun parseSessionStats(data: JsonObject?): SessionStats { - val tokens = data?.get("tokens")?.jsonObject + val tokens = runCatching { data?.get("tokens")?.jsonObject }.getOrNull() val inputTokens = coalesceLong( @@ -1270,7 +1274,7 @@ private fun parseSessionStats(data: JsonObject?): SessionStats { data?.intField("autoCompactions"), ) - val context = data?.get("context")?.jsonObject + val context = runCatching { data?.get("context")?.jsonObject }.getOrNull() val contextUsedTokens = coalesceLongOrNull( context?.longField("used"), @@ -1317,7 +1321,7 @@ private fun parseAvailableModels(data: JsonObject?): List { return models.mapNotNull { modelElement -> val modelObject = modelElement.jsonObject val id = modelObject.stringField("id") ?: return@mapNotNull null - val cost = modelObject["cost"]?.jsonObject + val cost = runCatching { modelObject["cost"]?.jsonObject }.getOrNull() AvailableModel( id = id, diff --git a/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt b/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt index 6e9b7ca..78b2b9d 100644 --- a/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt @@ -90,6 +90,28 @@ class RpcSessionControllerTest { assertEquals(7, stats.toolResultCount) } + @Test + fun parseSessionStatsIgnoresMalformedNestedTokenAndContextObjects() { + val stats = + invokeParser( + functionName = "parseSessionStats", + data = + buildJsonObject { + put("tokens", JsonPrimitive("not-an-object")) + put("context", JsonPrimitive("not-an-object")) + put("inputTokens", 12) + put("outputTokens", 34) + put("contextTokens", 2222) + put("contextWindow", 128000) + }, + ) + + assertEquals(12L, stats.inputTokens) + assertEquals(34L, stats.outputTokens) + assertEquals(2222L, stats.contextUsedTokens) + assertEquals(128000L, stats.contextWindowTokens) + } + @Test fun parseBashResultMapsCurrentAndLegacyFields() { val current = @@ -188,6 +210,36 @@ class RpcSessionControllerTest { assertEquals(0.005, legacy.outputCostPer1k) } + @Test + fun parseAvailableModelsIgnoresMalformedCostObjectAndUsesLegacyCostFields() { + val models = + invokeParser>( + functionName = "parseAvailableModels", + data = + buildJsonObject { + put( + "models", + buildJsonArray { + add( + buildJsonObject { + put("id", "model-with-bad-cost") + put("name", "Model With Bad Cost") + put("provider", "openai") + put("cost", JsonPrimitive("invalid-shape")) + put("inputCostPer1k", 0.004) + put("outputCostPer1k", 0.012) + }, + ) + }, + ) + }, + ) + + assertEquals(1, models.size) + assertEquals(0.004, models.single().inputCostPer1k) + assertEquals(0.012, models.single().outputCostPer1k) + } + @Test fun parseModelInfoSupportsSetModelDirectPayload() { val model = From 63694b75c36e2d4f4771ec3eacb598872f4d9127 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 17 Feb 2026 11:46:30 +0000 Subject: [PATCH 30/32] refactor(chat): reduce timeline complexity and harden parsing --- .../pimobile/sessions/RpcSessionController.kt | 6 +- .../ayagmar/pimobile/ui/chat/ChatScreen.kt | 214 ++++++++++++------ .../sessions/RpcSessionControllerTest.kt | 46 ++++ .../chat/ChatScreenContextUsageLabelTest.kt | 24 ++ 4 files changed, 216 insertions(+), 74 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt index ff0ea7b..c3d5fc0 100644 --- a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt @@ -71,6 +71,7 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import java.util.UUID +import kotlin.math.roundToInt @Suppress("TooManyFunctions", "LargeClass") class RpcSessionController( @@ -1293,8 +1294,11 @@ private fun parseSessionStats(data: JsonObject?): SessionStats { val contextUsagePercent = coalesceIntOrNull( context?.intField("percent"), + context?.doubleField("percent")?.roundToInt(), data?.intField("contextPercent"), + data?.doubleField("contextPercent")?.roundToInt(), data?.intField("contextUsagePercent"), + data?.doubleField("contextUsagePercent")?.roundToInt(), ) return SessionStats( @@ -1319,7 +1323,7 @@ private fun parseAvailableModels(data: JsonObject?): List { val models = runCatching { data?.get("models")?.jsonArray }.getOrNull() ?: JsonArray(emptyList()) return models.mapNotNull { modelElement -> - val modelObject = modelElement.jsonObject + val modelObject = runCatching { modelElement.jsonObject }.getOrNull() ?: return@mapNotNull null val id = modelObject.stringField("id") ?: return@mapNotNull null val cost = runCatching { modelObject["cost"]?.jsonObject }.getOrNull() diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt index 48edfb6..3319ee4 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt @@ -786,7 +786,7 @@ private fun ChatTimeline( val contentItemsCount = timeline.size + if (showInlineRunProgress) 1 else 0 val renderedItemsCount = contentItemsCount + 1 // includes bottom anchor item val latestTimelineActivityKey = - remember(timeline, showInlineRunProgress, isRunActive) { + remember(timeline, showInlineRunProgress) { val tail = timeline.lastOrNull() val tailKey = when (tail) { @@ -856,85 +856,46 @@ private fun ChatTimeline( renderedItemsCount, listState.isScrollInProgress, ) { - if (!isRunActive) return@LaunchedEffect - if (!shouldAutoScrollToBottom) return@LaunchedEffect - if (renderedItemsCount <= 0) return@LaunchedEffect - if (listState.isScrollInProgress) return@LaunchedEffect + val shouldRunStreamingAutoScrollLoop = + isRunActive && + shouldAutoScrollToBottom && + renderedItemsCount > 0 && + !listState.isScrollInProgress + if (!shouldRunStreamingAutoScrollLoop) { + return@LaunchedEffect + } while (true) { val targetIndex = renderedItemsCount - 1 - listState.scrollToItem(targetIndex) + val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + (lastVisibleIndex < targetIndex) + .takeIf { it } + ?.let { + listState.scrollToItem(targetIndex) + } delay(STREAMING_AUTO_SCROLL_CHECK_INTERVAL_MS) } } Box(modifier = modifier.fillMaxWidth()) { - LazyColumn( - state = listState, - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - if (hasOlderMessages) { - item(key = "load-older-messages") { - TextButton( - onClick = onLoadOlderMessages, - modifier = Modifier.fillMaxWidth(), - ) { - Text("Load older messages ($hiddenHistoryCount hidden)") - } - } - } - - items(items = timeline, key = { item -> item.id }) { item -> - when (item) { - is ChatTimelineItem.User -> { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - UserCard( - text = item.text, - imageCount = item.imageCount, - imageUris = item.imageUris, - onImageClick = { uri -> - previewImageUri = uri - }, - ) - } - } - - is ChatTimelineItem.Assistant -> { - AssistantCard( - item = item, - onToggleThinkingExpansion = onToggleThinkingExpansion, - ) - } - - is ChatTimelineItem.Tool -> { - ToolCard( - item = item, - isArgumentsExpanded = item.id in expandedToolArguments, - onToggleToolExpansion = onToggleToolExpansion, - onToggleDiffExpansion = onToggleDiffExpansion, - onToggleArgumentsExpansion = onToggleToolArgumentsExpansion, - ) - } - } - } - - if (showInlineRunProgress) { - item(key = "inline-run-progress") { - InlineRunProgressCard( - phase = runPhase, - elapsedSeconds = runElapsedSeconds, - ) - } - } - - item(key = CHAT_TIMELINE_BOTTOM_ANCHOR_KEY) { - Spacer(modifier = Modifier.height(1.dp)) - } - } + ChatTimelineList( + listState = listState, + timeline = timeline, + hasOlderMessages = hasOlderMessages, + hiddenHistoryCount = hiddenHistoryCount, + expandedToolArguments = expandedToolArguments, + showInlineRunProgress = showInlineRunProgress, + runPhase = runPhase, + runElapsedSeconds = runElapsedSeconds, + onLoadOlderMessages = onLoadOlderMessages, + onToggleToolExpansion = onToggleToolExpansion, + onToggleThinkingExpansion = onToggleThinkingExpansion, + onToggleDiffExpansion = onToggleDiffExpansion, + onToggleToolArgumentsExpansion = onToggleToolArgumentsExpansion, + onPreviewImage = { uri -> + previewImageUri = uri + }, + ) AnimatedVisibility( visible = shouldShowJumpToLatest, @@ -964,6 +925,112 @@ private fun ChatTimeline( } } +@Suppress("LongParameterList") +@Composable +private fun ChatTimelineList( + listState: androidx.compose.foundation.lazy.LazyListState, + timeline: List, + hasOlderMessages: Boolean, + hiddenHistoryCount: Int, + expandedToolArguments: Set, + showInlineRunProgress: Boolean, + runPhase: LiveRunPhase, + runElapsedSeconds: Long, + onLoadOlderMessages: () -> Unit, + onToggleToolExpansion: (String) -> Unit, + onToggleThinkingExpansion: (String) -> Unit, + onToggleDiffExpansion: (String) -> Unit, + onToggleToolArgumentsExpansion: (String) -> Unit, + onPreviewImage: (String) -> Unit, +) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (hasOlderMessages) { + item(key = "load-older-messages") { + TextButton( + onClick = onLoadOlderMessages, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Load older messages ($hiddenHistoryCount hidden)") + } + } + } + + items(items = timeline, key = { item -> item.id }) { item -> + ChatTimelineRow( + item = item, + expandedToolArguments = expandedToolArguments, + onToggleToolExpansion = onToggleToolExpansion, + onToggleThinkingExpansion = onToggleThinkingExpansion, + onToggleDiffExpansion = onToggleDiffExpansion, + onToggleToolArgumentsExpansion = onToggleToolArgumentsExpansion, + onPreviewImage = onPreviewImage, + ) + } + + if (showInlineRunProgress) { + item(key = "inline-run-progress") { + InlineRunProgressCard( + phase = runPhase, + elapsedSeconds = runElapsedSeconds, + ) + } + } + + item(key = CHAT_TIMELINE_BOTTOM_ANCHOR_KEY) { + Spacer(modifier = Modifier.height(1.dp)) + } + } +} + +@Suppress("LongParameterList") +@Composable +private fun ChatTimelineRow( + item: ChatTimelineItem, + expandedToolArguments: Set, + onToggleToolExpansion: (String) -> Unit, + onToggleThinkingExpansion: (String) -> Unit, + onToggleDiffExpansion: (String) -> Unit, + onToggleToolArgumentsExpansion: (String) -> Unit, + onPreviewImage: (String) -> Unit, +) { + when (item) { + is ChatTimelineItem.User -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + UserCard( + text = item.text, + imageCount = item.imageCount, + imageUris = item.imageUris, + onImageClick = onPreviewImage, + ) + } + } + + is ChatTimelineItem.Assistant -> { + AssistantCard( + item = item, + onToggleThinkingExpansion = onToggleThinkingExpansion, + ) + } + + is ChatTimelineItem.Tool -> { + ToolCard( + item = item, + isArgumentsExpanded = item.id in expandedToolArguments, + onToggleToolExpansion = onToggleToolExpansion, + onToggleDiffExpansion = onToggleDiffExpansion, + onToggleArgumentsExpansion = onToggleToolArgumentsExpansion, + ) + } + } +} + @Suppress("LongMethod") @Composable private fun UserCard( @@ -2986,6 +3053,7 @@ private fun formatContextUsageLabel( val fallbackUsedTokens = (statsSnapshot.inputTokens + statsSnapshot.outputTokens).coerceAtLeast(0L) val fallbackWindowTokens = currentModel?.contextWindow?.takeIf { it > 0 }?.toLong() + val approximateWindowTokens = explicitWindowTokens ?: fallbackWindowTokens val contextUsage = buildContextUsageCoreLabel( @@ -2993,7 +3061,7 @@ private fun formatContextUsageLabel( explicitWindowTokens = explicitWindowTokens, explicitPercent = explicitPercent, fallbackUsedTokens = fallbackUsedTokens, - fallbackWindowTokens = fallbackWindowTokens, + fallbackWindowTokens = approximateWindowTokens, ) val compactionLabel = diff --git a/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt b/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt index 78b2b9d..6b70643 100644 --- a/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/sessions/RpcSessionControllerTest.kt @@ -112,6 +112,25 @@ class RpcSessionControllerTest { assertEquals(128000L, stats.contextWindowTokens) } + @Test + fun parseSessionStatsRoundsDecimalContextPercent() { + val stats = + invokeParser( + functionName = "parseSessionStats", + data = + buildJsonObject { + put( + "context", + buildJsonObject { + put("percent", 2.6) + }, + ) + }, + ) + + assertEquals(3, stats.contextUsagePercent) + } + @Test fun parseBashResultMapsCurrentAndLegacyFields() { val current = @@ -240,6 +259,33 @@ class RpcSessionControllerTest { assertEquals(0.012, models.single().outputCostPer1k) } + @Test + fun parseAvailableModelsSkipsMalformedEntriesAndKeepsValidModels() { + val models = + invokeParser>( + functionName = "parseAvailableModels", + data = + buildJsonObject { + put( + "models", + buildJsonArray { + add(JsonPrimitive("malformed")) + add( + buildJsonObject { + put("id", "valid-model") + put("name", "Valid Model") + put("provider", "openai") + }, + ) + }, + ) + }, + ) + + assertEquals(1, models.size) + assertEquals("valid-model", models.single().id) + } + @Test fun parseModelInfoSupportsSetModelDirectPayload() { val model = diff --git a/app/src/test/java/com/ayagmar/pimobile/ui/chat/ChatScreenContextUsageLabelTest.kt b/app/src/test/java/com/ayagmar/pimobile/ui/chat/ChatScreenContextUsageLabelTest.kt index 1f11290..6b19ba7 100644 --- a/app/src/test/java/com/ayagmar/pimobile/ui/chat/ChatScreenContextUsageLabelTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/ui/chat/ChatScreenContextUsageLabelTest.kt @@ -80,6 +80,30 @@ class ChatScreenContextUsageLabelTest { assertEquals("Ctx 90%", label) } + @Test + fun usesExplicitWindowAsApproximateFallbackWhenUsedTokensMissing() { + val label = + formatContextLabel( + stats = + SessionStats( + inputTokens = 3200, + outputTokens = 0, + cacheReadTokens = 0, + cacheWriteTokens = 0, + totalCost = 0.0, + messageCount = 0, + userMessageCount = 0, + assistantMessageCount = 0, + toolResultCount = 0, + sessionPath = null, + contextWindowTokens = 64000, + ), + currentModel = null, + ) + + assertEquals("Ctx ~3.2K/64.0K", label) + } + private fun modelWithContextWindow(window: Int): ModelInfo { return ModelInfo( id = "m1", From a661dd957217d501bc4d1e89f772c83f3a9efc58 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 17 Feb 2026 12:00:00 +0000 Subject: [PATCH 31/32] refactor(chat): simplify prompt flow and autoscroll state --- .../ayagmar/pimobile/chat/ChatViewModel.kt | 156 +++++++---- .../ayagmar/pimobile/ui/chat/ChatScreen.kt | 256 ++++++++++++------ 2 files changed, 278 insertions(+), 134 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt index 5e57d20..ea15f63 100644 --- a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt +++ b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt @@ -14,6 +14,7 @@ import com.ayagmar.pimobile.corerpc.AutoRetryStartEvent import com.ayagmar.pimobile.corerpc.AvailableModel import com.ayagmar.pimobile.corerpc.ExtensionErrorEvent import com.ayagmar.pimobile.corerpc.ExtensionUiRequestEvent +import com.ayagmar.pimobile.corerpc.ImagePayload import com.ayagmar.pimobile.corerpc.MessageEndEvent import com.ayagmar.pimobile.corerpc.MessageStartEvent import com.ayagmar.pimobile.corerpc.MessageUpdateEvent @@ -135,26 +136,65 @@ class ChatViewModel( val pendingImages = currentState.pendingImages if (message.isEmpty() && pendingImages.isEmpty()) return - val slashInvocation = message.extractKnownSlashInvocation() - if (slashInvocation != null) { - if (pendingImages.isNotEmpty()) { - _uiState.update { - it.copy(errorMessage = "Image attachments are only supported for normal prompts") - } - return + if (handleSlashInvocationIfNeeded(message = message, pendingImages = pendingImages)) { + return + } + + preparePromptDispatch() + val optimisticUserId = addOptimisticUserMessage(message = message, pendingImages = pendingImages) + + viewModelScope.launch { + val imagePayloads = encodePendingImages(pendingImages) + + if (message.isEmpty() && imagePayloads.isEmpty()) { + handleImageEncodingFailure(optimisticUserId) + return@launch } + val clearedDraftState = clearDraftAfterPromptDispatch(currentState) + + val result = sessionController.sendPrompt(message, imagePayloads) + if (result.isFailure) { + handleSendPromptFailure( + result = result, + optimisticUserId = optimisticUserId, + currentState = currentState, + clearedDraftState = clearedDraftState, + ) + } + } + } + + private fun handleSlashInvocationIfNeeded( + message: String, + pendingImages: List, + ): Boolean { + val slashInvocation = message.extractKnownSlashInvocation() ?: return false + + if (pendingImages.isNotEmpty()) { + _uiState.update { + it.copy(errorMessage = "Image attachments are only supported for normal prompts") + } + } else { handleKnownSlashCommand(slashInvocation) - return } + return true + } + + private fun preparePromptDispatch() { _uiState.update { it.copy(sessionCoherencyWarning = null) } // Record prompt send for TTFT tracking recordMetricsSafely { PerformanceMetrics.recordPromptSend() } hasRecordedFirstToken = false markLocalSessionMutationExpected() + } + private fun addOptimisticUserMessage( + message: String, + pendingImages: List, + ): String { val optimisticUserId = "$LOCAL_USER_ITEM_PREFIX${UUID.randomUUID()}" upsertTimelineItem( ChatTimelineItem.User( @@ -165,53 +205,64 @@ class ChatViewModel( ), ) pendingLocalUserIds.addLast(optimisticUserId) + return optimisticUserId + } - viewModelScope.launch { - val imagePayloads = - withContext(Dispatchers.Default) { - pendingImages.mapNotNull { pending -> - imageEncoder?.encodeToPayload(pending) - } - } - - if (message.isEmpty() && imagePayloads.isEmpty()) { - discardPendingLocalUserItem(optimisticUserId) - _uiState.update { - it.copy(errorMessage = "Unable to attach image. Please try again.") - } - return@launch + private suspend fun encodePendingImages(pendingImages: List): List { + return withContext(Dispatchers.Default) { + pendingImages.mapNotNull { pending -> + imageEncoder?.encodeToPayload(pending) } + } + } - var inputWasCleared = false - var imagesWereCleared = false - _uiState.update { state -> - val shouldClearInput = state.inputText == currentState.inputText - val shouldClearImages = state.pendingImages == currentState.pendingImages - inputWasCleared = shouldClearInput - imagesWereCleared = shouldClearImages + private fun handleImageEncodingFailure(optimisticUserId: String) { + discardPendingLocalUserItem(optimisticUserId) + _uiState.update { + it.copy(errorMessage = "Unable to attach image. Please try again.") + } + } - state.copy( - inputText = if (shouldClearInput) "" else state.inputText, - pendingImages = if (shouldClearImages) emptyList() else state.pendingImages, - errorMessage = null, - ) - } + private fun clearDraftAfterPromptDispatch(currentState: ChatUiState): DraftClearState { + var inputWasCleared = false + var imagesWereCleared = false - val result = sessionController.sendPrompt(message, imagePayloads) - if (result.isFailure) { - discardPendingLocalUserItem(optimisticUserId) - _uiState.update { state -> - val shouldRestoreDraft = - (inputWasCleared || imagesWereCleared) && - state.inputText.isEmpty() && - state.pendingImages.isEmpty() - state.copy( - inputText = if (shouldRestoreDraft) currentState.inputText else state.inputText, - pendingImages = if (shouldRestoreDraft) currentState.pendingImages else state.pendingImages, - errorMessage = result.exceptionOrNull()?.message, - ) - } - } + _uiState.update { state -> + val shouldClearInput = state.inputText == currentState.inputText + val shouldClearImages = state.pendingImages == currentState.pendingImages + inputWasCleared = shouldClearInput + imagesWereCleared = shouldClearImages + + state.copy( + inputText = if (shouldClearInput) "" else state.inputText, + pendingImages = if (shouldClearImages) emptyList() else state.pendingImages, + errorMessage = null, + ) + } + + return DraftClearState( + inputWasCleared = inputWasCleared, + imagesWereCleared = imagesWereCleared, + ) + } + + private fun handleSendPromptFailure( + result: Result, + optimisticUserId: String, + currentState: ChatUiState, + clearedDraftState: DraftClearState, + ) { + discardPendingLocalUserItem(optimisticUserId) + _uiState.update { state -> + val shouldRestoreDraft = + (clearedDraftState.inputWasCleared || clearedDraftState.imagesWereCleared) && + state.inputText.isEmpty() && + state.pendingImages.isEmpty() + state.copy( + inputText = if (shouldRestoreDraft) currentState.inputText else state.inputText, + pendingImages = if (shouldRestoreDraft) currentState.pendingImages else state.pendingImages, + errorMessage = result.exceptionOrNull()?.message, + ) } } @@ -2765,6 +2816,11 @@ private data class InitialLoadMetadata( val sessionPath: String?, ) +private data class DraftClearState( + val inputWasCleared: Boolean, + val imagesWereCleared: Boolean, +) + private data class ThinkingDiagnosticsCounters( val startEvents: Int = 0, val deltaEvents: Int = 0, diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt index 3319ee4..5292d8a 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt @@ -762,7 +762,7 @@ private fun ChatBody( } } -@Suppress("LongParameterList", "LongMethod") +@Suppress("LongParameterList") @Composable private fun ChatTimeline( timeline: List, @@ -782,29 +782,139 @@ private fun ChatTimeline( ) { var previewImageUri by rememberSaveable { mutableStateOf(null) } val listState = androidx.compose.foundation.lazy.rememberLazyListState() + val autoScrollUi = + rememberTimelineAutoScrollUi( + listState = listState, + timeline = timeline, + showInlineRunProgress = showInlineRunProgress, + isRunActive = isRunActive, + ) + + Box(modifier = modifier.fillMaxWidth()) { + ChatTimelineList( + listState = listState, + timeline = timeline, + hasOlderMessages = hasOlderMessages, + hiddenHistoryCount = hiddenHistoryCount, + expandedToolArguments = expandedToolArguments, + showInlineRunProgress = showInlineRunProgress, + runPhase = runPhase, + runElapsedSeconds = runElapsedSeconds, + onLoadOlderMessages = onLoadOlderMessages, + onToggleToolExpansion = onToggleToolExpansion, + onToggleThinkingExpansion = onToggleThinkingExpansion, + onToggleDiffExpansion = onToggleDiffExpansion, + onToggleToolArgumentsExpansion = onToggleToolArgumentsExpansion, + onPreviewImage = { uri -> + previewImageUri = uri + }, + ) + + AnimatedVisibility( + visible = autoScrollUi.shouldShowJumpToLatest, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp), + ) { + OutlinedButton( + onClick = autoScrollUi.onJumpToLatest, + modifier = Modifier.testTag(CHAT_JUMP_TO_LATEST_TAG), + ) { + Text("Jump to latest") + } + } + + previewImageUri?.let { uri -> + ImagePreviewDialog( + uriString = uri, + onDismiss = { previewImageUri = null }, + ) + } + } +} + +private data class TimelineAutoScrollUi( + val shouldShowJumpToLatest: Boolean, + val onJumpToLatest: () -> Unit, +) + +@Suppress("LongMethod") +@Composable +private fun rememberTimelineAutoScrollUi( + listState: androidx.compose.foundation.lazy.LazyListState, + timeline: List, + showInlineRunProgress: Boolean, + isRunActive: Boolean, +): TimelineAutoScrollUi { val coroutineScope = androidx.compose.runtime.rememberCoroutineScope() val contentItemsCount = timeline.size + if (showInlineRunProgress) 1 else 0 val renderedItemsCount = contentItemsCount + 1 // includes bottom anchor item val latestTimelineActivityKey = remember(timeline, showInlineRunProgress) { - val tail = timeline.lastOrNull() - val tailKey = - when (tail) { - is ChatTimelineItem.Assistant -> { - "assistant:${tail.id}:${tail.text.length}:${tail.thinking?.length ?: 0}:${tail.isStreaming}" - } + buildLatestTimelineActivityKey( + timeline = timeline, + showInlineRunProgress = showInlineRunProgress, + ) + } + val isNearBottom = rememberIsNearBottom(listState) + var shouldStickToBottom by + rememberShouldStickToBottom( + listState = listState, + isNearBottom = isNearBottom, + renderedItemsCount = renderedItemsCount, + ) - is ChatTimelineItem.Tool -> { - "tool:${tail.id}:${tail.output.length}:${tail.isStreaming}:${tail.isCollapsed}" - } + val shouldAutoScrollToBottom = shouldStickToBottom || isNearBottom - is ChatTimelineItem.User -> "user:${tail.id}:${tail.text.length}:${tail.imageCount}" - null -> "empty" - } - "$tailKey:inline=$showInlineRunProgress:count=${timeline.size}" + runActivityAutoScroll( + listState = listState, + latestTimelineActivityKey = latestTimelineActivityKey, + renderedItemsCount = renderedItemsCount, + shouldAutoScrollToBottom = shouldAutoScrollToBottom, + ) + + runStreamingAutoScroll( + listState = listState, + isRunActive = isRunActive, + shouldAutoScrollToBottom = shouldAutoScrollToBottom, + renderedItemsCount = renderedItemsCount, + ) + + return TimelineAutoScrollUi( + shouldShowJumpToLatest = renderedItemsCount > 1 && !shouldAutoScrollToBottom, + onJumpToLatest = { + shouldStickToBottom = true + coroutineScope.launch { + listState.animateScrollToItem(renderedItemsCount - 1) + } + }, + ) +} + +private fun buildLatestTimelineActivityKey( + timeline: List, + showInlineRunProgress: Boolean, +): String { + val tail = timeline.lastOrNull() + val tailKey = + when (tail) { + is ChatTimelineItem.Assistant -> { + "assistant:${tail.id}:${tail.text.length}:${tail.thinking?.length ?: 0}:${tail.isStreaming}" + } + + is ChatTimelineItem.Tool -> { + "tool:${tail.id}:${tail.output.length}:${tail.isStreaming}:${tail.isCollapsed}" + } + + is ChatTimelineItem.User -> "user:${tail.id}:${tail.text.length}:${tail.imageCount}" + null -> "empty" } - var lastAutoScrollAtMs by remember { mutableStateOf(0L) } + return "$tailKey:inline=$showInlineRunProgress:count=${timeline.size}" +} + +@Composable +private fun rememberIsNearBottom(listState: androidx.compose.foundation.lazy.LazyListState): Boolean { val isNearBottom by remember { derivedStateOf { @@ -815,41 +925,68 @@ private fun ChatTimeline( lastItemIndex <= 0 || lastVisibleIndex >= lastItemIndex - AUTO_SCROLL_BOTTOM_THRESHOLD_ITEMS } } - var shouldStickToBottom by remember { mutableStateOf(true) } + + return isNearBottom +} + +@Composable +private fun rememberShouldStickToBottom( + listState: androidx.compose.foundation.lazy.LazyListState, + isNearBottom: Boolean, + renderedItemsCount: Int, +): androidx.compose.runtime.MutableState { + val shouldStickToBottom = remember { mutableStateOf(true) } LaunchedEffect(listState.isScrollInProgress, isNearBottom, renderedItemsCount) { if (renderedItemsCount <= 1) { - shouldStickToBottom = true + shouldStickToBottom.value = true return@LaunchedEffect } if (listState.isScrollInProgress) { - shouldStickToBottom = isNearBottom + shouldStickToBottom.value = isNearBottom } } - val shouldAutoScrollToBottom = shouldStickToBottom || isNearBottom - val shouldShowJumpToLatest = renderedItemsCount > 1 && !shouldAutoScrollToBottom + return shouldStickToBottom +} + +@Composable +private fun runActivityAutoScroll( + listState: androidx.compose.foundation.lazy.LazyListState, + latestTimelineActivityKey: String, + renderedItemsCount: Int, + shouldAutoScrollToBottom: Boolean, +) { + var lastAutoScrollAtMs by remember { mutableStateOf(0L) } - // Auto-scroll while the user is in follow mode (sticky near-bottom state). - // This keeps streaming/thinking updates pinned without forcing jumps after manual scroll-up. LaunchedEffect(latestTimelineActivityKey, renderedItemsCount, shouldAutoScrollToBottom) { - if (renderedItemsCount > 0 && shouldAutoScrollToBottom) { - val targetIndex = renderedItemsCount - 1 - val now = System.currentTimeMillis() + if (renderedItemsCount <= 0 || !shouldAutoScrollToBottom) { + return@LaunchedEffect + } - when { - lastAutoScrollAtMs == 0L -> listState.scrollToItem(targetIndex) - now - lastAutoScrollAtMs >= AUTO_SCROLL_ANIMATION_MIN_INTERVAL_MS -> - listState.animateScrollToItem(targetIndex) + val targetIndex = renderedItemsCount - 1 + val now = System.currentTimeMillis() - else -> listState.scrollToItem(targetIndex) - } + when { + lastAutoScrollAtMs == 0L -> listState.scrollToItem(targetIndex) + now - lastAutoScrollAtMs >= AUTO_SCROLL_ANIMATION_MIN_INTERVAL_MS -> + listState.animateScrollToItem(targetIndex) - lastAutoScrollAtMs = now + else -> listState.scrollToItem(targetIndex) } + + lastAutoScrollAtMs = now } +} +@Composable +private fun runStreamingAutoScroll( + listState: androidx.compose.foundation.lazy.LazyListState, + isRunActive: Boolean, + shouldAutoScrollToBottom: Boolean, + renderedItemsCount: Int, +) { LaunchedEffect( isRunActive, shouldAutoScrollToBottom, @@ -868,59 +1005,10 @@ private fun ChatTimeline( while (true) { val targetIndex = renderedItemsCount - 1 val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 - (lastVisibleIndex < targetIndex) - .takeIf { it } - ?.let { - listState.scrollToItem(targetIndex) - } - delay(STREAMING_AUTO_SCROLL_CHECK_INTERVAL_MS) - } - } - - Box(modifier = modifier.fillMaxWidth()) { - ChatTimelineList( - listState = listState, - timeline = timeline, - hasOlderMessages = hasOlderMessages, - hiddenHistoryCount = hiddenHistoryCount, - expandedToolArguments = expandedToolArguments, - showInlineRunProgress = showInlineRunProgress, - runPhase = runPhase, - runElapsedSeconds = runElapsedSeconds, - onLoadOlderMessages = onLoadOlderMessages, - onToggleToolExpansion = onToggleToolExpansion, - onToggleThinkingExpansion = onToggleThinkingExpansion, - onToggleDiffExpansion = onToggleDiffExpansion, - onToggleToolArgumentsExpansion = onToggleToolArgumentsExpansion, - onPreviewImage = { uri -> - previewImageUri = uri - }, - ) - - AnimatedVisibility( - visible = shouldShowJumpToLatest, - enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier.align(Alignment.BottomEnd).padding(8.dp), - ) { - OutlinedButton( - onClick = { - shouldStickToBottom = true - coroutineScope.launch { - listState.animateScrollToItem(renderedItemsCount - 1) - } - }, - modifier = Modifier.testTag(CHAT_JUMP_TO_LATEST_TAG), - ) { - Text("Jump to latest") + if (lastVisibleIndex < targetIndex) { + listState.scrollToItem(targetIndex) } - } - - previewImageUri?.let { uri -> - ImagePreviewDialog( - uriString = uri, - onDismiss = { previewImageUri = null }, - ) + delay(STREAMING_AUTO_SCROLL_CHECK_INTERVAL_MS) } } } From efdbb79915546475f4fdfede41a07acce33ffab8 Mon Sep 17 00:00:00 2001 From: Abdeslam Yassine Agmar Date: Tue, 17 Feb 2026 13:40:34 +0000 Subject: [PATCH 32/32] fix(test): cancel viewModelScope after each test to prevent coroutine leaks Fix failing test 'successfulPromptClearsSentImagesEvenWhenUserTypesNewDraftMidFlight' that was crashing due to uncaught exceptions from leaked coroutines. The issue was that viewModelScope coroutines continued running after tests finished and crashed when Dispatchers.resetMain() was called. Changes: - Add viewModels list to track created ViewModels in tests - Cancel viewModelScope and clear list in @After before resetting Main dispatcher - Add createViewModel() helper to ensure all ViewModels are tracked - Fix ktlint multiline expression formatting issues - Fix Compose naming convention for runActivityAutoScroll and runStreamingAutoScroll --- .../ayagmar/pimobile/ui/chat/ChatScreen.kt | 8 +- .../ChatViewModelThinkingExpansionTest.kt | 96 +++++++++++-------- 2 files changed, 60 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt index 5292d8a..e975f68 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/chat/ChatScreen.kt @@ -866,14 +866,14 @@ private fun rememberTimelineAutoScrollUi( val shouldAutoScrollToBottom = shouldStickToBottom || isNearBottom - runActivityAutoScroll( + RunActivityAutoScroll( listState = listState, latestTimelineActivityKey = latestTimelineActivityKey, renderedItemsCount = renderedItemsCount, shouldAutoScrollToBottom = shouldAutoScrollToBottom, ) - runStreamingAutoScroll( + RunStreamingAutoScroll( listState = listState, isRunActive = isRunActive, shouldAutoScrollToBottom = shouldAutoScrollToBottom, @@ -952,7 +952,7 @@ private fun rememberShouldStickToBottom( } @Composable -private fun runActivityAutoScroll( +private fun RunActivityAutoScroll( listState: androidx.compose.foundation.lazy.LazyListState, latestTimelineActivityKey: String, renderedItemsCount: Int, @@ -981,7 +981,7 @@ private fun runActivityAutoScroll( } @Composable -private fun runStreamingAutoScroll( +private fun RunStreamingAutoScroll( listState: androidx.compose.foundation.lazy.LazyListState, isRunActive: Boolean, shouldAutoScrollToBottom: Boolean, diff --git a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt index 823da57..d9009cd 100644 --- a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt @@ -2,6 +2,7 @@ package com.ayagmar.pimobile.chat +import androidx.lifecycle.viewModelScope import com.ayagmar.pimobile.corerpc.AgentEndEvent import com.ayagmar.pimobile.corerpc.AssistantMessageEvent import com.ayagmar.pimobile.corerpc.MessageEndEvent @@ -12,6 +13,7 @@ import com.ayagmar.pimobile.sessions.TreeNavigationResult import com.ayagmar.pimobile.testutil.FakeSessionController import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest @@ -30,22 +32,30 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class ChatViewModelThinkingExpansionTest { private val dispatcher = StandardTestDispatcher() + private val viewModels = mutableListOf() @Before fun setUp() { Dispatchers.setMain(dispatcher) + viewModels.clear() } @After fun tearDown() { + viewModels.forEach { it.viewModelScope.cancel() } + viewModels.clear() Dispatchers.resetMain() } + private fun createViewModel(controller: FakeSessionController): ChatViewModel { + return ChatViewModel(sessionController = controller).also { viewModels.add(it) } + } + @Test fun thinkingExpansionStatePersistsAcrossStreamingUpdatesWhenMessageKeyChanges() = runTest(dispatcher) { val controller = FakeSessionController() - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -109,7 +119,7 @@ class ChatViewModelThinkingExpansionTest { fun thinkingExpansionStateRemainsStableOnFinalStreamingUpdate() = runTest(dispatcher) { val controller = FakeSessionController() - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -166,7 +176,7 @@ class ChatViewModelThinkingExpansionTest { fun pendingAssistantDeltaIsFlushedWhenMessageEnds() = runTest(dispatcher) { val controller = FakeSessionController() - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -209,7 +219,7 @@ class ChatViewModelThinkingExpansionTest { fun pendingAssistantDeltaIsFlushedWhenAgentEnds() = runTest(dispatcher) { val controller = FakeSessionController() - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -249,7 +259,7 @@ class ChatViewModelThinkingExpansionTest { fun sessionChangeDropsPendingAssistantDeltaFromPreviousSession() = runTest(dispatcher) { val controller = FakeSessionController() - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -320,7 +330,7 @@ class ChatViewModelThinkingExpansionTest { ), ) - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -344,7 +354,7 @@ class ChatViewModelThinkingExpansionTest { fun slashPaletteDoesNotAutoOpenForRegularTextContexts() = runTest(dispatcher) { val controller = FakeSessionController() - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -365,7 +375,7 @@ class ChatViewModelThinkingExpansionTest { fun selectingCommandReplacesTrailingSlashToken() = runTest(dispatcher) { val controller = FakeSessionController() - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -402,7 +412,7 @@ class ChatViewModelThinkingExpansionTest { ), ) - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -429,7 +439,7 @@ class ChatViewModelThinkingExpansionTest { fun sendingInteractiveBuiltinShowsExplicitMessageWithoutRpcSend() = runTest(dispatcher) { val controller = FakeSessionController() - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -445,7 +455,7 @@ class ChatViewModelThinkingExpansionTest { fun sendingModelSlashCommandOpensModelPickerWithoutRpcPrompt() = runTest(dispatcher) { val controller = FakeSessionController() - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -461,7 +471,7 @@ class ChatViewModelThinkingExpansionTest { fun sendingNameSlashCommandRenamesSessionWithoutRpcPrompt() = runTest(dispatcher) { val controller = FakeSessionController() - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -477,11 +487,13 @@ class ChatViewModelThinkingExpansionTest { @Test fun slashCommandsClearExistingErrorsOnSuccessfulExecution() = runTest(dispatcher) { - val controller = FakeSessionController().apply { - abortResult = Result.failure(IllegalStateException("abort failed")) - abortRetryResult = Result.failure(IllegalStateException("abort retry failed")) - } - val viewModel = ChatViewModel(sessionController = controller) + val controller = + FakeSessionController() + .apply { + abortResult = Result.failure(IllegalStateException("abort failed")) + abortRetryResult = Result.failure(IllegalStateException("abort retry failed")) + } + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -514,7 +526,7 @@ class ChatViewModelThinkingExpansionTest { fun selectingBridgeBackedBuiltinTreeOpensTreeSheet() = runTest(dispatcher) { val controller = FakeSessionController() - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -536,7 +548,7 @@ class ChatViewModelThinkingExpansionTest { fun streamingSteerAndFollowUpAreVisibleInPendingQueueInspectorState() = runTest(dispatcher) { val controller = FakeSessionController() - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -557,7 +569,7 @@ class ChatViewModelThinkingExpansionTest { fun pendingQueueCanBeRemovedClearedAndResetsWhenStreamingStops() = runTest(dispatcher) { val controller = FakeSessionController() - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -590,7 +602,7 @@ class ChatViewModelThinkingExpansionTest { fun turnEndClearsStreamingIndicatorsAndPendingQueue() = runTest(dispatcher) { val controller = FakeSessionController() - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -627,7 +639,7 @@ class ChatViewModelThinkingExpansionTest { abortResult = Result.failure(IllegalStateException("abort failed")) abortRetryResult = Result.success(Unit) } - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -647,7 +659,7 @@ class ChatViewModelThinkingExpansionTest { abortResult = Result.failure(IllegalStateException("abort failed")) abortRetryResult = Result.failure(IllegalStateException("abort retry failed")) } - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -665,7 +677,7 @@ class ChatViewModelThinkingExpansionTest { val controller = FakeSessionController() controller.messagesPayload = historyWithUserMessages(count = 260) - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -697,7 +709,7 @@ class ChatViewModelThinkingExpansionTest { val controller = FakeSessionController() controller.messagesPayload = historyWithUserMessages(count = 1_500) - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -722,7 +734,7 @@ class ChatViewModelThinkingExpansionTest { runTest(dispatcher) { val controller = FakeSessionController() controller.messagesPayload = historyWithMessageTexts(listOf("baseline")) - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -743,7 +755,7 @@ class ChatViewModelThinkingExpansionTest { runTest(dispatcher) { val controller = FakeSessionController() controller.messagesPayload = historyWithMessageTexts(listOf("unchanged")) - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -773,7 +785,7 @@ class ChatViewModelThinkingExpansionTest { sessionPath = "/tmp/session.jsonl", ), ) - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -788,7 +800,7 @@ class ChatViewModelThinkingExpansionTest { fun repeatedPromptTextReplacesOptimisticUserItemsInOrder() = runTest(dispatcher) { val controller = FakeSessionController() - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -840,7 +852,7 @@ class ChatViewModelThinkingExpansionTest { runTest(dispatcher) { val controller = FakeSessionController() controller.sendPromptResult = Result.failure(IllegalStateException("rpc failed")) - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -858,11 +870,13 @@ class ChatViewModelThinkingExpansionTest { @Test fun sendPromptFailureDoesNotOverwriteNewerDraftInput() = runTest(dispatcher) { - val controller = FakeSessionController().apply { - sendPromptResult = Result.failure(IllegalStateException("rpc failed")) - sendPromptDelayMs = 50L - } - val viewModel = ChatViewModel(sessionController = controller) + val controller = + FakeSessionController() + .apply { + sendPromptResult = Result.failure(IllegalStateException("rpc failed")) + sendPromptDelayMs = 50L + } + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -882,10 +896,12 @@ class ChatViewModelThinkingExpansionTest { @Test fun successfulPromptClearsSentImagesEvenWhenUserTypesNewDraftMidFlight() = runTest(dispatcher) { - val controller = FakeSessionController().apply { - sendPromptDelayMs = 50L - } - val viewModel = ChatViewModel(sessionController = controller) + val controller = + FakeSessionController() + .apply { + sendPromptDelayMs = 50L + } + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel) @@ -915,7 +931,7 @@ class ChatViewModelThinkingExpansionTest { fun serverUserMessagePreservesPendingImageUris() = runTest(dispatcher) { val controller = FakeSessionController() - val viewModel = ChatViewModel(sessionController = controller) + val viewModel = createViewModel(controller) dispatcher.scheduler.advanceUntilIdle() awaitInitialLoad(viewModel)