diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index cc7a1022..6090254d 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -346,6 +346,8 @@ fun MainNavigation(sessionId: String? = null) { val bottomNavItems = resolveBottomNavItems(bottomNavItemsState) val bottomNavKeys = remember(bottomNavItems) { bottomNavItems.mapTo(mutableSetOf()) { it.key } } + val drawerEntriesBySection = remember { DRAWER_ENTRIES.groupBy { it.section } } + // Sync primary screens to NavigationController LaunchedEffect(bottomNavKeys) { NavigationController.updatePrimaryScreens(bottomNavKeys) @@ -419,33 +421,31 @@ fun MainNavigation(sessionId: String? = null) { color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.SemiBold, ) - DRAWER_ENTRIES - .filter { it.section == section } - .forEach { entry -> - NavigationDrawerItem( - icon = { Icon(entry.icon, contentDescription = null) }, - label = { Text(stringResource(entry.labelRes)) }, - selected = currentScreen == entry.key, - onClick = { - scope.launch { drawerState.close() } - NavigationController.navigateTo(entry.key) - }, - colors = - NavigationDrawerItemDefaults.colors( - selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, - selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer, - selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer, + drawerEntriesBySection[section]?.forEach { entry -> + NavigationDrawerItem( + icon = { Icon(entry.icon, contentDescription = null) }, + label = { Text(stringResource(entry.labelRes)) }, + selected = currentScreen == entry.key, + onClick = { + scope.launch { drawerState.close() } + NavigationController.navigateTo(entry.key) + }, + colors = + NavigationDrawerItemDefaults.colors( + selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, + selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer, + selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + modifier = + Modifier + .padding(horizontal = 8.dp, vertical = 1.dp) + .testTag( + "drawer_${entry.key::class.simpleName?.lowercase()?.removeSuffix( + "screen", + ) ?: ""}", ), - modifier = - Modifier - .padding(horizontal = 8.dp, vertical = 1.dp) - .testTag( - "drawer_${entry.key::class.simpleName?.lowercase()?.removeSuffix( - "screen", - ) ?: ""}", - ), - ) - } + ) + } } Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 4f2862d7..5f8efea2 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -212,7 +212,10 @@ interface HermesApiService { suspend fun getModelOptions(): Response @GET("api/logs") - suspend fun getLogs(): Response + suspend fun getLogs( + @Query("limit") limit: Int? = null, + @Query("lines") lines: Int? = null, + ): Response @GET("api/dashboard/plugins/hub") suspend fun getPlugins(): Response diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt index dbda34ab..8e4f2b8a 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/NetworkResult.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.data.remote +import kotlinx.coroutines.delay import retrofit2.Response import java.io.IOException @@ -68,6 +69,8 @@ suspend inline fun safeApiCall( } else { val code = response.code() if (code in 500..599 && attempt < retries) { + val backoff = 500L * (1 shl attempt) + delay(backoff) continue } return NetworkResult.Failure(mapHttpError(code)) @@ -82,6 +85,8 @@ suspend inline fun safeApiCall( ), ) } + val backoff = 500L * (1 shl attempt) + delay(backoff) } catch (e: Exception) { return NetworkResult.Failure( NetworkError.Unknown( diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 65dd1a66..2cf7403c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -28,6 +28,7 @@ import okhttp3.Request import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener +import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger @@ -64,6 +65,7 @@ object HermesWsClient { private val requestId = AtomicInteger(0) private val connected = AtomicBoolean(false) private val intentionalClose = AtomicBoolean(false) + private val messageQueue = ConcurrentLinkedQueue() @Volatile private var webSocket: WebSocket? = null @@ -170,7 +172,13 @@ object HermesWsClient { // prompts (PromptSubmit params include the `text` field) — never // stream to logcat in release builds. if (BuildConfig.DEBUG) Log.d(TAG, "→ $json") - webSocket?.send(json) ?: Log.w(TAG, "send() called while disconnected") + val ws = webSocket + if (ws != null && connected.get()) { + ws.send(json) + } else { + Log.d(TAG, "WS disconnected — queuing message") + messageQueue.add(json) + } return id } @@ -243,17 +251,25 @@ object HermesWsClient { private class WsListenerImpl : WebSocketListener() { override fun onOpen( - ws: WebSocket, + webSocket: WebSocket, response: Response, ) { Log.i(TAG, "WebSocket opened") connected.set(true) _connectionStatus.value = ConnectionStatus.CONNECTED currentBackoff = INITIAL_BACKOFF_MS + + while (messageQueue.isNotEmpty()) { + val msg = messageQueue.poll() + if (msg != null) { + if (BuildConfig.DEBUG) Log.d(TAG, "→ (queued) $msg") + webSocket.send(msg) + } + } } override fun onMessage( - ws: WebSocket, + webSocket: WebSocket, text: String, ) { // B2 (Jun 18 2026, kanban t_8884db16): incoming WS text contains @@ -264,16 +280,16 @@ object HermesWsClient { } override fun onClosing( - ws: WebSocket, + webSocket: WebSocket, code: Int, reason: String, ) { Log.d(TAG, "WebSocket closing: $code $reason") - ws.close(code, reason) + webSocket.close(code, reason) } override fun onClosed( - ws: WebSocket, + webSocket: WebSocket, code: Int, reason: String, ) { @@ -284,7 +300,7 @@ object HermesWsClient { } override fun onFailure( - ws: WebSocket, + webSocket: WebSocket, t: Throwable, response: Response?, ) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt index fb93d30a..26c43828 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt @@ -72,6 +72,11 @@ fun KanbanScreen( task.assignedTo?.contains(query, ignoreCase = true) == true } } + + val tasksByColumn = + remember(filteredTasks) { + filteredTasks.groupBy { it.status.lowercase() } + } var showAddTaskDialog by remember { mutableStateOf(false) } LaunchedEffect(Unit) { @@ -149,13 +154,13 @@ fun KanbanScreen( contentPadding = PaddingValues(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - items(state.columns, key = { it.name }) { column -> + items(state.columns.size, key = { + index -> + state.columns[index].name + }) { columnIndex -> + val column = state.columns[columnIndex] val colName = column.name - val colTasks = - filteredTasks.filter { - it.status.equals(colName, ignoreCase = true) - } - val columnIndex = state.columns.indexOf(column) + val colTasks = tasksByColumn[colName.lowercase()] ?: emptyList() val prevColumn = state.columns.getOrNull(columnIndex - 1) val nextColumn = state.columns.getOrNull(columnIndex + 1) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt index c791e1bb..6d2ab24f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsViewModel.kt @@ -23,7 +23,7 @@ class LogsViewModel : ViewModel(), ToastHost { fun loadLogs() { safeLaunchLoad( - apiCall = { safeApiCall { ApiClient.hermesApi.getLogs() } }, + apiCall = { safeApiCall { ApiClient.hermesApi.getLogs(lines = 1000) } }, onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } }, onSuccess = { data -> val body = data diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 4006809c..227f2d5d 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -851,7 +851,7 @@ class E2eIntegrationTest { @Test fun testLogsListing_success() = runTest { - coEvery { mockApiService.getLogs() } returns + coEvery { mockApiService.getLogs(lines = 1000) } returns Response.success(LogResponse(listOf("Log line 1", "Log line 2"))) val viewModel = LogsViewModel()