fix(kanban): address PR correctness and CI regressions (#1137)

This commit is contained in:
M57 committed 2026-09-16 21:49:35 +03:00
1 parent 190a93a4cf
commit 1735de7d9d
25 files changed
+809 -159

No files matched your search

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