From 1735de7d9d623f27ebaef3de46ef04d74fe9fdf7 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:49:35 +0300 Subject: [PATCH] fix(kanban): address PR correctness and CI regressions (#1137) --- .../ui/kanban/KanbanScreenTest.kt | 17 +- .../data/local/KanbanPreferencesStore.kt | 6 + .../data/model/KanbanTaskModels.kt | 43 ++--- .../ui/kanban/KanbanModelOverride.kt | 14 ++ .../ui/kanban/KanbanRuntimeState.kt | 16 ++ .../hermescontrol/ui/kanban/KanbanScreen.kt | 149 +++++++++++------- .../ui/kanban/KanbanTaskScreen.kt | 92 ++++++++--- .../ui/kanban/KanbanTaskViewModel.kt | 34 +++- .../ui/kanban/KanbanViewModel.kt | 17 +- .../components/KanbanBoardSettingsDialog.kt | 8 +- .../components/KanbanCreateTaskDialog.kt | 23 ++- .../ui/kanban/components/KanbanFilterSheet.kt | 19 ++- .../components/KanbanModelOverrideEditor.kt | 61 ++++--- .../components/KanbanOrchestrationDialog.kt | 2 +- .../ui/kanban/components/KanbanTaskCard.kt | 19 ++- .../util/StreamingUriRequestBody.kt | 37 +++++ app/src/main/res/values-ar/strings.xml | 57 +++++++ app/src/main/res/values-ko/strings.xml | 57 +++++++ app/src/main/res/values-zh/strings.xml | 57 +++++++ app/src/main/res/values/strings.xml | 57 +++++++ .../data/model/KanbanSerializationTest.kt | 25 +++ .../ui/kanban/KanbanModelOverrideTest.kt | 16 ++ .../ui/kanban/KanbanMutationTest.kt | 36 +++++ .../ui/kanban/KanbanTaskViewModelTest.kt | 38 ++++- .../util/StreamingUriRequestBodyTest.kt | 68 ++++++++ 25 files changed, 809 insertions(+), 159 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/util/StreamingUriRequestBody.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/util/StreamingUriRequestBodyTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanScreenTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanScreenTest.kt index 3a7a6d27..2e88773f 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanScreenTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanScreenTest.kt @@ -1,9 +1,12 @@ package com.m57.hermescontrol.ui.kanban +import androidx.compose.ui.test.assertDoesNotExist import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.m57.hermescontrol.data.model.KanbanColumn @@ -45,7 +48,7 @@ class KanbanScreenTest { } } - composeTestRule.onNodeWithText("Show archived").performClick() + composeTestRule.onNodeWithTag("kanban_show_archived").performClick() assertTrue(toggled) } @@ -66,18 +69,18 @@ class KanbanScreenTest { } // Expand advanced options - composeTestRule.onNodeWithText("Advanced Options").performClick() + composeTestRule.onNodeWithTag("kanban_advanced_options").performClick() // By default scratch is selected, workspace path is not visible - composeTestRule.onNodeWithText("Workspace Path Override").assertDoesNotExist() + composeTestRule.onNodeWithTag("kanban_workspace_path").assertDoesNotExist() // Select Worktree - composeTestRule.onNodeWithText("Worktree").performClick() - composeTestRule.onNodeWithText("Workspace Path Override").assertIsDisplayed() + composeTestRule.onNodeWithTag("kanban_workspace_kind_worktree").performClick() + composeTestRule.onNodeWithTag("kanban_workspace_path").performScrollTo().assertIsDisplayed() // Select Scratch again - composeTestRule.onNodeWithText("Scratch (Default)").performClick() - composeTestRule.onNodeWithText("Workspace Path Override").assertDoesNotExist() + composeTestRule.onNodeWithTag("kanban_workspace_kind_scratch").performClick() + composeTestRule.onNodeWithTag("kanban_workspace_path").assertDoesNotExist() } @Test diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/KanbanPreferencesStore.kt b/app/src/main/java/com/m57/hermescontrol/data/local/KanbanPreferencesStore.kt index 9f42707e..79583bdb 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/local/KanbanPreferencesStore.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/local/KanbanPreferencesStore.kt @@ -12,6 +12,8 @@ interface KanbanPreferencesStore { slug: String, ) + fun clearSelectedBoard(endpoint: String) + fun getViewMode(): String fun setViewMode(mode: String) @@ -45,6 +47,10 @@ class InMemoryKanbanPreferencesStore : KanbanPreferencesStore { selectedBoards[endpoint] = slug } + override fun clearSelectedBoard(endpoint: String) { + selectedBoards.remove(endpoint) + } + override fun getViewMode(): String = viewMode override fun setViewMode(mode: String) { diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt index 194f10a7..6ec97abb 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.data.model +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement @@ -146,18 +147,18 @@ data class CreateTaskBody( val assignee: String? = null, val tenant: String? = null, val priority: Int = 0, - val workspaceKind: String? = null, - val workspacePath: String? = null, + @SerialName("workspace_kind") val workspaceKind: String? = null, + @SerialName("workspace_path") val workspacePath: String? = null, val parents: List = emptyList(), val triage: Boolean = false, - val maxRuntimeSeconds: Int? = null, + @SerialName("max_runtime_seconds") val maxRuntimeSeconds: Int? = null, val skills: List? = null, - val goalMode: Boolean = false, - val goalMaxTurns: Int? = null, - val modelOverride: String? = null, - val providerOverride: String? = null, - val reasoningEffort: String? = null, - val projectId: String? = null, + @SerialName("goal_mode") val goalMode: Boolean = false, + @SerialName("goal_max_turns") val goalMaxTurns: Int? = null, + @SerialName("model_override") val modelOverride: String? = null, + @SerialName("provider_override") val providerOverride: String? = null, + @SerialName("reasoning_effort") val reasoningEffort: String? = null, + @SerialName("project_id") val projectId: String? = null, ) @Serializable @@ -174,14 +175,14 @@ data class UpdateTaskBody( val title: String? = null, val body: String? = null, val result: String? = null, - val blockReason: String? = null, + @SerialName("block_reason") val blockReason: String? = null, val summary: String? = null, val metadata: Map? = null, - val modelOverride: String? = null, - val providerOverride: String? = null, - val clearModelOverride: Boolean = false, - val reasoningEffort: String? = null, - val clearReasoningEffort: Boolean = false, + @SerialName("model_override") val modelOverride: String? = null, + @SerialName("provider_override") val providerOverride: String? = null, + @SerialName("clear_model_override") val clearModelOverride: Boolean = false, + @SerialName("reasoning_effort") val reasoningEffort: String? = null, + @SerialName("clear_reasoning_effort") val clearReasoningEffort: Boolean = false, ) @Serializable @@ -199,12 +200,12 @@ data class BulkTasksBody( val result: String? = null, val summary: String? = null, val metadata: Map? = null, - val reclaimFirst: Boolean = false, - val modelOverride: String? = null, - val providerOverride: String? = null, - val clearModelOverride: Boolean = false, - val reasoningEffort: String? = null, - val clearReasoningEffort: Boolean = false, + @SerialName("reclaim_first") val reclaimFirst: Boolean = false, + @SerialName("model_override") val modelOverride: String? = null, + @SerialName("provider_override") val providerOverride: String? = null, + @SerialName("clear_model_override") val clearModelOverride: Boolean = false, + @SerialName("reasoning_effort") val reasoningEffort: String? = null, + @SerialName("clear_reasoning_effort") val clearReasoningEffort: Boolean = false, ) @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanModelOverride.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanModelOverride.kt index 326d23a6..19b4f293 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanModelOverride.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanModelOverride.kt @@ -2,6 +2,20 @@ package com.m57.hermescontrol.ui.kanban import com.m57.hermescontrol.data.model.UpdateTaskBody +private val ALL_REASONING_EFFORTS = listOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra") + +fun supportedKanbanReasoningEfforts( + supportsReasoning: Boolean, + canDisableReasoning: Boolean, +): List = + if (!supportsReasoning) { + listOf("") + } else if (canDisableReasoning) { + ALL_REASONING_EFFORTS + } else { + ALL_REASONING_EFFORTS.filterNot { it == "none" } + } + /** * Task model override matching desktop's TaskModelOverride contract: * - model: model name or ID diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanRuntimeState.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanRuntimeState.kt index 50b6ba27..d9acd1f3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanRuntimeState.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanRuntimeState.kt @@ -1,6 +1,9 @@ package com.m57.hermescontrol.ui.kanban +import androidx.compose.runtime.Composable +import androidx.compose.runtime.produceState import com.m57.hermescontrol.data.model.KanbanTask +import kotlinx.coroutines.delay enum class KanbanArcState { QUEUED, @@ -48,3 +51,16 @@ object KanbanRuntimeHelper { } } } + +/** One coarse clock per board screen, rather than one coroutine per task card. */ +@Composable +fun rememberKanbanNowSeconds(enabled: Boolean): Long = + produceState( + initialValue = System.currentTimeMillis() / 1000L, + key1 = enabled, + ) { + while (enabled) { + delay(5_000L) + value = System.currentTimeMillis() / 1000L + } + }.value 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 9001d6fc..d5274829 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 @@ -89,6 +89,7 @@ import com.m57.hermescontrol.ui.kanban.components.KanbanCreateTaskDialog import com.m57.hermescontrol.ui.kanban.components.KanbanFilterSheet import com.m57.hermescontrol.ui.kanban.components.KanbanOrchestrationDialog import com.m57.hermescontrol.ui.kanban.components.KanbanTaskCard +import com.m57.hermescontrol.util.StreamingUriRequestBody import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -107,6 +108,7 @@ fun KanbanScreen( viewModel: KanbanViewModel = viewModel { KanbanViewModel() }, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val nowSeconds = rememberKanbanNowSeconds(state.tasks.any { it.status.equals("running", ignoreCase = true) }) val context = LocalContext.current val coroutineScope = rememberCoroutineScope() @@ -168,10 +170,12 @@ fun KanbanScreen( try { val cachedFile = File(pendingExportPath!!) if (cachedFile.exists()) { - context.contentResolver.openOutputStream(uri)?.use { output -> - cachedFile.inputStream().use { input -> - input.copyTo(output) - } + withContext(Dispatchers.IO) { + context.contentResolver.openOutputStream(uri)?.use { output -> + cachedFile.inputStream().use { input -> + input.copyTo(output) + } + } ?: error("Unable to open selected destination") } viewModel.showToast("Board exported successfully") } @@ -199,50 +203,64 @@ fun KanbanScreen( } } - val inputStream = context.contentResolver.openInputStream(uri) - if (inputStream != null) { - val bytes = withContext(Dispatchers.IO) { inputStream.use { it.readBytes() } } - val targetPath = "kanban-imports/$fileName" - val pathBody = targetPath.toRequestBody("text/plain".toMediaTypeOrNull()) - val overwriteBody = "true".toRequestBody("text/plain".toMediaTypeOrNull()) - val filePart = - MultipartBody.Part.createFormData( - "file", - fileName, - bytes.toRequestBody("application/gzip".toMediaTypeOrNull()), - ) - val uploadResult = - withContext(Dispatchers.IO) { - safeApiCall { - ApiClient.hermesApi.uploadManagedFileStream(pathBody, overwriteBody, filePart) + val contentLength = + context.contentResolver + .query( + uri, + arrayOf(OpenableColumns.SIZE), + null, + null, + null, + )?.use { cursor -> + val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE) + if (sizeIndex != -1 && cursor.moveToFirst() && !cursor.isNull(sizeIndex)) { + cursor.getLong(sizeIndex).takeIf { it >= 0L } ?: -1L + } else { + -1L } + } ?: -1L + val targetPath = "kanban-imports/$fileName" + val pathBody = targetPath.toRequestBody("text/plain".toMediaTypeOrNull()) + val overwriteBody = "true".toRequestBody("text/plain".toMediaTypeOrNull()) + val requestBody = + StreamingUriRequestBody( + contentResolver = context.contentResolver, + uri = uri, + contentType = "application/gzip".toMediaTypeOrNull(), + contentLength = contentLength, + ) + val filePart = MultipartBody.Part.createFormData("file", fileName, requestBody) + val uploadResult = + withContext(Dispatchers.IO) { + safeApiCall { + ApiClient.hermesApi.uploadManagedFileStream(pathBody, overwriteBody, filePart) } - when (uploadResult) { - is NetworkResult.Success -> { - val serverPath = - uploadResult.data.entry?.path ?: uploadResult.data.path ?: targetPath - val importResult = viewModel.importBoard(serverPath) - when (importResult) { - is NetworkResult.Success -> { - val res = importResult.data - var msg = "Board '${res.name}' imported" - if (res.renamed) msg += " (as ${res.board})" - viewModel.showToast(msg) - viewModel.loadBoards() - } + } + when (uploadResult) { + is NetworkResult.Success -> { + val serverPath = + uploadResult.data.entry?.path ?: uploadResult.data.path ?: targetPath + val importResult = viewModel.importBoard(serverPath) + when (importResult) { + is NetworkResult.Success -> { + val res = importResult.data + var msg = "Board '${res.name}' imported" + if (res.renamed) msg += " (as ${res.board})" + viewModel.showToast(msg) + viewModel.loadBoards() + } - is NetworkResult.Failure -> { - viewModel.showToast("Import failed: ${importResult.error.message}") - } - } - runCatching { - ApiClient.hermesApi.deleteManagedFile(ManagedFileDelete(serverPath)) + is NetworkResult.Failure -> { + viewModel.showToast("Import failed: ${importResult.error.message}") } } + runCatching { + ApiClient.hermesApi.deleteManagedFile(ManagedFileDelete(serverPath)) + } + } - is NetworkResult.Failure -> { - viewModel.showToast("Failed to upload archive: ${uploadResult.error.message}") - } + is NetworkResult.Failure -> { + viewModel.showToast("Failed to upload archive: ${uploadResult.error.message}") } } } catch (e: Exception) { @@ -270,14 +288,14 @@ fun KanbanScreen( IconButton(onClick = { showFilterSheet = true }) { Icon( imageVector = Icons.Default.FilterList, - contentDescription = "Filter", + contentDescription = stringResource(R.string.kanban_filter), ) } Box { IconButton(onClick = { showBoardMenu = true }) { Icon( imageVector = Icons.Default.MoreVert, - contentDescription = "Board options", + contentDescription = stringResource(R.string.kanban_board_options), ) } DropdownMenu( @@ -285,7 +303,7 @@ fun KanbanScreen( onDismissRequest = { showBoardMenu = false }, ) { DropdownMenuItem( - text = { Text("New Board") }, + text = { Text(stringResource(R.string.kanban_new_board)) }, onClick = { showBoardMenu = false showCreateBoardDialog = true @@ -439,7 +457,7 @@ fun KanbanScreen( IconButton(onClick = { showFilterSheet = true }) { Icon( imageVector = Icons.Default.FilterList, - contentDescription = "Filters", + contentDescription = stringResource(R.string.kanban_filters), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -695,6 +713,7 @@ fun KanbanScreen( items(group.tasks, key = { it.id }) { task -> KanbanTaskCard( task = task, + nowSeconds = nowSeconds, defaultAssignee = state.orchestration ?.resolvedDefaultAssignee, @@ -743,6 +762,7 @@ fun KanbanScreen( items(colTasks, key = { it.id }) { task -> KanbanTaskCard( task = task, + nowSeconds = nowSeconds, defaultAssignee = state.orchestration ?.resolvedDefaultAssignee, @@ -931,13 +951,19 @@ fun KanbanScreen( } }, ) { - Text(if (selectedTaskIds.size == filteredTasks.size) "Clear" else "All") + Text( + if (selectedTaskIds.size == filteredTasks.size) { + stringResource(R.string.kanban_clear_selection) + } else { + stringResource(R.string.kanban_select_all) + }, + ) } Button( onClick = { showBulkMoveDialog = true }, enabled = selectedTaskIds.isNotEmpty(), ) { - Text("Move") + Text(stringResource(R.string.kanban_move)) } Button( onClick = { showBulkAssignDialog = true }, @@ -965,7 +991,7 @@ fun KanbanScreen( }, enabled = selectedTaskIds.isNotEmpty(), ) { - Text("Archive") + Text(stringResource(R.string.kanban_action_archive)) } Button( onClick = { showBulkDeleteDialog = true }, @@ -983,7 +1009,10 @@ fun KanbanScreen( selectedTaskIds = emptySet() }, ) { - Icon(Icons.Default.Close, contentDescription = "Close multi-select") + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.kanban_close_multi_select), + ) } } } @@ -997,7 +1026,7 @@ fun KanbanScreen( } AlertDialog( onDismissRequest = { showBulkMoveDialog = false }, - title = { Text("Move ${selectedTaskIds.size} tasks") }, + title = { Text(stringResource(R.string.kanban_move_tasks, selectedTaskIds.size)) }, text = { Column { writableMoveColumns.forEach { col -> @@ -1097,13 +1126,13 @@ fun KanbanScreen( AlertDialog( onDismissRequest = { showCreateBoardDialog = false }, - title = { Text("Create New Board") }, + title = { Text(stringResource(R.string.kanban_create_new_board)) }, text = { Column { OutlinedTextField( value = boardSlug, onValueChange = { boardSlug = it }, - label = { Text("Slug (id)") }, + label = { Text(stringResource(R.string.kanban_slug)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, ) @@ -1111,7 +1140,7 @@ fun KanbanScreen( OutlinedTextField( value = boardName, onValueChange = { boardName = it }, - label = { Text("Display Name") }, + label = { Text(stringResource(R.string.kanban_display_name)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, ) @@ -1119,7 +1148,7 @@ fun KanbanScreen( OutlinedTextField( value = boardDesc, onValueChange = { boardDesc = it }, - label = { Text("Description") }, + label = { Text(stringResource(R.string.kanban_description)) }, modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.height(8.dp)) @@ -1184,7 +1213,7 @@ fun KanbanScreen( }, enabled = boardSlug.isNotBlank(), ) { - Text("Create") + Text(stringResource(R.string.action_add)) } }, dismissButton = { @@ -1333,16 +1362,16 @@ private fun ConfirmActionDialog( ) { AlertDialog( onDismissRequest = onDismiss, - title = { Text("Confirm action") }, + title = { Text(stringResource(R.string.kanban_confirm_action)) }, text = { Text(message) }, confirmButton = { Button(onClick = onConfirm) { - Text("Confirm") + Text(stringResource(R.string.action_confirm)) } }, dismissButton = { TextButton(onClick = onDismiss) { - Text("Cancel") + Text(stringResource(R.string.action_cancel)) } }, ) @@ -1376,7 +1405,7 @@ private fun CompleteTaskDialog( }, dismissButton = { TextButton(onClick = onDismiss) { - Text("Cancel") + Text(stringResource(R.string.action_cancel)) } }, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt index ef23a3eb..b972a14f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt @@ -345,7 +345,7 @@ private fun TaskOverviewTab( onClick = onReclaim, shape = RoundedCornerShape(6.dp), ) { - Text("Reclaim task") + Text(stringResource(R.string.kanban_reclaim_task)) } } } @@ -400,7 +400,7 @@ private fun TaskOverviewTab( onDismissRequest = { assigneeMenuExpanded = false }, ) { DropdownMenuItem( - text = { Text("Unassigned") }, + text = { Text(stringResource(R.string.kanban_assignee_unassigned)) }, onClick = { onReassign(null) assigneeMenuExpanded = false @@ -591,7 +591,7 @@ private fun TaskOverviewTab( IconButton(onClick = { isEditingDescription = true }) { Icon( Icons.Filled.Edit, - contentDescription = "Edit Description", + contentDescription = stringResource(R.string.kanban_edit_description), modifier = Modifier.size(16.dp), ) } @@ -608,14 +608,14 @@ private fun TaskOverviewTab( Spacer(modifier = Modifier.height(8.dp)) Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { OutlinedButton(onClick = { isEditingDescription = false }) { - Text("Cancel") + Text(stringResource(R.string.action_cancel)) } Spacer(modifier = Modifier.width(8.dp)) Button(onClick = { onSaveDescription(descriptionDraft) isEditingDescription = false }) { - Text("Save") + Text(stringResource(R.string.action_save)) } } } else { @@ -676,7 +676,7 @@ private fun TaskDiscussionTab( ) { if (comments.isEmpty()) { item { - Text("No comments yet.", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.kanban_no_comments), style = MaterialTheme.typography.bodySmall) } } else { items(comments, key = { it.id }) { comment -> @@ -712,7 +712,7 @@ private fun TaskDiscussionTab( OutlinedTextField( value = newCommentText, onValueChange = { newCommentText = it }, - placeholder = { Text("Write a comment...") }, + placeholder = { Text(stringResource(R.string.kanban_write_comment)) }, modifier = Modifier.fillMaxWidth(), maxLines = 4, enabled = !isSubmitting, @@ -733,7 +733,7 @@ private fun TaskDiscussionTab( ) { Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(4.dp)) - Text("Note & Requeue") + Text(stringResource(R.string.kanban_note_requeue)) } Spacer(modifier = Modifier.width(8.dp)) } @@ -754,12 +754,19 @@ private fun TaskDiscussionTab( Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(6.dp)) } - Text("Comment") + Text(stringResource(R.string.kanban_comment)) } } } } +private fun formatRunDuration(durationSeconds: Long): String = + when { + durationSeconds < 60L -> "${durationSeconds}s" + durationSeconds < 3600L -> "${durationSeconds / 60L}m" + else -> "${durationSeconds / 3600L}h" + } + @Composable private fun TaskRunsTab( runs: List, @@ -771,12 +778,16 @@ private fun TaskRunsTab( verticalArrangement = Arrangement.spacedBy(8.dp), ) { item { - Text("Execution Runs", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + Text( + stringResource(R.string.kanban_execution_runs), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) } if (runs.isEmpty()) { item { - Text("No run history available.", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.kanban_no_run_history), style = MaterialTheme.typography.bodySmall) } } else { items(runs, key = { it.id }) { run -> @@ -791,7 +802,7 @@ private fun TaskRunsTab( modifier = Modifier.fillMaxWidth(), ) { Text( - text = "Run #${run.id}", + text = stringResource(R.string.kanban_run_number, run.id), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, ) @@ -806,15 +817,40 @@ private fun TaskRunsTab( }, ) } - run.profile?.let { Text("Profile: $it", style = MaterialTheme.typography.bodySmall) } - run.outcome?.let { Text("Outcome: $it", style = MaterialTheme.typography.bodySmall) } + run.profile?.let { + Text( + stringResource(R.string.kanban_run_profile, it), + style = MaterialTheme.typography.bodySmall, + ) + } + run.outcome?.let { + Text( + stringResource(R.string.kanban_run_outcome, it), + style = MaterialTheme.typography.bodySmall, + ) + } + if (run.startedAt != null && run.endedAt != null && run.endedAt >= run.startedAt) { + Text( + stringResource( + R.string.kanban_run_duration, + formatRunDuration(run.endedAt - run.startedAt), + ), + style = MaterialTheme.typography.bodySmall, + ) + } run.error?.let { Text( - "Error: $it", + stringResource(R.string.kanban_run_error, it), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, ) } + run.summary?.takeIf { it.isNotBlank() }?.let { + Text( + stringResource(R.string.kanban_run_summary, it), + style = MaterialTheme.typography.bodySmall, + ) + } } } } @@ -823,7 +859,11 @@ private fun TaskRunsTab( if (workerLog != null && workerLog.exists && workerLog.content.isNotBlank()) { item { Spacer(modifier = Modifier.height(16.dp)) - Text("Worker Log Output", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + Text( + stringResource(R.string.kanban_worker_log), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) Spacer(modifier = Modifier.height(6.dp)) Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), @@ -852,12 +892,16 @@ private fun TaskActivityTab( verticalArrangement = Arrangement.spacedBy(8.dp), ) { item { - Text("Event Timeline", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + Text( + stringResource(R.string.kanban_event_timeline), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) } if (events.isEmpty()) { item { - Text("No activity events.", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.kanban_no_activity), style = MaterialTheme.typography.bodySmall) } } else { items(events, key = { it.id }) { event -> @@ -951,7 +995,11 @@ private fun TaskFilesTab( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - Text("Attachments", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + Text( + stringResource(R.string.kanban_attachments), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) Button( onClick = { launcher.launch("*/*") }, enabled = !isUploading, @@ -960,7 +1008,7 @@ private fun TaskFilesTab( CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) Spacer(modifier = Modifier.width(6.dp)) } - Text("Upload") + Text(stringResource(R.string.kanban_upload)) } } @@ -972,7 +1020,7 @@ private fun TaskFilesTab( ) { if (attachments.isEmpty()) { item { - Text("No attachments uploaded.", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.kanban_no_attachments), style = MaterialTheme.typography.bodySmall) } } else { items(attachments, key = { it.id }) { att -> @@ -993,7 +1041,7 @@ private fun TaskFilesTab( Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = att.filename?.ifBlank { "File #${att.id}" } ?: "File #${att.id}", + text = att.filename.ifBlank { "File #${att.id}" }, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt index b188628b..b9b6249e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt @@ -52,6 +52,7 @@ data class KanbanTaskUiState( class KanbanTaskViewModel( private val repository: KanbanRepository = KanbanRepositoryImpl(), private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() }, + private val enableFallbackPolling: Boolean = true, ) : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(KanbanTaskUiState()) @@ -63,17 +64,30 @@ class KanbanTaskViewModel( private var currentLoadGen: Int = 0 private var reloadJob: Job? = null private var logPollJob: Job? = null + private var detailPollJob: Job? = null fun loadTask( board: String, taskId: String, ) { val gen = ++currentLoadGen + reloadJob?.cancel() + stopLogPolling() + detailPollJob?.cancel() activeTaskId = taskId - _uiState.update { it.copy(isLoading = true, errorMessage = null) } + _uiState.update { + it.copy( + isLoading = true, + detail = null, + workerLog = null, + estimate = null, + errorMessage = null, + ) + } loadProfiles() loadModelOptions() connectEvents(board) + startDetailPolling(board, taskId) viewModelScope.launch { when (val result = repository.getTask(taskId = taskId, board = board)) { @@ -191,8 +205,23 @@ class KanbanTaskViewModel( logPollJob = null } + private fun startDetailPolling( + board: String, + taskId: String, + ) { + if (!enableFallbackPolling) return + detailPollJob = + viewModelScope.launch { + while (isActive) { + delay(DETAIL_POLL_INTERVAL_MS) + loadTaskSilently(board, taskId) + } + } + } + private fun connectEvents(board: String) { if (eventsBoard == board) return + eventsClient?.disconnect() eventsBoard = board val client = eventsClient ?: eventsClientProvider().also { eventsClient = it } client.connect( @@ -536,6 +565,8 @@ class KanbanTaskViewModel( public override fun onCleared() { stopLogPolling() + detailPollJob?.cancel() + reloadJob?.cancel() eventsClient?.disconnect() super.onCleared() } @@ -547,5 +578,6 @@ class KanbanTaskViewModel( private companion object { const val RELOAD_DEBOUNCE_MS = 250L const val POLL_INTERVAL_MS = 3000L + const val DETAIL_POLL_INTERVAL_MS = 30_000L } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt index 3f7e2157..5c7d38c9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt @@ -174,13 +174,14 @@ class KanbanViewModel( loadOrchestration() loadModelOptions() viewModelScope.launch { - when (val result = repository.getBoards(includeArchived = _uiState.value.includeArchived)) { + when (val result = repository.getBoards(includeArchived = false)) { is NetworkResult.Success -> { val boards = result.data.boards _uiState.update { it.copy(isLoading = false, boards = boards) } val targetBoard = boards.find { it.id == previouslySelectedId } ?: boards.find { it.id == result.data.current } + ?: boards.find { it.id.equals("default", ignoreCase = true) } ?: boards.firstOrNull() if (targetBoard != null) { selectBoard(targetBoard) @@ -531,7 +532,9 @@ class KanbanViewModel( _uiState.update { it.copy(isLoading = true) } when (val res = repository.deleteBoard(slug, delete = false)) { is NetworkResult.Success -> { - _uiState.update { it.copy(toastMessage = "Board archived") } + val endpoint = endpointProvider() + preferences.clearSelectedBoard(endpoint) + _uiState.update { it.copy(selectedBoard = null, toastMessage = "Board archived") } loadBoards() } @@ -652,13 +655,19 @@ class KanbanViewModel( val board = _uiState.value.selectedBoard ?: return viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } - when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, assignee = assignee))) { + val body = + BulkTasksBody( + ids = taskIds, + assignee = assignee ?: "", + reclaimFirst = true, + ) + when (val res = repository.bulkTasks(board.id, body)) { is NetworkResult.Success -> { val results = res.data.results val succeeded = results.filter { it.ok }.map { it.id }.toSet() val failed = results.filter { !it.ok }.map { it.id }.toSet() val firstError = results.firstOrNull { !it.ok }?.error - val actionName = if (assignee != null) "Assigned" else "Unassigned" + val actionName = if (!assignee.isNullOrBlank()) "Assigned" else "Unassigned" val msg = when { failed.isEmpty() -> "$actionName ${succeeded.size} tasks" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanBoardSettingsDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanBoardSettingsDialog.kt index 907b8f0f..b47fd02e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanBoardSettingsDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanBoardSettingsDialog.kt @@ -61,7 +61,7 @@ fun KanbanBoardSettingsDialog( OutlinedTextField( value = name, onValueChange = { name = it }, - label = { Text("Board Name") }, + label = { Text(stringResource(R.string.kanban_board_name)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, ) @@ -69,7 +69,7 @@ fun KanbanBoardSettingsDialog( OutlinedTextField( value = description, onValueChange = { description = it }, - label = { Text("Description") }, + label = { Text(stringResource(R.string.kanban_description)) }, modifier = Modifier.fillMaxWidth(), minLines = 2, ) @@ -121,8 +121,8 @@ fun KanbanBoardSettingsDialog( OutlinedTextField( value = defaultWorkdir, onValueChange = { defaultWorkdir = it }, - label = { Text("Default Workdir") }, - placeholder = { Text("e.g. /home/user/project") }, + label = { Text(stringResource(R.string.kanban_default_workdir)) }, + placeholder = { Text(stringResource(R.string.kanban_workdir_example)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt index d8586762..dbbc28e2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt @@ -43,6 +43,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R @@ -83,7 +84,7 @@ fun KanbanCreateTaskDialog( val descTextFieldState = rememberSyncedTextFieldState(desc) { desc = it } var selectedColumn by remember(defaultColumn) { mutableStateOf(defaultColumn) } var selectedAssignee by remember(defaultAssignee) { - mutableStateOf(defaultAssignee ?: profiles.firstOrNull()?.name ?: "default") + mutableStateOf(defaultAssignee.orEmpty()) } var priority by remember { mutableIntStateOf(0) } var goalMode by remember { mutableStateOf(false) } @@ -222,7 +223,13 @@ fun KanbanCreateTaskDialog( DropdownMenuItem( text = { Column { - Text(if (isDef) "${profile.name} (Default)" else profile.name) + Text( + if (isDef) { + stringResource(R.string.kanban_assignee_default_suffix, profile.name) + } else { + profile.name + }, + ) if (profile.model != null) { Text( profile.model, @@ -311,6 +318,7 @@ fun KanbanCreateTaskDialog( Modifier .fillMaxWidth() .clickable { showAdvanced = !showAdvanced } + .testTag("kanban_advanced_options") .padding(vertical = 8.dp), ) { Text( @@ -409,15 +417,16 @@ fun KanbanCreateTaskDialog( modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), ) { listOf( - "scratch" to "Scratch (Default)", - "worktree" to "Worktree", - "dir" to "Dir", + "scratch" to stringResource(R.string.kanban_workspace_scratch), + "worktree" to stringResource(R.string.kanban_workspace_worktree), + "dir" to stringResource(R.string.kanban_workspace_dir), ).forEach { (kind, label) -> FilterChip( selected = workspaceKind == kind, onClick = { workspaceKind = kind }, label = { Text(label) }, enabled = !isCreating, + modifier = Modifier.testTag("kanban_workspace_kind_$kind"), ) } } @@ -435,7 +444,7 @@ fun KanbanCreateTaskDialog( Text(stringResource(R.string.kanban_workspace_path_hint)) } }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().testTag("kanban_workspace_path"), singleLine = true, enabled = !isCreating, ) @@ -448,7 +457,7 @@ fun KanbanCreateTaskDialog( value = skillsText, onValueChange = { skillsText = it }, label = { Text(stringResource(R.string.kanban_skills)) }, - placeholder = { Text("e.g. git, coding, tests") }, + placeholder = { Text(stringResource(R.string.kanban_skills_placeholder)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, enabled = !isCreating, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt index 4dabbdfd..bf9a9cd9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt @@ -21,8 +21,11 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -60,7 +63,7 @@ fun KanbanFilterSheet( fontWeight = FontWeight.Bold, ) TextButton(onClick = onClearFilters) { - Text("Clear all") + Text(stringResource(R.string.kanban_clear_all)) } } @@ -75,10 +78,11 @@ fun KanbanFilterSheet( .fillMaxWidth() .padding(vertical = 4.dp), ) { - Text("Show archived tasks", style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.kanban_show_archived), style = MaterialTheme.typography.bodyMedium) Switch( checked = includeArchived, onCheckedChange = onToggleIncludeArchived, + modifier = Modifier.testTag("kanban_show_archived"), ) } @@ -91,10 +95,11 @@ fun KanbanFilterSheet( .fillMaxWidth() .padding(vertical = 4.dp), ) { - Text("Group running lane by profile", style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.kanban_group_running), style = MaterialTheme.typography.bodyMedium) Switch( checked = groupRunning, onCheckedChange = onToggleGroupRunning, + modifier = Modifier.testTag("kanban_group_running"), ) } @@ -102,7 +107,7 @@ fun KanbanFilterSheet( // Assignee Filter Text( - text = "Assignee", + text = stringResource(R.string.kanban_assignee), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(vertical = 4.dp), @@ -120,7 +125,7 @@ fun KanbanFilterSheet( selected = selectedAssignee == null, onClick = { onSelectAssignee(null) }, ) - Text("All assignees", style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.kanban_all_assignees), style = MaterialTheme.typography.bodyMedium) } assignees.forEach { assignee -> @@ -144,7 +149,7 @@ fun KanbanFilterSheet( HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) Text( - text = "Tenant", + text = stringResource(R.string.kanban_tenant), style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary, modifier = Modifier.padding(vertical = 4.dp), @@ -162,7 +167,7 @@ fun KanbanFilterSheet( selected = selectedTenant == null, onClick = { onSelectTenant(null) }, ) - Text("All tenants", style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.kanban_all_tenants), style = MaterialTheme.typography.bodyMedium) } tenants.forEach { tenant -> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanModelOverrideEditor.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanModelOverrideEditor.kt index c04d3f7e..a96aff66 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanModelOverrideEditor.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanModelOverrideEditor.kt @@ -33,6 +33,7 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.ui.kanban.KanbanModelOverride +import com.m57.hermescontrol.ui.kanban.supportedKanbanReasoningEfforts import com.m57.hermescontrol.ui.model.components.ModelPickerDialog @Composable @@ -123,6 +124,14 @@ fun KanbanModelOverrideEditor( color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(modifier = Modifier.height(4.dp)) + val selectedCapabilities = + modelProviders + .firstOrNull { it.slug == override.provider } + ?.capabilities + ?.get(override.model) + val reasoningSupported = selectedCapabilities?.reasoning != false + val canDisableReasoning = selectedCapabilities?.can_disable_reasoning != false + val supportedEfforts = supportedKanbanReasoningEfforts(reasoningSupported, canDisableReasoning) Row( horizontalArrangement = Arrangement.spacedBy(6.dp), modifier = @@ -130,24 +139,33 @@ fun KanbanModelOverrideEditor( .fillMaxWidth() .horizontalScroll(rememberScrollState()), ) { - listOf( - "" to stringResource(R.string.kanban_effort_inherit), - "none" to stringResource(R.string.kanban_effort_none), - "low" to stringResource(R.string.kanban_effort_low), - "medium" to stringResource(R.string.kanban_effort_medium), - "high" to stringResource(R.string.kanban_effort_high), - ).forEach { (effortVal, label) -> - FilterChip( - selected = override.effort == effortVal, - onClick = { - if (enabled) { - onOverrideChange(override.copy(effort = effortVal)) + supportedEfforts + .map { effortVal -> + effortVal to + when (effortVal) { + "" -> stringResource(R.string.kanban_effort_inherit) + "none" -> stringResource(R.string.kanban_effort_none) + "minimal" -> stringResource(R.string.kanban_effort_minimal) + "low" -> stringResource(R.string.kanban_effort_low) + "medium" -> stringResource(R.string.kanban_effort_medium) + "high" -> stringResource(R.string.kanban_effort_high) + "xhigh" -> stringResource(R.string.kanban_effort_xhigh) + "max" -> stringResource(R.string.kanban_effort_max) + "ultra" -> stringResource(R.string.kanban_effort_ultra) + else -> effortVal } - }, - label = { Text(label) }, - enabled = enabled, - ) - } + }.forEach { (effortVal, label) -> + FilterChip( + selected = override.effort == effortVal, + onClick = { + if (enabled) { + onOverrideChange(override.copy(effort = effortVal)) + } + }, + label = { Text(label) }, + enabled = enabled, + ) + } } } @@ -157,7 +175,14 @@ fun KanbanModelOverrideEditor( title = stringResource(R.string.kanban_select_model), pinnedModels = pinnedModels, onSelect = { provider, model -> - onOverrideChange(override.copy(provider = provider, model = model)) + val capabilities = modelProviders.firstOrNull { it.slug == provider }?.capabilities?.get(model) + onOverrideChange( + override.copy( + provider = provider, + model = model, + effort = if (capabilities?.reasoning == false) "" else override.effort, + ), + ) showPickerDialog = false }, onDismiss = { showPickerDialog = false }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanOrchestrationDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanOrchestrationDialog.kt index 29dc7929..966aabb2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanOrchestrationDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanOrchestrationDialog.kt @@ -317,7 +317,7 @@ private fun ProfileDescriptionEditor( modifier = Modifier.fillMaxWidth(), minLines = 2, maxLines = 4, - placeholder = { Text("Profile description for routing") }, + placeholder = { Text(stringResource(R.string.kanban_profile_description_hint)) }, ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt index 0f725509..e35a855d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt @@ -31,10 +31,12 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.KanbanTask import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.kanban.KanbanArcState @@ -47,6 +49,7 @@ fun KanbanTaskCard( modifier: Modifier = Modifier, isSelected: Boolean = false, defaultAssignee: String? = null, + nowSeconds: Long = System.currentTimeMillis() / 1000L, onActionClick: ((KanbanTask) -> Unit)? = null, ) { val statusColors = LocalHermesStatusColors.current @@ -60,9 +63,9 @@ fun KanbanTaskCard( else -> MaterialTheme.colorScheme.outline } - val arcState = KanbanRuntimeHelper.arcState(task, defaultAssignee) + val arcState = KanbanRuntimeHelper.arcState(task, defaultAssignee, nowSeconds) val isWontRun = KanbanRuntimeHelper.isWontRun(task, defaultAssignee) - val elapsed = KanbanRuntimeHelper.formatElapsed(task.startedAt) + val elapsed = KanbanRuntimeHelper.formatElapsed(task.startedAt, nowSeconds) val cardColors = if (isSelected) { @@ -103,7 +106,11 @@ fun KanbanTaskCard( arcState == KanbanArcState.RUNNING -> { Spacer(modifier = Modifier.width(4.dp)) Text( - text = "· ${elapsed ?: "working"}", + text = + stringResource( + R.string.kanban_runtime_elapsed, + elapsed ?: stringResource(R.string.kanban_runtime_working), + ), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Medium, color = statusColors.success, @@ -113,7 +120,7 @@ fun KanbanTaskCard( arcState == KanbanArcState.STALE -> { Spacer(modifier = Modifier.width(4.dp)) Text( - text = "· STALE", + text = stringResource(R.string.kanban_runtime_stale_badge), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, color = statusColors.warning, @@ -123,7 +130,7 @@ fun KanbanTaskCard( isWontRun -> { Spacer(modifier = Modifier.width(4.dp)) Text( - text = "· WON'T RUN", + text = stringResource(R.string.kanban_runtime_wont_run_badge), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, color = statusColors.error, @@ -146,7 +153,7 @@ fun KanbanTaskCard( ) { Icon( imageVector = Icons.Default.MoreVert, - contentDescription = "Task actions", + contentDescription = stringResource(R.string.kanban_task_actions), modifier = Modifier.size(16.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/java/com/m57/hermescontrol/util/StreamingUriRequestBody.kt b/app/src/main/java/com/m57/hermescontrol/util/StreamingUriRequestBody.kt new file mode 100644 index 00000000..e8db6b18 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/util/StreamingUriRequestBody.kt @@ -0,0 +1,37 @@ +package com.m57.hermescontrol.util + +import android.content.ContentResolver +import android.net.Uri +import okhttp3.MediaType +import okhttp3.RequestBody +import okio.BufferedSink +import okio.source +import java.io.IOException + +/** + * A streaming OkHttp [RequestBody] backed by an Android SAF [Uri] and [ContentResolver]. + * + * Streams bytes directly from the open stream into OkHttp's [BufferedSink] without + * buffering the entire file into heap memory (avoiding OutOfMemoryError on large archives). + */ +class StreamingUriRequestBody( + private val contentResolver: ContentResolver, + private val uri: Uri, + private val contentType: MediaType?, + private val contentLength: Long = -1L, +) : RequestBody() { + override fun contentType(): MediaType? = contentType + + override fun contentLength(): Long = contentLength + + override fun writeTo(sink: BufferedSink) { + val inputStream = + contentResolver.openInputStream(uri) + ?: throw IOException("Unable to open input stream for URI: $uri") + inputStream.use { stream -> + stream.source().use { source -> + sink.writeAll(source) + } + } + } +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index aa5a2bc9..64f7abeb 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -324,6 +324,7 @@ 2 (مرتفع) 3 (عاجل) المسؤول + %1$s (الافتراضي) افتراضي (غير معيَّن) موقوف (بدون عامل) وضع الهدف الذاتي @@ -347,11 +348,32 @@ منخفض متوسط مرتفع + حد أدنى + مرتفع جدًا + أقصى + فائق + إظهار المهام المؤرشفة + تجميع المهام قيد التشغيل حسب الملف الشخصي + جميع المسؤولين + المستأجر + جميع المستأجرين + عمليات التنفيذ + لا يتوفر سجل للتنفيذ. + التنفيذ #%1$d + الملف الشخصي: %1$s + النتيجة: %1$s + المدة: %1$s + الخطأ: %1$s + الملخص: %1$s نطاق المشروع بدون مشروع أرشفة اللوحة هل أنت متأكد من أرشفة اللوحة \'%1$s\'؟ يمكنك استعادتها لاحقًا من اللوحات المؤرشفة. إعدادات اللوحة + اسم اللوحة + الوصف + مجلد العمل الافتراضي + مثال: /home/user/project إعدادات التنسيق الملف المنسق المسؤول الافتراضي @@ -378,6 +400,41 @@ قيد التشغيل متوقف (لا توجد إشارة) لن تعمل: غير معيّنة + · %1$s + · متوقف + · لن تعمل + إجراءات المهمة + استعادة المهمة + لا توجد تعليقات بعد. + اكتب تعليقًا... + ملاحظة وإعادة إدراج + تعليق + مخرجات سجل العامل + الخط الزمني للأحداث + لا توجد أحداث نشاط. + المرفقات + رفع + لا توجد مرفقات مرفوعة. + مسح الكل + مثال: git، coding، tests + وصف الملف الشخصي للتوجيه + تصفية + خيارات اللوحة + لوحة جديدة + مسح + الكل + نقل + إغلاق التحديد المتعدد + نقل %1$d من المهام + إنشاء لوحة جديدة + المعرّف (Slug) + اسم العرض + عوامل التصفية + تأكيد الإجراء + تعديل الوصف + مؤقت (افتراضي) + شجرة عمل + مجلد غير معيّن توسيع %1$s طي %1$s diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 5ae97979..c0b78d96 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -322,6 +322,7 @@ 2 (높음) 3 (긴급) 담당자 + %1$s (기본값) 기본 (미지정) 보류 (워커 없음) 자율 목표 모드 @@ -345,11 +346,32 @@ 낮음 중간 높음 + 최소 + 매우 높음 + 최대 + 울트라 + 보관된 작업 표시 + 실행 중인 작업을 프로필별로 그룹화 + 모든 담당자 + 테넌트 + 모든 테넌트 + 실행 기록 + 실행 기록이 없습니다. + 실행 #%1$d + 프로필: %1$s + 결과: %1$s + 기간: %1$s + 오류: %1$s + 요약: %1$s 프로젝트 범위 프로젝트 없음 보드 보관 \'%1$s\' 보드를 보관하시겠습니까? 나중에 보관된 보드에서 복구할 수 있습니다. 보드 설정 + 보드 이름 + 설명 + 기본 작업 디렉터리 + 예: /home/user/project 오케스트레이션 설정 오케스트레이터 프로필 기본 담당자 @@ -376,6 +398,41 @@ 실행 중 응답 없음 (하트비트 지연) 실행 안 됨: 담당자 미지정 + · %1$s + · 응답 없음 + · 실행 안 됨 + 작업 작업 + 작업 회수 + 아직 댓글이 없습니다. + 댓글을 작성하세요... + 메모 및 다시 대기열에 추가 + 댓글 + 워커 로그 출력 + 이벤트 타임라인 + 활동 이벤트가 없습니다. + 첨부파일 + 업로드 + 업로드된 첨부파일이 없습니다. + 모두 지우기 + 예: git, coding, tests + 라우팅에 사용할 프로필 설명 + 필터 + 보드 옵션 + 새 보드 + 지우기 + 전체 + 이동 + 다중 선택 닫기 + 작업 %1$d개 이동 + 새 보드 만들기 + 슬러그 (id) + 표시 이름 + 필터 + 작업 확인 + 설명 편집 + 임시 (기본값) + 작업 트리 + 디렉터리 미지정 %1$s 펼치기 %1$s 접기 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 9dfb6330..116b4a8d 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -323,6 +323,7 @@ 2 (高) 3 (紧急) 负责人 + %1$s(默认) 默认 (未指定) 暂挂 (无 worker) 自主目标模式 @@ -346,11 +347,32 @@ 低 中 高 + 最小 + 超高 + 最大 + 极高 + 显示已归档任务 + 按配置文件分组运行中的任务 + 所有负责人 + 租户 + 所有租户 + 执行记录 + 暂无执行记录。 + 执行 #%1$d + 配置文件:%1$s + 结果:%1$s + 时长:%1$s + 错误:%1$s + 摘要:%1$s 项目范围 无项目 归档看板 确定要归档看板 \'%1$s\' 吗?稍后可从已归档看板中恢复。 看板设置 + 看板名称 + 描述 + 默认工作目录 + 例如:/home/user/project 编排设置 编排配置文件 默认负责人 @@ -377,6 +399,41 @@ 运行中 无响应 (心跳超时) 无法运行:未指定负责人 + · %1$s + · 无响应 + · 无法运行 + 任务操作 + 回收任务 + 暂无评论。 + 写下评论…… + 备注并重新排队 + 评论 + 工作器日志输出 + 事件时间线 + 暂无活动事件。 + 附件 + 上传 + 暂无已上传附件。 + 全部清除 + 例如:git、coding、tests + 用于路由的配置文件描述 + 筛选 + 看板选项 + 新建看板 + 清除 + 全部 + 移动 + 关闭多选 + 移动 %1$d 个任务 + 创建新看板 + Slug(ID) + 显示名称 + 筛选 + 确认操作 + 编辑描述 + 临时(默认) + 工作树 + 目录 未分配 展开 %1$s 折叠 %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 59775a15..47e97456 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -359,6 +359,7 @@ 2 (High) 3 (Urgent) Assignee + %1$s (Default) Default (Unassigned) Parked (No worker) Autonomous Goal Mode @@ -382,11 +383,32 @@ Low Medium High + Minimal + XHigh + Max + Ultra + Show archived tasks + Group running lane by profile + All assignees + Tenant + All tenants + Execution Runs + No run history available. + Run #%1$d + Profile: %1$s + Outcome: %1$s + Duration: %1$s + Error: %1$s + Summary: %1$s Project Scope No project Archive Board Are you sure you want to archive board \'%1$s\'? You can recover it later from archived boards. Board Settings + Board Name + Description + Default Workdir + e.g. /home/user/project Orchestration Settings Orchestrator Profile Default Assignee @@ -413,6 +435,41 @@ Working Stale (no heartbeat) Won\'t run: unassigned + · %1$s + · STALE + · WON\'T RUN + Task actions + Reclaim task + No comments yet. + Write a comment... + Note & Requeue + Comment + Worker Log Output + Event Timeline + No activity events. + Attachments + Upload + No attachments uploaded. + Clear all + e.g. git, coding, tests + Profile description for routing + Filter + Board options + New Board + Clear + All + Move + Close multi-select + Move %1$d tasks + Create New Board + Slug (id) + Display Name + Filters + Confirm action + Edit Description + Scratch (Default) + Worktree + Dir Unassigned Expand %1$s Collapse %1$s diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/KanbanSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/KanbanSerializationTest.kt index cefde4ed..876b69de 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/model/KanbanSerializationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/model/KanbanSerializationTest.kt @@ -289,4 +289,29 @@ class KanbanSerializationTest { assertEquals("M", estimate.complexity) assertEquals("Multi-file changes needed", estimate.rationale) } + + @Test + fun testBulkAssignmentUsesExplicitEmptyStringAndReclaim() { + val assignJson = + json.encodeToString( + BulkTasksBody( + ids = listOf("t_1"), + assignee = "researcher", + reclaimFirst = true, + ), + ) + val unassignJson = + json.encodeToString( + BulkTasksBody( + ids = listOf("t_1"), + assignee = "", + reclaimFirst = true, + ), + ) + + assertTrue(assignJson.contains("\"assignee\":\"researcher\"")) + assertTrue(assignJson.contains("\"reclaim_first\":true")) + assertTrue(unassignJson.contains("\"assignee\":\"\"")) + assertTrue(unassignJson.contains("\"reclaim_first\":true")) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanModelOverrideTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanModelOverrideTest.kt index 4d67d94a..c54458d3 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanModelOverrideTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanModelOverrideTest.kt @@ -63,4 +63,20 @@ class KanbanModelOverrideTest { assertEquals("low", patch.reasoningEffort) assertFalse(patch.clearReasoningEffort) } + + @Test + fun testFullReasoningScaleAndCapabilities() { + assertEquals( + listOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"), + supportedKanbanReasoningEfforts(supportsReasoning = true, canDisableReasoning = true), + ) + assertEquals( + listOf("minimal", "low", "medium", "high", "xhigh", "max", "ultra"), + supportedKanbanReasoningEfforts(supportsReasoning = true, canDisableReasoning = false), + ) + assertEquals( + listOf(""), + supportedKanbanReasoningEfforts(supportsReasoning = false, canDisableReasoning = false), + ) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt index 83a031e3..8e9b9709 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt @@ -374,6 +374,40 @@ class KanbanMutationTest { assertEquals("Board created: Sprint 2", vm.uiState.value.toastMessage) } + @Test + fun testBulkAssignUsesReclaimAndRetainsOnlyFailures() = + runTest(testDispatcher) { + val board = KanbanBoard(id = "dev", name = "Dev") + coEvery { mockRepository.getBoard("dev") } returns + NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList())))) + coEvery { mockRepository.bulkTasks("dev", any()) } returns + NetworkResult.Success( + BulkTasksResponse( + results = + listOf( + BulkTaskResult("t_1", ok = true), + BulkTaskResult("t_2", ok = false, error = "locked"), + ), + ), + ) + + val vm = createViewModel() + vm.selectBoard(board) + testDispatcher.scheduler.advanceUntilIdle() + + var failedIds = emptySet() + vm.bulkAssign(listOf("t_1", "t_2"), assignee = null) { failedIds = it } + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { + mockRepository.bulkTasks( + "dev", + match { it.ids == listOf("t_1", "t_2") && it.assignee == "" && it.reclaimFirst }, + ) + } + assertEquals(setOf("t_2"), failedIds) + } + @Test fun testRenameBoardSuccess() = runTest(testDispatcher) { @@ -408,11 +442,13 @@ class KanbanMutationTest { ) val vm = createViewModel() + preferences.setSelectedBoard("http://127.0.0.1:9119", "old-board") vm.deleteBoard("old-board") testDispatcher.scheduler.advanceUntilIdle() coVerify { mockRepository.deleteBoard("old-board", delete = false) } assertEquals("Board archived", vm.uiState.value.toastMessage) + assertEquals(null, preferences.getSelectedBoard("http://127.0.0.1:9119")) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt index 780ea755..e56d010e 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt @@ -15,6 +15,7 @@ import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.resetMain import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain @@ -31,10 +32,11 @@ class KanbanTaskViewModelTest { private val mockRepository = mockk(relaxed = true) private val mockEventsClient = mockk(relaxed = true) - private fun createViewModel(): KanbanTaskViewModel = + private fun createViewModel(enableFallbackPolling: Boolean = false): KanbanTaskViewModel = KanbanTaskViewModel( repository = mockRepository, eventsClientProvider = { mockEventsClient }, + enableFallbackPolling = enableFallbackPolling, ) @Before @@ -285,4 +287,38 @@ class KanbanTaskViewModelTest { assertTrue(deleted) assertEquals("Task deleted", vm.uiState.value.toastMessage) } + + @Test + fun testFallbackPollingReloadsTaskWithoutEvents() = + runTest(testDispatcher) { + val first = KanbanTaskFull(id = "t_1", title = "Before", status = "todo") + val second = KanbanTaskFull(id = "t_1", title = "After", status = "todo") + coEvery { mockRepository.getTask("t_1", "dev") } returnsMany + listOf( + NetworkResult.Success(KanbanTaskDetailResponse(task = first)), + NetworkResult.Success(KanbanTaskDetailResponse(task = second)), + ) + + val vm = createViewModel(enableFallbackPolling = true) + vm.loadTask("dev", "t_1") + testDispatcher.scheduler.runCurrent() + assertEquals( + "Before", + vm.uiState.value.detail + ?.task + ?.title, + ) + + testDispatcher.scheduler.advanceTimeBy(30_000L) + testDispatcher.scheduler.runCurrent() + + assertEquals( + "After", + vm.uiState.value.detail + ?.task + ?.title, + ) + coVerify(exactly = 2) { mockRepository.getTask("t_1", "dev") } + vm.onCleared() + } } diff --git a/app/src/test/java/com/m57/hermescontrol/util/StreamingUriRequestBodyTest.kt b/app/src/test/java/com/m57/hermescontrol/util/StreamingUriRequestBodyTest.kt new file mode 100644 index 00000000..5d33ecbd --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/util/StreamingUriRequestBodyTest.kt @@ -0,0 +1,68 @@ +package com.m57.hermescontrol.util + +import android.content.ContentResolver +import android.net.Uri +import io.mockk.every +import io.mockk.mockk +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okio.Buffer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.IOException + +class StreamingUriRequestBodyTest { + @Test + fun testStreamsDirectlyIntoSink() { + val testData = "test archive data streaming content".toByteArray(Charsets.UTF_8) + val mockResolver = mockk() + val mockUri = mockk() + + every { mockResolver.openInputStream(mockUri) } answers { + ByteArrayInputStream(testData) + } + + val mediaType = "application/gzip".toMediaTypeOrNull() + val requestBody = + StreamingUriRequestBody( + contentResolver = mockResolver, + uri = mockUri, + contentType = mediaType, + contentLength = testData.size.toLong(), + ) + + assertEquals(mediaType, requestBody.contentType()) + assertEquals(testData.size.toLong(), requestBody.contentLength()) + + val buffer = Buffer() + requestBody.writeTo(buffer) + + assertEquals("test archive data streaming content", buffer.readUtf8()) + } + + @Test + fun testThrowsWhenInputStreamCannotBeOpened() { + val mockResolver = mockk() + val mockUri = mockk() + + every { mockResolver.openInputStream(mockUri) } returns null + + val requestBody = + StreamingUriRequestBody( + contentResolver = mockResolver, + uri = mockUri, + contentType = null, + ) + + var thrown = false + try { + val buffer = Buffer() + requestBody.writeTo(buffer) + } catch (e: IOException) { + thrown = true + } + + assertTrue(thrown) + } +}