diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9b19240..87a678e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -56,6 +56,7 @@ android { abortOnError = true checkReleaseBuilds = true warningsAsErrors = true + disable += "GradleDependency" baseline = file("lint-baseline.xml") } } 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 f38e5c6..f1ba5f1 100644 --- a/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt +++ b/app/src/main/java/com/ayagmar/pimobile/chat/ChatViewModel.kt @@ -1631,11 +1631,33 @@ class ChatViewModel( null } + val shouldForceRuntimeReload = + reason == TimelineReloadReason.MANUAL_SYNC || + reason == TimelineReloadReason.AUTO_FRESHNESS_REFRESH + initialLoadJob?.cancel() initialLoadJob = viewModelScope.launch(Dispatchers.IO) { - val messagesResult = sessionController.getMessages() - val stateResult = sessionController.getState() + val reloadResult = + if (shouldForceRuntimeReload) { + sessionController.reloadActiveSessionFromDisk() + } else { + null + } + val reloadError = reloadResult?.exceptionOrNull() + + val messagesResult = + if (reloadError == null) { + sessionController.getMessages() + } else { + Result.failure(reloadError) + } + val stateResult = + if (reloadError == null) { + sessionController.getState() + } else { + Result.failure(reloadError) + } if (messagesResult.isSuccess) { recordMetricsSafely { PerformanceMetrics.recordFirstMessagesRendered() } @@ -1670,7 +1692,7 @@ class ChatViewModel( } } - latestSessionPath = metadata.sessionPath ?: latestSessionPath + latestSessionPath = metadata.sessionPath ?: reloadResult?.getOrNull() ?: latestSessionPath refreshSessionFreshness(trigger = FreshnessCheckTrigger.POST_LOAD) val refreshedHistorySignature = historyWindowSignature(historyWindowMessages) @@ -1684,7 +1706,7 @@ class ChatViewModel( state.copy( isSyncingSession = false, sessionCoherencyWarning = - if (hasPotentialExternalChanges) { + if (messagesResult.isFailure || hasPotentialExternalChanges) { SESSION_COHERENCY_WARNING_MESSAGE } else { null @@ -1705,7 +1727,14 @@ class ChatViewModel( } } else if (reason == TimelineReloadReason.AUTO_FRESHNESS_REFRESH) { _uiState.update { - it.copy(sessionCoherencyWarning = null) + it.copy( + sessionCoherencyWarning = + if (messagesResult.isSuccess) { + null + } else { + SESSION_COHERENCY_WARNING_MESSAGE + }, + ) } if (messagesResult.isSuccess) { 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 df9af37..4ca608c 100644 --- a/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/RpcSessionController.kt @@ -193,6 +193,30 @@ class RpcSessionController( } } + override suspend fun reloadActiveSessionFromDisk(): Result { + return mutex.withLock { + runCatching { + val connection = ensureActiveConnection() + val sessionPath = refreshCurrentSessionPath(connection) + check(!sessionPath.isNullOrBlank()) { + "No active session file available to reload" + } + + val switchResponse = + sendAndAwaitResponse( + connection = connection, + requestTimeoutMs = requestTimeoutMs, + command = SwitchSessionCommand(id = UUID.randomUUID().toString(), sessionPath = sessionPath), + expectedCommand = SWITCH_SESSION_COMMAND, + ).requireSuccess("Failed to reload active session") + + switchResponse.requireNotCancelled("Active session reload was cancelled") + + refreshCurrentSessionPath(connection) + } + } + } + override suspend fun renameSession(name: String): Result { return mutex.withLock { runCatching { 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 8bded12..e593990 100644 --- a/app/src/main/java/com/ayagmar/pimobile/sessions/SessionController.kt +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/SessionController.kt @@ -51,6 +51,8 @@ interface SessionController { suspend fun getState(): Result + suspend fun reloadActiveSessionFromDisk(): Result + suspend fun sendPrompt( message: String, images: List = emptyList(), diff --git a/app/src/main/java/com/ayagmar/pimobile/sessions/SessionsViewModel.kt b/app/src/main/java/com/ayagmar/pimobile/sessions/SessionsViewModel.kt index 7410309..c551af7 100644 --- a/app/src/main/java/com/ayagmar/pimobile/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/ayagmar/pimobile/sessions/SessionsViewModel.kt @@ -254,8 +254,10 @@ class SessionsViewModel( } fun resumeSession(session: SessionRecord) { - val hostId = _uiState.value.selectedHostId ?: return - val selectedHost = _uiState.value.hosts.firstOrNull { host -> host.id == hostId } ?: return + val state = _uiState.value + val hostId = state.selectedHostId ?: return + val selectedHost = state.hosts.firstOrNull { host -> host.id == hostId } ?: return + val isOpenCurrentSession = state.activeSessionPath == session.sessionPath // Record resume start for performance tracking PerformanceMetrics.recordResumeStart() @@ -291,7 +293,8 @@ class SessionsViewModel( if (resumeResult.isSuccess) { markConnectionWarm(hostId = hostId, cwd = session.cwd) - emitMessage("Resumed ${session.summaryTitle()}") + val action = if (isOpenCurrentSession) "Opened" else "Resumed" + emitMessage("$action ${session.summaryTitle()}") _navigateToChat.trySend(Unit) } diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/components/PiTopBar.kt b/app/src/main/java/com/ayagmar/pimobile/ui/components/PiTopBar.kt index b391667..8bf7866 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/components/PiTopBar.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/components/PiTopBar.kt @@ -1,6 +1,6 @@ package com.ayagmar.pimobile.ui.components -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable @@ -15,10 +15,15 @@ fun PiTopBar( ) { Row( modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - title() + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterStart, + ) { + title() + } + actions() } } diff --git a/app/src/main/java/com/ayagmar/pimobile/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/ayagmar/pimobile/ui/sessions/SessionsScreen.kt index 639da72..f89478b 100644 --- a/app/src/main/java/com/ayagmar/pimobile/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/ayagmar/pimobile/ui/sessions/SessionsScreen.kt @@ -610,8 +610,8 @@ private fun SessionCardFooter( ) PiButton( - label = if (isActive) "Active" else "Resume", - enabled = !isBusy && !isActive, + label = if (isActive) "Open" else "Resume", + enabled = !isBusy, onClick = onResumeClick, ) } 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 8ce16e0..a493837 100644 --- a/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt +++ b/app/src/test/java/com/ayagmar/pimobile/chat/ChatViewModelThinkingExpansionTest.kt @@ -748,6 +748,7 @@ class ChatViewModelThinkingExpansionTest { waitForState(viewModel) { state -> !state.isSyncingSession } val state = viewModel.uiState.value + assertEquals(1, controller.reloadActiveSessionCallCount) assertEquals( "Potential cross-device session edits detected. Use Sync now before continuing.", state.sessionCoherencyWarning, @@ -773,9 +774,37 @@ class ChatViewModelThinkingExpansionTest { dispatcher.scheduler.advanceUntilIdle() waitForState(viewModel) { state -> !state.isSyncingSession } + assertEquals(2, controller.reloadActiveSessionCallCount) assertEquals(null, viewModel.uiState.value.sessionCoherencyWarning) } + @Test + fun syncNowShowsErrorAndSkipsMessageFetchWhenReloadFails() = + runTest(dispatcher) { + val controller = FakeSessionController() + controller.messagesPayload = historyWithMessageTexts(listOf("unchanged")) + val viewModel = createViewModel(controller) + dispatcher.scheduler.advanceUntilIdle() + awaitInitialLoad(viewModel) + + val baselineMessageCalls = controller.getMessagesCallCount + val baselineStateCalls = controller.getStateCallCount + controller.reloadActiveSessionResult = Result.failure(IllegalStateException("reload failed")) + + viewModel.syncNow() + dispatcher.scheduler.advanceUntilIdle() + + waitForState(viewModel) { state -> !state.isSyncingSession } + assertEquals(1, controller.reloadActiveSessionCallCount) + assertEquals(baselineMessageCalls, controller.getMessagesCallCount) + assertEquals(baselineStateCalls, controller.getStateCallCount) + assertEquals("reload failed", viewModel.uiState.value.errorMessage) + assertEquals( + "Potential cross-device session edits detected. Use Sync now before continuing.", + viewModel.uiState.value.sessionCoherencyWarning, + ) + } + @Test fun jumpAndContinueUsesInPlaceTreeNavigationResult() = 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 00a2564..0e21e29 100644 --- a/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt +++ b/app/src/test/java/com/ayagmar/pimobile/testutil/FakeSessionController.kt @@ -38,6 +38,7 @@ class FakeSessionController : SessionController { var sendPromptCallCount: Int = 0 var getMessagesCallCount: Int = 0 var getStateCallCount: Int = 0 + var reloadActiveSessionCallCount: Int = 0 var getSessionFreshnessCallCount: Int = 0 var getStateResult: Result = Result.success( @@ -47,6 +48,7 @@ class FakeSessionController : SessionController { success = true, ), ) + var reloadActiveSessionResult: Result = Result.success(null) var lastPromptMessage: String? = null var lastFreshnessSessionPath: String? = null var lastImportedSessionFileName: String? = null @@ -147,6 +149,11 @@ class FakeSessionController : SessionController { return getStateResult } + override suspend fun reloadActiveSessionFromDisk(): Result { + reloadActiveSessionCallCount += 1 + return reloadActiveSessionResult + } + override suspend fun sendPrompt( message: String, images: List,