From be9223eb78afe8588353fa618604705ce2603377 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:13:54 +0300 Subject: [PATCH 01/12] feat(kanban): add board transfer models, endpoints and auto-nudge dispatcher (#1137) --- .../data/model/KanbanBoardModels.kt | 14 ++++ .../data/model/KanbanTaskModels.kt | 4 +- .../data/remote/KanbanApiService.kt | 15 ++++ .../data/repository/KanbanDispatcherNudger.kt | 35 ++++++++ .../data/repository/KanbanRepository.kt | 83 +++++++++++++++---- 5 files changed, 134 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/repository/KanbanDispatcherNudger.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanBoardModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanBoardModels.kt index 22276588..a2b4a83e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanBoardModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanBoardModels.kt @@ -92,3 +92,17 @@ data class DeleteBoardResponse( val result: JsonElement? = null, val current: String? = null, ) + +@Serializable +data class ExportBoardBody( + val output: String = "", + val attachments: Boolean = true, + val logs: Boolean = false, +) + +@Serializable +data class ImportBoardBody( + val archive: String, + val slug: String? = null, + val switch: Boolean = false, +) 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 b93d3c27..194f10a7 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 @@ -252,4 +252,6 @@ data class TaskEstimate( val complexity: String? = null, val rationale: String? = null, val model: String? = null, -) +) { + val tokens: Int? get() = estTokens +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt index fc85ec16..bd4a7648 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt @@ -2,6 +2,8 @@ package com.m57.hermescontrol.data.remote import com.m57.hermescontrol.data.model.AttachmentUploadResponse import com.m57.hermescontrol.data.model.AutoDescribeResponse +import com.m57.hermescontrol.data.model.BoardExportResult +import com.m57.hermescontrol.data.model.BoardImportResult import com.m57.hermescontrol.data.model.BulkTasksBody import com.m57.hermescontrol.data.model.BulkTasksResponse import com.m57.hermescontrol.data.model.CreateBoardBody @@ -10,6 +12,8 @@ import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateTaskResponse import com.m57.hermescontrol.data.model.DeleteBoardResponse import com.m57.hermescontrol.data.model.DispatchResult +import com.m57.hermescontrol.data.model.ExportBoardBody +import com.m57.hermescontrol.data.model.ImportBoardBody import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse import com.m57.hermescontrol.data.model.KanbanProfilesResponse @@ -77,6 +81,17 @@ interface KanbanApiService { @Path("slug") slug: String, ): Response + @POST("api/plugins/kanban/boards/{slug}/export") + suspend fun exportBoard( + @Path("slug") slug: String, + @Body body: ExportBoardBody = ExportBoardBody(), + ): Response + + @POST("api/plugins/kanban/boards/import") + suspend fun importBoard( + @Body body: ImportBoardBody, + ): Response + @GET("api/plugins/kanban/tasks/{id}") suspend fun getTask( @Path("id") taskId: String, diff --git a/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanDispatcherNudger.kt b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanDispatcherNudger.kt new file mode 100644 index 00000000..2f18d5a8 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanDispatcherNudger.kt @@ -0,0 +1,35 @@ +package com.m57.hermescontrol.data.repository + +import com.m57.hermescontrol.data.remote.KanbanApiService +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +interface KanbanDispatcherNudger { + fun scheduleNudge(board: String?) +} + +class DefaultKanbanDispatcherNudger( + private val apiProvider: () -> KanbanApiService, + private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), + private val debounceMs: Long = 400L, +) : KanbanDispatcherNudger { + private val lock = Any() + private val boardJobs = mutableMapOf() + + override fun scheduleNudge(board: String?) { + synchronized(lock) { + boardJobs[board]?.cancel() + boardJobs[board] = + scope.launch { + delay(debounceMs) + runCatching { + apiProvider().nudgeDispatcher(board) + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt index efe3c419..422a7e27 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt @@ -2,6 +2,8 @@ package com.m57.hermescontrol.data.repository import com.m57.hermescontrol.data.model.AttachmentUploadResponse import com.m57.hermescontrol.data.model.AutoDescribeResponse +import com.m57.hermescontrol.data.model.BoardExportResult +import com.m57.hermescontrol.data.model.BoardImportResult import com.m57.hermescontrol.data.model.BulkTasksBody import com.m57.hermescontrol.data.model.BulkTasksResponse import com.m57.hermescontrol.data.model.CreateBoardBody @@ -10,6 +12,8 @@ import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.CreateTaskResponse import com.m57.hermescontrol.data.model.DeleteBoardResponse import com.m57.hermescontrol.data.model.DispatchResult +import com.m57.hermescontrol.data.model.ExportBoardBody +import com.m57.hermescontrol.data.model.ImportBoardBody import com.m57.hermescontrol.data.model.KanbanBoardResponse import com.m57.hermescontrol.data.model.KanbanBoardsResponse import com.m57.hermescontrol.data.model.KanbanProfilesResponse @@ -58,6 +62,13 @@ interface KanbanRepository { delete: Boolean = false, ): NetworkResult + suspend fun exportBoard( + slug: String, + body: ExportBoardBody = ExportBoardBody(), + ): NetworkResult + + suspend fun importBoard(body: ImportBoardBody): NetworkResult + suspend fun getTask( taskId: String, board: String?, @@ -151,6 +162,7 @@ interface KanbanRepository { class KanbanRepositoryImpl( private val apiProvider: () -> KanbanApiService = { ApiClient.kanbanApi }, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val nudger: KanbanDispatcherNudger = DefaultKanbanDispatcherNudger(apiProvider), ) : KanbanRepository { override suspend fun getBoards(includeArchived: Boolean): NetworkResult = withContext(ioDispatcher) { @@ -187,6 +199,19 @@ class KanbanRepositoryImpl( safeApiCall { apiProvider().deleteBoard(slug, delete) } } + override suspend fun exportBoard( + slug: String, + body: ExportBoardBody, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().exportBoard(slug, body) } + } + + override suspend fun importBoard(body: ImportBoardBody): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().importBoard(body) } + } + override suspend fun getTask( taskId: String, board: String?, @@ -200,7 +225,11 @@ class KanbanRepositoryImpl( body: CreateTaskBody, ): NetworkResult = withContext(ioDispatcher) { - safeApiCall { apiProvider().createTask(board = board, body = body) } + val result = safeApiCall { apiProvider().createTask(board = board, body = body) } + if (result is NetworkResult.Success) { + nudger.scheduleNudge(board) + } + result } override suspend fun updateTask( @@ -209,7 +238,11 @@ class KanbanRepositoryImpl( body: UpdateTaskBody, ): NetworkResult = withContext(ioDispatcher) { - safeApiCall { apiProvider().updateTask(taskId = taskId, board = board, body = body) } + val result = safeApiCall { apiProvider().updateTask(taskId = taskId, board = board, body = body) } + if (result is NetworkResult.Success) { + nudger.scheduleNudge(board) + } + result } override suspend fun deleteTask( @@ -217,7 +250,11 @@ class KanbanRepositoryImpl( board: String?, ): NetworkResult = withContext(ioDispatcher) { - safeApiCall { apiProvider().deleteTask(taskId = taskId, board = board) } + val result = safeApiCall { apiProvider().deleteTask(taskId = taskId, board = board) } + if (result is NetworkResult.Success) { + nudger.scheduleNudge(board) + } + result } override suspend fun bulkTasks( @@ -225,7 +262,11 @@ class KanbanRepositoryImpl( body: BulkTasksBody, ): NetworkResult = withContext(ioDispatcher) { - safeApiCall { apiProvider().bulkTasks(board = board, body = body) } + val result = safeApiCall { apiProvider().bulkTasks(board = board, body = body) } + if (result is NetworkResult.Success) { + nudger.scheduleNudge(board) + } + result } override suspend fun addComment( @@ -250,13 +291,18 @@ class KanbanRepositoryImpl( reclaimFirst: Boolean, ): NetworkResult = withContext(ioDispatcher) { - safeApiCall { - apiProvider().reassignTask( - taskId = taskId, - board = board, - body = ReassignTaskBody(profile = profile, reclaimFirst = reclaimFirst), - ) + val result = + safeApiCall { + apiProvider().reassignTask( + taskId = taskId, + board = board, + body = ReassignTaskBody(profile = profile, reclaimFirst = reclaimFirst), + ) + } + if (result is NetworkResult.Success) { + nudger.scheduleNudge(board) } + result } override suspend fun reclaimTask( @@ -265,13 +311,18 @@ class KanbanRepositoryImpl( reason: String?, ): NetworkResult = withContext(ioDispatcher) { - safeApiCall { - apiProvider().reclaimTask( - taskId = taskId, - board = board, - body = ReclaimTaskBody(reason = reason), - ) + val result = + safeApiCall { + apiProvider().reclaimTask( + taskId = taskId, + board = board, + body = ReclaimTaskBody(reason = reason), + ) + } + if (result is NetworkResult.Success) { + nudger.scheduleNudge(board) } + result } override suspend fun getTaskLog( From dd11f19a1c8bffb009fbf90b118204e277c6ccb2 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:14:28 +0300 Subject: [PATCH 02/12] feat(kanban): add runtime state, event formatter and grouping helpers (#1137) --- .../ui/kanban/KanbanColumnCollapseHelper.kt | 14 ++ .../ui/kanban/KanbanEventFormatter.kt | 168 ++++++++++++++++++ .../ui/kanban/KanbanLaneGrouping.kt | 26 +++ .../ui/kanban/KanbanModelOverride.kt | 79 ++++++++ .../ui/kanban/KanbanRuntimeState.kt | 50 ++++++ .../ui/kanban/KanbanStatusConstraints.kt | 15 ++ 6 files changed, 352 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanColumnCollapseHelper.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanEventFormatter.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanLaneGrouping.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanModelOverride.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanRuntimeState.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanStatusConstraints.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanColumnCollapseHelper.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanColumnCollapseHelper.kt new file mode 100644 index 00000000..d81af538 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanColumnCollapseHelper.kt @@ -0,0 +1,14 @@ +package com.m57.hermescontrol.ui.kanban + +object KanbanColumnCollapseHelper { + fun isColumnCollapsed( + columnName: String, + columnTaskCount: Int, + boardHasWork: Boolean, + overrides: Map, + ): Boolean { + overrides[columnName]?.let { return it } + if (!boardHasWork) return false + return columnTaskCount == 0 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanEventFormatter.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanEventFormatter.kt new file mode 100644 index 00000000..6aaf5164 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanEventFormatter.kt @@ -0,0 +1,168 @@ +package com.m57.hermescontrol.ui.kanban + +import com.m57.hermescontrol.data.model.KanbanDetailEvent +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +data class FormattedKanbanEvent( + val label: String, + val detail: String? = null, +) + +object KanbanEventFormatter { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + fun format(event: KanbanDetailEvent): FormattedKanbanEvent { + val payloadMap = extractPayloadMap(event.payload) + + fun str(key: String): String? { + val element = payloadMap[key] ?: return null + return when { + element is JsonPrimitive && element.isString -> element.content.takeIf { it.isNotBlank() } + element is JsonPrimitive -> element.content.takeIf { it.isNotBlank() } + else -> null + } + } + + fun col(key: String): String? { + val raw = str(key) ?: return null + return when (raw.lowercase()) { + "triage" -> "Triage" + "todo" -> "Todo" + "scheduled" -> "Scheduled" + "ready" -> "Ready" + "running" -> "Running" + "blocked" -> "Blocked" + "review" -> "Review" + "done" -> "Done" + "archived" -> "Archived" + else -> raw.replaceFirstChar { it.uppercase() } + } + } + + return when (event.kind.lowercase()) { + "created" -> { + val status = col("status") ?: "Todo" + val assignee = str("assignee") + val label = if (assignee != null) "Created in $status by $assignee" else "Created in $status" + FormattedKanbanEvent(label = label) + } + + "status" -> { + val targetStatus = col("status") ?: "?" + val reason = str("reason") + val parent = str("parent") + val detail = + when (reason) { + "parent_reopened" -> if (parent != null) "Parent $parent reopened" else "Parent reopened" + else -> reason + } + FormattedKanbanEvent( + label = "Moved to $targetStatus", + detail = detail, + ) + } + + "assigned" -> { + val assignee = str("assignee") + FormattedKanbanEvent( + label = if (assignee != null) "Assigned to $assignee" else "Unassigned", + ) + } + + "commented" -> { + val author = str("author") ?: "someone" + FormattedKanbanEvent(label = "Comment by $author") + } + + "claimed" -> { + val source = str("source_status") + FormattedKanbanEvent( + label = if (source == "review") "Claimed for review" else "Claimed by worker", + ) + } + + "spawned" -> { + val pid = str("pid") ?: payloadMap["pid"]?.toString() + FormattedKanbanEvent( + label = "Worker started", + detail = if (pid != null && pid != "null") "PID $pid" else null, + ) + } + + "completed" -> { + FormattedKanbanEvent(label = "Completed") + } + + "blocked" -> { + val reason = str("reason") + FormattedKanbanEvent(label = "Blocked", detail = reason) + } + + "unblocked" -> { + val status = col("status") + val label = if (status != null) "Unblocked ($status)" else "Unblocked" + FormattedKanbanEvent(label = label) + } + + "reclaimed" -> { + val reason = str("reason") + FormattedKanbanEvent(label = "Reclaimed", detail = reason) + } + + "specified" -> { + FormattedKanbanEvent(label = "Specified") + } + + "promoted" -> { + FormattedKanbanEvent(label = "Promoted to ready") + } + + "scheduled" -> { + val reason = str("reason") + FormattedKanbanEvent(label = "Scheduled", detail = reason) + } + + "archived" -> { + FormattedKanbanEvent(label = "Archived") + } + + "reprioritized" -> { + val priority = str("priority") ?: payloadMap["priority"]?.toString() ?: "?" + FormattedKanbanEvent(label = "Priority set to $priority") + } + + else -> { + val label = event.kind.replace('_', ' ').replaceFirstChar { it.uppercase() } + val detail = + payloadMap.entries + .filter { (_, v) -> v is JsonPrimitive } + .joinToString(" ") { (k, v) -> "$k=${(v as JsonPrimitive).content}" } + .ifBlank { null } + FormattedKanbanEvent(label = label, detail = detail) + } + } + } + + private fun extractPayloadMap(element: JsonElement?): Map { + if (element == null) return emptyMap() + if (element is JsonObject) return element + if (element is JsonPrimitive && element.isString) { + val content = element.content.trim() + if (content.startsWith("{") && content.endsWith("}")) { + runCatching { + val parsed = json.parseToJsonElement(content) + if (parsed is JsonObject) return parsed + } + } + return if (content.isNotEmpty()) mapOf("raw" to element) else emptyMap() + } + return emptyMap() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanLaneGrouping.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanLaneGrouping.kt new file mode 100644 index 00000000..be18abfc --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanLaneGrouping.kt @@ -0,0 +1,26 @@ +package com.m57.hermescontrol.ui.kanban + +import com.m57.hermescontrol.data.model.KanbanTask + +data class KanbanRunningGroup( + val assignee: String?, + val tasks: List, +) + +object KanbanLaneGrouping { + fun groupRunningTasks(tasks: List): List { + val grouped = tasks.groupBy { it.assignee?.takeIf { a -> a.isNotBlank() } } + val namedGroups = + grouped + .filterKeys { it != null } + .entries + .sortedBy { it.key!!.lowercase() } + .map { KanbanRunningGroup(assignee = it.key, tasks = it.value) } + val unassignedTasks = grouped[null] + return if (!unassignedTasks.isNullOrEmpty()) { + namedGroups + KanbanRunningGroup(assignee = null, tasks = unassignedTasks) + } else { + namedGroups + } + } +} 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 new file mode 100644 index 00000000..326d23a6 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanModelOverride.kt @@ -0,0 +1,79 @@ +package com.m57.hermescontrol.ui.kanban + +import com.m57.hermescontrol.data.model.UpdateTaskBody + +/** + * Task model override matching desktop's TaskModelOverride contract: + * - model: model name or ID + * - provider: provider name or ID + * - effort: "" (inherit), "none" (explicitly disabled), or reasoning effort string ("low", "medium", "high") + */ +data class KanbanModelOverride( + val model: String = "", + val provider: String = "", + val effort: String = "", +) { + val isInherited: Boolean + get() = model.isBlank() && provider.isBlank() && effort.isBlank() + + fun displayLabel(inheritLabel: String = "Inherit profile"): String { + if (isInherited) return inheritLabel + val trimmedModel = model.trim() + val trimmedProvider = provider.trim() + val trimmedEffort = effort.trim() + + val base = + when { + trimmedModel.isNotEmpty() && trimmedProvider.isNotEmpty() -> "$trimmedProvider: $trimmedModel" + trimmedModel.isNotEmpty() -> trimmedModel + else -> inheritLabel + } + + val effortPart = + when (trimmedEffort.lowercase()) { + "none" -> "None" + "low" -> "Low" + "medium" -> "Medium" + "high" -> "High" + "" -> "" + else -> trimmedEffort.replaceFirstChar { it.uppercase() } + } + + return if (effortPart.isNotEmpty() && base != inheritLabel) { + "$base · $effortPart" + } else if (effortPart.isNotEmpty()) { + "Effort: $effortPart" + } else { + base + } + } + + fun toUpdateTaskBody(): UpdateTaskBody { + val trimmedModel = model.trim() + val trimmedProvider = provider.trim() + val trimmedEffort = effort.trim() + + return UpdateTaskBody( + modelOverride = trimmedModel.ifEmpty { null }, + providerOverride = if (trimmedModel.isNotEmpty() && trimmedProvider.isNotEmpty()) trimmedProvider else null, + clearModelOverride = trimmedModel.isEmpty(), + reasoningEffort = trimmedEffort.ifEmpty { null }, + clearReasoningEffort = trimmedEffort.isEmpty(), + ) + } + + companion object { + val EMPTY = KanbanModelOverride() + + fun fromTask( + model: String?, + provider: String?, + effort: String?, + ): KanbanModelOverride = + KanbanModelOverride( + model = model?.trim().orEmpty(), + provider = provider?.trim().orEmpty(), + effort = effort?.trim().orEmpty(), + ) + } +} 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 new file mode 100644 index 00000000..50b6ba27 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanRuntimeState.kt @@ -0,0 +1,50 @@ +package com.m57.hermescontrol.ui.kanban + +import com.m57.hermescontrol.data.model.KanbanTask + +enum class KanbanArcState { + QUEUED, + RUNNING, + STALE, +} + +object KanbanRuntimeHelper { + fun arcState( + task: KanbanTask, + fallbackAssignee: String? = null, + nowSeconds: Long = System.currentTimeMillis() / 1000L, + ): KanbanArcState? { + val normalized = task.status.lowercase() + if (normalized == "running") { + val isStale = task.lastHeartbeatAt?.let { (nowSeconds - it) > 120L } ?: false + return if (isStale) KanbanArcState.STALE else KanbanArcState.RUNNING + } + val isQueued = + normalized == "triage" || + normalized == "review" || + (normalized == "ready" && (!task.assignee.isNullOrBlank() || !fallbackAssignee.isNullOrBlank())) + return if (isQueued) KanbanArcState.QUEUED else null + } + + fun isWontRun( + task: KanbanTask, + fallbackAssignee: String? = null, + ): Boolean = + task.status.equals("ready", ignoreCase = true) && + task.assignee.isNullOrBlank() && + fallbackAssignee.isNullOrBlank() + + fun formatElapsed( + startSeconds: Long?, + nowSeconds: Long = System.currentTimeMillis() / 1000L, + ): String? { + if (startSeconds == null || startSeconds <= 0L || nowSeconds < startSeconds) return null + val diff = nowSeconds - startSeconds + return when { + diff < 60L -> "${diff}s" + diff < 3600L -> "${diff / 60L}m" + diff < 86400L -> "${diff / 3600L}h" + else -> "${diff / 86400L}d" + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanStatusConstraints.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanStatusConstraints.kt new file mode 100644 index 00000000..018f6aea --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanStatusConstraints.kt @@ -0,0 +1,15 @@ +package com.m57.hermescontrol.ui.kanban + +/** + * Shared constraints for Kanban target columns. + * Mirrors desktop's LOCKED_COLUMNS: system-owned columns that users can drag/move + * cards OUT of, but never manually target for moves or creation. + */ +object KanbanStatusConstraints { + val LOCKED_COLUMNS = setOf("running", "review", "scheduled") + + fun isLockedTarget(status: String): Boolean = status.lowercase() in LOCKED_COLUMNS + + fun isUserWritableTarget(status: String): Boolean = + !isLockedTarget(status) && !status.equals("archived", ignoreCase = true) +} From c97d7127fba01134b24fc6f94be5046de3702e4b Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:14:59 +0300 Subject: [PATCH 03/12] feat(kanban): implement board parity, orchestration and task editing (#1137) --- .../hermescontrol/ui/kanban/KanbanScreen.kt | 956 ++++++++++++++---- .../ui/kanban/KanbanTaskScreen.kt | 620 ++++++++---- .../ui/kanban/KanbanTaskViewModel.kt | 316 +++++- .../ui/kanban/KanbanViewModel.kt | 351 ++++++- .../components/KanbanBoardSettingsDialog.kt | 155 +++ .../components/KanbanCreateTaskDialog.kt | 285 +++--- .../components/KanbanModelOverrideEditor.kt | 166 +++ .../components/KanbanOrchestrationDialog.kt | 324 ++++++ .../ui/kanban/components/KanbanTaskCard.kt | 47 +- 9 files changed, 2670 insertions(+), 550 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanBoardSettingsDialog.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanModelOverrideEditor.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanOrchestrationDialog.kt 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 34a09bef..9001d6fc 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 @@ -1,12 +1,18 @@ package com.m57.hermescontrol.ui.kanban +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -19,6 +25,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.FilterList @@ -33,6 +41,9 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -46,12 +57,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +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.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -59,15 +71,31 @@ import com.m57.hermescontrol.KanbanTaskDetailKey import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.KanbanTask +import com.m57.hermescontrol.data.model.ManagedFileDelete +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.GatewayFileClient +import com.m57.hermescontrol.data.remote.GatewayFileResult +import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.data.remote.safeApiCall +import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.ToastEffect +import com.m57.hermescontrol.ui.kanban.components.KanbanBoardSettingsDialog 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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File private const val DEFAULT_COLUMN = "todo" @@ -79,12 +107,12 @@ fun KanbanScreen( viewModel: KanbanViewModel = viewModel { KanbanViewModel() }, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() var query by remember { mutableStateOf("") } var selectedAssignee by remember { mutableStateOf(null) } var selectedTenant by remember { mutableStateOf(null) } - var includeArchived by remember { mutableStateOf(false) } - var groupRunning by remember { mutableStateOf(false) } var showFilterSheet by remember { mutableStateOf(false) } val filteredTasks = @@ -109,6 +137,7 @@ fun KanbanScreen( filteredTasks.groupBy { it.status.lowercase() } } var showAddTaskDialog by remember { mutableStateOf(false) } + var createTaskDefaultColumn by remember { mutableStateOf(DEFAULT_COLUMN) } var taskForActions by remember { mutableStateOf(null) } var confirmTarget by remember { mutableStateOf?>(null) } var summaryTarget by remember { mutableStateOf?>(null) } @@ -117,12 +146,116 @@ fun KanbanScreen( var isMultiSelectMode by remember { mutableStateOf(false) } var selectedTaskIds by remember { mutableStateOf(setOf()) } var showBulkMoveDialog by remember { mutableStateOf(false) } + var showBulkAssignDialog by remember { mutableStateOf(false) } + var showBulkDeleteDialog by remember { mutableStateOf(false) } // Board management dialogs var showBoardMenu by remember { mutableStateOf(false) } var showCreateBoardDialog by remember { mutableStateOf(false) } - var showRenameBoardDialog by remember { mutableStateOf(false) } + var showBoardSettingsDialog by remember { mutableStateOf(false) } var showDeleteBoardDialog by remember { mutableStateOf(false) } + var showOrchestrationDialog by remember { mutableStateOf(false) } + + // Board Transfer (Export / Import) + var pendingExportPath by remember { mutableStateOf(null) } + + val exportLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/gzip"), + ) { uri -> + if (uri != null && pendingExportPath != null) { + coroutineScope.launch { + try { + val cachedFile = File(pendingExportPath!!) + if (cachedFile.exists()) { + context.contentResolver.openOutputStream(uri)?.use { output -> + cachedFile.inputStream().use { input -> + input.copyTo(output) + } + } + viewModel.showToast("Board exported successfully") + } + } catch (e: Exception) { + viewModel.showToast("Failed to save export: ${e.message}") + } finally { + pendingExportPath = null + } + } + } + } + + val importLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri: Uri? -> + if (uri != null) { + coroutineScope.launch { + try { + var fileName = "board.tar.gz" + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex != -1 && cursor.moveToFirst()) { + fileName = cursor.getString(nameIndex) ?: "board.tar.gz" + } + } + + 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) + } + } + 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("Failed to upload archive: ${uploadResult.error.message}") + } + } + } + } catch (e: Exception) { + viewModel.showToast("Import failed: ${e.message}") + } + } + } + } + + LaunchedEffect(state.tasks) { + val existingIds = state.tasks.map { it.id }.toSet() + selectedTaskIds = selectedTaskIds.intersect(existingIds) + } LaunchedEffect(Unit) { viewModel.loadBoards() @@ -160,22 +293,67 @@ fun KanbanScreen( ) if (state.selectedBoard != null) { DropdownMenuItem( - text = { Text("Rename Board") }, + text = { Text(stringResource(R.string.kanban_board_settings)) }, onClick = { showBoardMenu = false - showRenameBoardDialog = true + showBoardSettingsDialog = true }, ) - if (state.boards.size > 1) { + if (state.boards.size > 1 && !state.selectedBoard?.id.equals("default", ignoreCase = true)) { DropdownMenuItem( - text = { Text("Delete Board") }, + text = { Text(stringResource(R.string.kanban_archive_board)) }, onClick = { showBoardMenu = false showDeleteBoardDialog = true }, ) } + DropdownMenuItem( + text = { Text(stringResource(R.string.kanban_export_board)) }, + onClick = { + showBoardMenu = false + state.selectedBoard?.let { b -> + coroutineScope.launch { + when (val res = viewModel.exportBoard(b.id)) { + is NetworkResult.Success -> { + val download = + GatewayFileClient.fetch( + res.data.archive, + context.cacheDir, + ) + if (download is GatewayFileResult.Success) { + pendingExportPath = download.file.cacheFile.absolutePath + exportLauncher.launch("${b.slug}.tar.gz") + } else { + viewModel.showToast("Failed to download board export") + } + } + + is NetworkResult.Failure -> { + viewModel.showToast("Export failed: ${res.error.message}") + } + } + } + } + }, + ) } + DropdownMenuItem( + text = { Text(stringResource(R.string.kanban_import_board)) }, + onClick = { + showBoardMenu = false + importLauncher.launch( + arrayOf("application/gzip", "application/x-gzip", "application/octet-stream", "*/*"), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.kanban_orchestration_settings)) }, + onClick = { + showBoardMenu = false + showOrchestrationDialog = true + }, + ) DropdownMenuItem( text = { Text(if (isMultiSelectMode) "Exit Multi-Select" else "Select Multiple") }, onClick = { @@ -260,20 +438,29 @@ fun KanbanScreen( Spacer(modifier = Modifier.width(8.dp)) IconButton(onClick = { showFilterSheet = true }) { Icon( - imageVector = Icons.Filled.FilterList, + imageVector = Icons.Default.FilterList, contentDescription = "Filters", tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } Spacer(modifier = Modifier.width(4.dp)) - IconButton(onClick = { showAddTaskDialog = true }) { + IconButton( + onClick = { + createTaskDefaultColumn = DEFAULT_COLUMN + showAddTaskDialog = true + }, + ) { Icon( - imageVector = Icons.Filled.Add, + imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.kanban_add_task), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } + + val boardHasWork = state.tasks.isNotEmpty() + val statusColors = LocalHermesStatusColors.current + LazyRow( modifier = Modifier.fillMaxSize(), contentPadding = @@ -291,79 +478,310 @@ fun KanbanScreen( val column = state.columns[columnIndex] val colName = column.name val colTasks = tasksByColumn[colName.lowercase()] ?: emptyList() - - Column( - modifier = - Modifier - .width(280.dp) - .fillMaxSize(), - ) { - Text( - text = "${colName.replaceFirstChar { it.uppercase() }} (${ - colTasks.size - })", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 8.dp), + val isCollapsed = + KanbanColumnCollapseHelper.isColumnCollapsed( + columnName = colName, + columnTaskCount = colTasks.size, + boardHasWork = boardHasWork, + overrides = state.columnCollapseOverrides, ) + val statusTone = + when (colName.lowercase()) { + "ready" -> statusColors.info + "running" -> statusColors.success + "blocked" -> statusColors.error + "review" -> statusColors.warning + "done" -> statusColors.success + else -> MaterialTheme.colorScheme.outline + } - if (colTasks.isEmpty()) { - Box( + if (isCollapsed) { + Card( + modifier = + Modifier + .width(56.dp) + .fillMaxHeight() + .clickable { + viewModel.toggleColumnCollapse( + colName, + boardHasWork, + colTasks.size, + ) + }, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize().padding(vertical = 12.dp), + ) { + Box( + modifier = + Modifier + .size(8.dp) + .background(statusTone, shape = CircleShape), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = colTasks.size.toString(), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = colName.take(3).uppercase(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.weight(1f)) + if (KanbanStatusConstraints.isUserWritableTarget(colName)) { + IconButton( + onClick = { + createTaskDefaultColumn = colName + showAddTaskDialog = true + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = + stringResource( + R.string.kanban_add_to_column, + colName, + ), + modifier = Modifier.size(16.dp), + ) + } + } + IconButton( + onClick = { + viewModel.toggleColumnCollapse( + colName, + boardHasWork, + colTasks.size, + ) + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = + stringResource( + R.string.kanban_expand_column, + colName, + ), + modifier = Modifier.size(16.dp), + ) + } + } + } + } else { + Column( + modifier = + Modifier + .width(280.dp) + .fillMaxSize(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth() - .padding(vertical = 16.dp), - contentAlignment = Alignment.Center, + .padding(bottom = 8.dp), ) { Text( - text = stringResource(R.string.kanban_no_tasks), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = "${colName.replaceFirstChar { + it.uppercase() + }} (${colTasks.size})", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, ) + Row(verticalAlignment = Alignment.CenterVertically) { + if (KanbanStatusConstraints.isUserWritableTarget(colName)) { + IconButton( + onClick = { + createTaskDefaultColumn = colName + showAddTaskDialog = true + }, + modifier = Modifier.size(28.dp), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = + stringResource( + R.string.kanban_add_to_column, + colName, + ), + modifier = Modifier.size(18.dp), + ) + } + } + IconButton( + onClick = { + viewModel.toggleColumnCollapse( + colName, + boardHasWork, + colTasks.size, + ) + }, + modifier = Modifier.size(28.dp), + ) { + Icon( + imageVector = + Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = + stringResource( + R.string.kanban_collapse_column, + colName, + ), + modifier = Modifier.size(18.dp), + ) + } + } } - } else { - LazyColumn( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(colTasks, key = { it.id }) { task -> - KanbanTaskCard( - task = task, - isSelected = - isMultiSelectMode && task.id in selectedTaskIds, - onTaskClick = { clickedTask -> - if (isMultiSelectMode) { - selectedTaskIds = - if (clickedTask.id in selectedTaskIds) { - selectedTaskIds - clickedTask.id - } else { - selectedTaskIds + clickedTask.id - } - } else { - state.selectedBoard?.let { board -> - NavigationController.navigateTo( - KanbanTaskDetailKey( - boardSlug = board.id, - taskId = clickedTask.id, - ), - ) - } - } - }, - onActionClick = { clickedTask -> - if (isMultiSelectMode) { - selectedTaskIds = - if (clickedTask.id in selectedTaskIds) { - selectedTaskIds - clickedTask.id - } else { - selectedTaskIds + clickedTask.id - } - } else { - taskForActions = clickedTask - } - }, + + if (colTasks.isEmpty()) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.kanban_no_tasks), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + } else if (colName.equals("running", ignoreCase = true) && + state.groupRunning + ) { + val runningGroups = + remember(colTasks) { + KanbanLaneGrouping.groupRunningTasks(colTasks) + } + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + runningGroups.forEach { group -> + item(key = "group_${group.assignee ?: "unassigned"}") { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth().padding( + vertical = 4.dp, + ), + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = + "${group.assignee ?: stringResource( + R.string.kanban_unassigned_group, + )} (${group.tasks.size})", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + items(group.tasks, key = { it.id }) { task -> + KanbanTaskCard( + task = task, + defaultAssignee = + state.orchestration + ?.resolvedDefaultAssignee, + isSelected = + isMultiSelectMode && task.id in selectedTaskIds, + onTaskClick = { clickedTask -> + if (isMultiSelectMode) { + selectedTaskIds = + if (clickedTask.id in selectedTaskIds) { + selectedTaskIds - clickedTask.id + } else { + selectedTaskIds + clickedTask.id + } + } else { + state.selectedBoard?.let { board -> + NavigationController.navigateTo( + KanbanTaskDetailKey( + boardSlug = board.id, + taskId = clickedTask.id, + ), + ) + } + } + }, + onActionClick = { clickedTask -> + if (isMultiSelectMode) { + selectedTaskIds = + if (clickedTask.id in selectedTaskIds) { + selectedTaskIds - clickedTask.id + } else { + selectedTaskIds + clickedTask.id + } + } else { + taskForActions = clickedTask + } + }, + ) + } + } + } + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(colTasks, key = { it.id }) { task -> + KanbanTaskCard( + task = task, + defaultAssignee = + state.orchestration + ?.resolvedDefaultAssignee, + isSelected = + isMultiSelectMode && task.id in selectedTaskIds, + onTaskClick = { clickedTask -> + if (isMultiSelectMode) { + selectedTaskIds = + if (clickedTask.id in selectedTaskIds) { + selectedTaskIds - clickedTask.id + } else { + selectedTaskIds + clickedTask.id + } + } else { + state.selectedBoard?.let { board -> + NavigationController.navigateTo( + KanbanTaskDetailKey( + boardSlug = board.id, + taskId = clickedTask.id, + ), + ) + } + } + }, + onActionClick = { clickedTask -> + if (isMultiSelectMode) { + selectedTaskIds = + if (clickedTask.id in selectedTaskIds) { + selectedTaskIds - clickedTask.id + } else { + selectedTaskIds + clickedTask.id + } + } else { + taskForActions = clickedTask + } + }, + ) + } + } } } } @@ -376,10 +794,15 @@ fun KanbanScreen( if (showAddTaskDialog) { KanbanCreateTaskDialog( columns = state.columns, - defaultColumn = state.columns.firstOrNull()?.name ?: DEFAULT_COLUMN, + defaultColumn = createTaskDefaultColumn, profiles = state.profiles, existingTasks = state.tasks, isCreating = state.isCreatingTask, + defaultAssignee = state.orchestration?.resolvedDefaultAssignee, + boardDefaultWorkspaceKind = state.selectedBoard?.defaultWorkspaceKind, + boardDefaultWorkdir = state.selectedBoard?.defaultWorkdir, + modelProviders = state.modelProviders, + pinnedModels = state.pinnedModels, onEstimate = viewModel::estimateNewTask, onDismiss = { showAddTaskDialog = false }, onConfirm = { body, targetStatus -> @@ -453,17 +876,17 @@ fun KanbanScreen( tenants = tenants, selectedAssignee = selectedAssignee, selectedTenant = selectedTenant, - includeArchived = includeArchived, - groupRunning = groupRunning, + includeArchived = state.includeArchived, + groupRunning = state.groupRunning, onSelectAssignee = { selectedAssignee = it }, onSelectTenant = { selectedTenant = it }, - onToggleIncludeArchived = { includeArchived = it }, - onToggleGroupRunning = { groupRunning = it }, + onToggleIncludeArchived = viewModel::setIncludeArchived, + onToggleGroupRunning = viewModel::setGroupRunning, onClearFilters = { selectedAssignee = null selectedTenant = null - includeArchived = false - groupRunning = false + viewModel.setIncludeArchived(false) + viewModel.setGroupRunning(false) }, onDismiss = { showFilterSheet = false }, ) @@ -484,7 +907,10 @@ fun KanbanScreen( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(), + modifier = + Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .fillMaxWidth(), ) { Text( text = "${selectedTaskIds.size} selected", @@ -513,16 +939,44 @@ fun KanbanScreen( ) { Text("Move") } + Button( + onClick = { showBulkAssignDialog = true }, + enabled = selectedTaskIds.isNotEmpty(), + ) { + Text(stringResource(R.string.kanban_bulk_assign)) + } Button( onClick = { - viewModel.bulkArchive(selectedTaskIds.toList()) - isMultiSelectMode = false - selectedTaskIds = emptySet() + viewModel.bulkAssign(selectedTaskIds.toList(), null) { failed -> + selectedTaskIds = failed + if (failed.isEmpty()) isMultiSelectMode = false + } + }, + enabled = selectedTaskIds.isNotEmpty(), + ) { + Text(stringResource(R.string.kanban_bulk_unassign)) + } + Button( + onClick = { + viewModel.bulkArchive(selectedTaskIds.toList()) { failed -> + selectedTaskIds = failed + if (failed.isEmpty()) isMultiSelectMode = false + } }, enabled = selectedTaskIds.isNotEmpty(), ) { Text("Archive") } + Button( + onClick = { showBulkDeleteDialog = true }, + enabled = selectedTaskIds.isNotEmpty(), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.kanban_bulk_delete)) + } IconButton( onClick = { isMultiSelectMode = false @@ -537,18 +991,23 @@ fun KanbanScreen( } if (showBulkMoveDialog) { + val writableMoveColumns = + remember(state.columns) { + state.columns.filter { KanbanStatusConstraints.isUserWritableTarget(it.name) } + } AlertDialog( onDismissRequest = { showBulkMoveDialog = false }, title = { Text("Move ${selectedTaskIds.size} tasks") }, text = { Column { - state.columns.forEach { col -> + writableMoveColumns.forEach { col -> TextButton( onClick = { - viewModel.bulkMove(selectedTaskIds.toList(), col.name) + viewModel.bulkMove(selectedTaskIds.toList(), col.name) { failed -> + selectedTaskIds = failed + if (failed.isEmpty()) isMultiSelectMode = false + } showBulkMoveDialog = false - isMultiSelectMode = false - selectedTaskIds = emptySet() }, modifier = Modifier.fillMaxWidth(), ) { @@ -560,7 +1019,70 @@ fun KanbanScreen( confirmButton = {}, dismissButton = { TextButton(onClick = { showBulkMoveDialog = false }) { - Text("Cancel") + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + if (showBulkAssignDialog) { + AlertDialog( + onDismissRequest = { showBulkAssignDialog = false }, + title = { Text(stringResource(R.string.kanban_bulk_assign)) }, + text = { + Column { + state.profiles.forEach { profile -> + TextButton( + onClick = { + viewModel.bulkAssign(selectedTaskIds.toList(), profile.name) { failed -> + selectedTaskIds = failed + if (failed.isEmpty()) isMultiSelectMode = false + } + showBulkAssignDialog = false + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(profile.name, modifier = Modifier.fillMaxWidth()) + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = { showBulkAssignDialog = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + if (showBulkDeleteDialog) { + AlertDialog( + onDismissRequest = { showBulkDeleteDialog = false }, + title = { Text(stringResource(R.string.kanban_bulk_delete)) }, + text = { + Text(stringResource(R.string.kanban_bulk_delete_confirm, selectedTaskIds.size)) + }, + confirmButton = { + Button( + onClick = { + viewModel.bulkDelete(selectedTaskIds.toList()) { failed -> + selectedTaskIds = failed + if (failed.isEmpty()) isMultiSelectMode = false + } + showBulkDeleteDialog = false + }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.kanban_bulk_delete)) + } + }, + dismissButton = { + TextButton(onClick = { showBulkDeleteDialog = false }) { + Text(stringResource(R.string.action_cancel)) } }, ) @@ -570,6 +1092,9 @@ fun KanbanScreen( var boardSlug by remember { mutableStateOf("") } var boardName by remember { mutableStateOf("") } var boardDesc by remember { mutableStateOf("") } + var selectedProjId by remember { mutableStateOf(null) } + var projectDropdownExpanded by remember { mutableStateOf(false) } + AlertDialog( onDismissRequest = { showCreateBoardDialog = false }, title = { Text("Create New Board") }, @@ -577,14 +1102,8 @@ fun KanbanScreen( Column { OutlinedTextField( value = boardSlug, - onValueChange = { - boardSlug = - it.lowercase().filter { c -> - c.isLetterOrDigit() || c == '-' || c == '_' - } - }, - label = { Text("Board ID / Slug") }, - placeholder = { Text("e.g. sprint-3") }, + onValueChange = { boardSlug = it }, + label = { Text("Slug (id)") }, modifier = Modifier.fillMaxWidth(), singleLine = true, ) @@ -593,7 +1112,6 @@ fun KanbanScreen( value = boardName, onValueChange = { boardName = it }, label = { Text("Display Name") }, - placeholder = { Text("e.g. Sprint 3") }, modifier = Modifier.fillMaxWidth(), singleLine = true, ) @@ -604,6 +1122,51 @@ fun KanbanScreen( label = { Text("Description") }, modifier = Modifier.fillMaxWidth(), ) + Spacer(modifier = Modifier.height(8.dp)) + ExposedDropdownMenuBox( + expanded = projectDropdownExpanded, + onExpandedChange = { projectDropdownExpanded = it }, + ) { + val displayProj = + state.projects.find { it.id == selectedProjId }?.name + ?: stringResource(R.string.kanban_no_project) + OutlinedTextField( + value = displayProj, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.kanban_project)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = projectDropdownExpanded, + ) + }, + modifier = + Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + ) + ExposedDropdownMenu( + expanded = projectDropdownExpanded, + onDismissRequest = { projectDropdownExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.kanban_no_project)) }, + onClick = { + selectedProjId = null + projectDropdownExpanded = false + }, + ) + state.projects.forEach { proj -> + DropdownMenuItem( + text = { Text(proj.name) }, + onClick = { + selectedProjId = proj.id + projectDropdownExpanded = false + }, + ) + } + } + } } }, confirmButton = { @@ -614,6 +1177,7 @@ fun KanbanScreen( slug = boardSlug.trim(), name = boardName.trim().ifBlank { null }, description = boardDesc.trim().ifBlank { null }, + projectId = selectedProjId, ) showCreateBoardDialog = false } @@ -625,56 +1189,46 @@ fun KanbanScreen( }, dismissButton = { TextButton(onClick = { showCreateBoardDialog = false }) { - Text("Cancel") + Text(stringResource(R.string.action_cancel)) } }, ) } - if (showRenameBoardDialog && state.selectedBoard != null) { - var newName by remember { mutableStateOf(state.selectedBoard?.name ?: "") } - AlertDialog( - onDismissRequest = { showRenameBoardDialog = false }, - title = { Text("Rename Board") }, - text = { - OutlinedTextField( - value = newName, - onValueChange = { newName = it }, - label = { Text("Board Name") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - }, - confirmButton = { - Button( - onClick = { - if (newName.isNotBlank()) { - state.selectedBoard?.let { board -> - viewModel.renameBoard(board.id, newName.trim()) - } - showRenameBoardDialog = false - } - }, - enabled = newName.isNotBlank(), - ) { - Text("Save") - } - }, - dismissButton = { - TextButton(onClick = { showRenameBoardDialog = false }) { - Text("Cancel") + if (showBoardSettingsDialog && state.selectedBoard != null) { + KanbanBoardSettingsDialog( + board = state.selectedBoard!!, + projects = state.projects, + onSave = { body -> + state.selectedBoard?.let { b -> + viewModel.updateBoard(b.id, body) } }, + onDismiss = { showBoardSettingsDialog = false }, + ) + } + + if (showOrchestrationDialog) { + KanbanOrchestrationDialog( + settings = state.orchestration, + profiles = state.profiles, + onSaveSettings = viewModel::updateOrchestration, + onSaveProfileDescription = viewModel::updateProfileDescription, + onAutoDescribe = viewModel::autoDescribeProfile, + onDismiss = { showOrchestrationDialog = false }, ) } if (showDeleteBoardDialog && state.selectedBoard != null) { AlertDialog( onDismissRequest = { showDeleteBoardDialog = false }, - title = { Text("Delete Board") }, + title = { Text(stringResource(R.string.kanban_archive_board)) }, text = { Text( - "Are you sure you want to delete board '${state.selectedBoard?.displayName}'? This action cannot be undone.", + stringResource( + R.string.kanban_archive_board_confirm, + state.selectedBoard?.displayName.orEmpty(), + ), ) }, confirmButton = { @@ -690,12 +1244,12 @@ fun KanbanScreen( containerColor = MaterialTheme.colorScheme.error, ), ) { - Text("Delete") + Text(stringResource(R.string.kanban_action_archive)) } }, dismissButton = { TextButton(onClick = { showDeleteBoardDialog = false }) { - Text("Cancel") + Text(stringResource(R.string.action_cancel)) } }, ) @@ -718,36 +1272,19 @@ private fun LiveStatusPill(isLive: Boolean) { Box( modifier = Modifier - .size(8.dp) - .background(color = color, shape = CircleShape), + .size(6.dp) + .background(color, CircleShape), ) - Spacer(modifier = Modifier.width(6.dp)) + Spacer(modifier = Modifier.width(4.dp)) Text( - text = stringResource(if (isLive) R.string.kanban_live else R.string.kanban_offline), - style = MaterialTheme.typography.labelMedium, + text = if (isLive) stringResource(R.string.kanban_live) else stringResource(R.string.kanban_offline), + style = MaterialTheme.typography.labelSmall, color = color, ) } } -private fun KanbanTaskAction.labelRes(): Int = - when (this) { - KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage - KanbanTaskAction.READY -> R.string.kanban_action_ready - KanbanTaskAction.UNBLOCK -> R.string.kanban_action_unblock - KanbanTaskAction.BLOCK -> R.string.kanban_action_block - KanbanTaskAction.COMPLETE -> R.string.kanban_action_complete - KanbanTaskAction.ARCHIVE -> R.string.kanban_action_archive - } - -private fun KanbanTaskAction.confirmRes(): Int = - when (this) { - KanbanTaskAction.BLOCK -> R.string.kanban_confirm_blocked - KanbanTaskAction.COMPLETE -> R.string.kanban_confirm_done - KanbanTaskAction.ARCHIVE -> R.string.kanban_confirm_archive - else -> error("Action $this has no confirm message") - } - +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun TaskActionSheet( task: KanbanTask, @@ -755,31 +1292,37 @@ private fun TaskActionSheet( onAction: (KanbanTaskAction) -> Unit, onDismiss: () -> Unit, ) { - AlertDialog( + androidx.compose.material3.ModalBottomSheet( onDismissRequest = onDismiss, - title = { Text(task.title, maxLines = 2, overflow = TextOverflow.Ellipsis) }, - text = { - Column { - actions.forEach { action -> - Button( - onClick = { onAction(action) }, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - ) { - Text(stringResource(action.labelRes())) - } + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + ) { + Text( + text = task.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + modifier = Modifier.padding(bottom = 12.dp), + ) + actions.forEach { action -> + TextButton( + onClick = { onAction(action) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(action.labelRes()), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxWidth(), + ) } } - }, - confirmButton = {}, - dismissButton = { - Button(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - }, - ) + } + } } @Composable @@ -790,15 +1333,16 @@ private fun ConfirmActionDialog( ) { AlertDialog( onDismissRequest = onDismiss, + title = { Text("Confirm action") }, text = { Text(message) }, confirmButton = { Button(onClick = onConfirm) { - Text(stringResource(R.string.action_confirm)) + Text("Confirm") } }, dismissButton = { - Button(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) + TextButton(onClick = onDismiss) { + Text("Cancel") } }, ) @@ -806,7 +1350,7 @@ private fun ConfirmActionDialog( @Composable private fun CompleteTaskDialog( - onConfirm: (String) -> Unit, + onConfirm: (String?) -> Unit, onDismiss: () -> Unit, ) { var summary by remember { mutableStateOf("") } @@ -814,26 +1358,44 @@ private fun CompleteTaskDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.kanban_complete_title)) }, text = { - OutlinedTextField( - value = summary, - onValueChange = { summary = it }, - label = { Text(stringResource(R.string.kanban_complete_summary_label)) }, - supportingText = { Text(stringResource(R.string.kanban_complete_summary_hint)) }, - modifier = Modifier.fillMaxWidth(), - ) + Column { + OutlinedTextField( + value = summary, + onValueChange = { summary = it }, + label = { Text(stringResource(R.string.kanban_complete_summary_label)) }, + placeholder = { Text(stringResource(R.string.kanban_complete_summary_hint)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + ) + } }, confirmButton = { - Button( - onClick = { onConfirm(summary.trim()) }, - enabled = summary.isNotBlank(), - ) { + Button(onClick = { onConfirm(summary.trim().ifBlank { null }) }) { Text(stringResource(R.string.kanban_action_complete)) } }, dismissButton = { - Button(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) + TextButton(onClick = onDismiss) { + Text("Cancel") } }, ) } + +private fun KanbanTaskAction.labelRes(): Int = + when (this) { + KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage + KanbanTaskAction.READY -> R.string.kanban_action_ready + KanbanTaskAction.BLOCK -> R.string.kanban_action_block + KanbanTaskAction.UNBLOCK -> R.string.kanban_action_unblock + KanbanTaskAction.COMPLETE -> R.string.kanban_action_complete + KanbanTaskAction.ARCHIVE -> R.string.kanban_action_archive + } + +private fun KanbanTaskAction.confirmRes(): Int = + when (this) { + KanbanTaskAction.BLOCK -> R.string.kanban_confirm_blocked + KanbanTaskAction.COMPLETE -> R.string.kanban_confirm_done + KanbanTaskAction.ARCHIVE -> R.string.kanban_confirm_archive + else -> R.string.kanban_confirm_blocked + } 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 87d796ae..ef23a3eb 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 @@ -5,9 +5,11 @@ import android.provider.OpenableColumns import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -18,6 +20,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer @@ -26,16 +29,21 @@ import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.ChatBubbleOutline import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -43,9 +51,11 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Tab import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -56,11 +66,21 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.KanbanTaskDetailKey +import com.m57.hermescontrol.NavigationController +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.KanbanProfile +import com.m57.hermescontrol.data.model.KanbanTaskFull +import com.m57.hermescontrol.data.model.ModelProvider +import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.data.model.TaskEstimate +import com.m57.hermescontrol.data.model.TaskLinks import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.chat.MarkdownText import com.m57.hermescontrol.ui.common.ErrorState @@ -68,6 +88,7 @@ import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.ToastEffect +import com.m57.hermescontrol.ui.kanban.components.KanbanModelOverrideEditor @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -81,6 +102,7 @@ fun KanbanTaskScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() var selectedTabIndex by remember { mutableIntStateOf(0) } val tabs = listOf("Overview", "Discussion", "Runs", "Activity", "Files") + var showDeleteDialog by remember { mutableStateOf(false) } LaunchedEffect(boardSlug, taskId) { viewModel.loadTask(boardSlug, taskId) @@ -105,6 +127,15 @@ fun KanbanTaskScreen( } }, navigationIcon = NavIcon.Back(onBack), + actions = { + IconButton(onClick = { showDeleteDialog = true }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.kanban_delete_task), + tint = MaterialTheme.colorScheme.error, + ) + } + }, drawerGesturesEnabled = false, isRefreshing = state.isLoading, onRefresh = { viewModel.loadTask(boardSlug, taskId) }, @@ -127,7 +158,10 @@ fun KanbanTaskScreen( val task = detail.task Column(modifier = Modifier.fillMaxSize()) { - PrimaryTabRow(selectedTabIndex = selectedTabIndex) { + PrimaryScrollableTabRow( + selectedTabIndex = selectedTabIndex, + edgePadding = 16.dp, + ) { tabs.forEachIndexed { index, title -> Tab( selected = selectedTabIndex == index, @@ -141,6 +175,26 @@ fun KanbanTaskScreen( 0 -> { TaskOverviewTab( task = task, + links = detail.links, + profiles = state.profiles, + modelProviders = state.modelProviders, + pinnedModels = state.pinnedModels, + estimate = state.estimate, + isEstimating = state.isEstimating, + onReassign = { newProfile -> + viewModel.reassign(boardSlug, taskId, newProfile) + }, + onUpdateModel = { override -> + viewModel.updateModelOverride(boardSlug, taskId, override) + }, + onEstimate = { + viewModel.estimateTask(boardSlug, taskId) + }, + onNavigateToTask = { targetId -> + NavigationController.navigateTo( + KanbanTaskDetailKey(boardSlug = boardSlug, taskId = targetId), + ) + }, onSaveDescription = { newBody -> viewModel.updateDescription(boardSlug, taskId, newBody) }, @@ -191,19 +245,59 @@ fun KanbanTaskScreen( } } } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text(stringResource(R.string.kanban_delete_task)) }, + text = { Text(stringResource(R.string.kanban_delete_task_confirm)) }, + confirmButton = { + Button( + onClick = { + showDeleteDialog = false + viewModel.deleteTask(boardSlug, taskId, onSuccess = onBack) + }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(R.string.kanban_delete_task)) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } } @Composable private fun TaskOverviewTab( - task: com.m57.hermescontrol.data.model.KanbanTaskFull, + task: KanbanTaskFull, + links: TaskLinks, + profiles: List, + modelProviders: List, + pinnedModels: List, + estimate: TaskEstimate?, + isEstimating: Boolean, + onReassign: (String?) -> Unit, + onUpdateModel: (KanbanModelOverride) -> Unit, + onEstimate: () -> Unit, + onNavigateToTask: (String) -> Unit, onSaveDescription: (String) -> Unit, onReclaim: () -> Unit, modifier: Modifier = Modifier, ) { var isEditingDescription by remember { mutableStateOf(false) } var descriptionDraft by remember(task.body) { mutableStateOf(task.body ?: "") } + var assigneeMenuExpanded by remember { mutableStateOf(false) } val statusColors = LocalHermesStatusColors.current + val currentModelOverride = + remember(task.modelOverride, task.providerOverride, task.reasoningEffort) { + KanbanModelOverride.fromTask(task.modelOverride, task.providerOverride, task.reasoningEffort) + } + LazyColumn( modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp), @@ -260,7 +354,7 @@ private fun TaskOverviewTab( } } - // Metadata card + // Metadata / Attributes card Card( shape = RoundedCornerShape(8.dp), modifier = @@ -277,13 +371,199 @@ private fun TaskOverviewTab( ) Spacer(modifier = Modifier.height(8.dp)) MetaRow(label = "Status", value = task.status.uppercase()) - task.assignee?.let { MetaRow(label = "Assignee", value = it) } + + // Editable Assignee + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + ) { + Text( + text = "Assignee", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Box { + TextButton( + onClick = { assigneeMenuExpanded = true }, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), + modifier = Modifier.height(32.dp), + ) { + Text( + text = task.assignee ?: "Unassigned", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + } + DropdownMenu( + expanded = assigneeMenuExpanded, + onDismissRequest = { assigneeMenuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text("Unassigned") }, + onClick = { + onReassign(null) + assigneeMenuExpanded = false + }, + ) + profiles.forEach { p -> + DropdownMenuItem( + text = { Text(p.name) }, + onClick = { + onReassign(p.name) + assigneeMenuExpanded = false + }, + ) + } + } + } + } + task.priority?.let { MetaRow(label = "Priority", value = it.toString()) } task.tenant?.let { MetaRow(label = "Tenant", value = it) } - task.modelOverride?.let { MetaRow(label = "Model Override", value = it) } - task.reasoningEffort?.let { MetaRow(label = "Reasoning Effort", value = it) } task.workspaceKind?.let { MetaRow(label = "Workspace", value = "$it ${task.workspacePath ?: ""}") } task.workerPid?.let { MetaRow(label = "Worker PID", value = it.toString()) } + + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + // Editable Model & Reasoning Override + KanbanModelOverrideEditor( + override = currentModelOverride, + onOverrideChange = onUpdateModel, + modelProviders = modelProviders, + pinnedModels = pinnedModels, + ) + } + } + + // Dependencies Section + if (links.parents.isNotEmpty() || links.children.isNotEmpty()) { + Card( + shape = RoundedCornerShape(8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = stringResource(R.string.kanban_dependencies), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + if (links.parents.isNotEmpty()) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = stringResource(R.string.kanban_blocked_by), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + ) { + links.parents.forEach { parentId -> + SuggestionChip( + onClick = { onNavigateToTask(parentId) }, + label = { Text("#${parentId.removePrefix("t_").take(6)}") }, + ) + } + } + } + if (links.children.isNotEmpty()) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = stringResource(R.string.kanban_blocks), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + ) { + links.children.forEach { childId -> + SuggestionChip( + onClick = { onNavigateToTask(childId) }, + label = { Text("#${childId.removePrefix("t_").take(6)}") }, + ) + } + } + } + } + } + } + + // Effort Estimation Section + Card( + shape = RoundedCornerShape(8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.kanban_estimate), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + if (isEstimating) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + } + } + val currentEst = estimate + if (currentEst != null) { + Spacer(modifier = Modifier.height(6.dp)) + currentEst.tokens?.let { tokensCount -> + Text( + text = stringResource(R.string.kanban_estimated_tokens, tokensCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + if (!currentEst.complexity.isNullOrBlank()) { + Text( + text = stringResource(R.string.kanban_complexity, currentEst.complexity), + style = MaterialTheme.typography.bodySmall, + ) + } + if (!currentEst.rationale.isNullOrBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = currentEst.rationale, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = onEstimate, + enabled = !isEstimating, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.kanban_reestimate_action)) + } + } else { + Spacer(modifier = Modifier.height(6.dp)) + Button( + onClick = onEstimate, + enabled = !isEstimating, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.kanban_estimate_action)) + } + } } } @@ -367,16 +647,13 @@ private fun TaskOverviewTab( color = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.height(6.dp)) - SelectionContainer { - MarkdownText( - text = summary, - textColor = MaterialTheme.colorScheme.onSurface, - ) - } + MarkdownText( + text = summary, + textColor = MaterialTheme.colorScheme.onSurface, + ) } } } - Spacer(modifier = Modifier.height(24.dp)) } } } @@ -390,29 +667,22 @@ private fun TaskDiscussionTab( onNoteAndRequeue: (String) -> Unit, modifier: Modifier = Modifier, ) { - var commentText by remember { mutableStateOf("") } + var newCommentText by remember { mutableStateOf("") } - Column(modifier = modifier.fillMaxSize()) { + Column(modifier = modifier.fillMaxSize().padding(16.dp)) { LazyColumn( - modifier = - Modifier - .weight(1f) - .padding(horizontal = 16.dp, vertical = 8.dp), + modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp), ) { if (comments.isEmpty()) { item { - Box(modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { - Text( - "No comments yet. Post a note to steer the agent.", - style = MaterialTheme.typography.bodySmall, - ) - } + Text("No comments yet.", style = MaterialTheme.typography.bodySmall) } } else { items(comments, key = { it.id }) { comment -> Card( - shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(6.dp), modifier = Modifier.fillMaxWidth(), ) { Column(modifier = Modifier.padding(10.dp)) { @@ -422,65 +692,69 @@ private fun TaskDiscussionTab( ) { Text( text = comment.author, - style = MaterialTheme.typography.labelMedium, + style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, ) } Spacer(modifier = Modifier.height(4.dp)) - SelectionContainer { - MarkdownText( - text = comment.body, - textColor = MaterialTheme.colorScheme.onSurface, - ) - } + MarkdownText( + text = comment.body, + textColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } } } - HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) - Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { - OutlinedTextField( - value = commentText, - onValueChange = { commentText = it }, - placeholder = { Text("Write a steering note...") }, - modifier = Modifier.fillMaxWidth(), - maxLines = 3, - ) - Spacer(modifier = Modifier.height(8.dp)) - Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { - if (isRunning) { - OutlinedButton( - onClick = { - onNoteAndRequeue(commentText) - commentText = "" - }, - enabled = commentText.isNotBlank() && !isSubmitting, - ) { - Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.width(4.dp)) - Text("Note & Requeue") - } - Spacer(modifier = Modifier.width(8.dp)) - } + OutlinedTextField( + value = newCommentText, + onValueChange = { newCommentText = it }, + placeholder = { Text("Write a comment...") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 4, + enabled = !isSubmitting, + ) - Button( + Spacer(modifier = Modifier.height(8.dp)) + + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + if (isRunning) { + OutlinedButton( onClick = { - onAddComment(commentText) - commentText = "" + if (newCommentText.isNotBlank()) { + onNoteAndRequeue(newCommentText.trim()) + newCommentText = "" + } }, - enabled = commentText.isNotBlank() && !isSubmitting, + enabled = newCommentText.isNotBlank() && !isSubmitting, ) { - if (isSubmitting) { - CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) - } else { - Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.width(4.dp)) - Text("Post") - } + Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Note & Requeue") } + Spacer(modifier = Modifier.width(8.dp)) + } + + Button( + onClick = { + if (newCommentText.isNotBlank()) { + onAddComment(newCommentText.trim()) + newCommentText = "" + } + }, + enabled = newCommentText.isNotBlank() && !isSubmitting, + ) { + if (isSubmitting) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(6.dp)) + } else { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + } + Text("Comment") } } } @@ -494,59 +768,58 @@ private fun TaskRunsTab( ) { LazyColumn( modifier = modifier.fillMaxSize().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { item { - Text("Attempt History", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + Text("Execution Runs", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) } if (runs.isEmpty()) { item { - Text("No worker attempts recorded yet.", style = MaterialTheme.typography.bodySmall) + Text("No run history available.", style = MaterialTheme.typography.bodySmall) } } else { items(runs, key = { it.id }) { run -> Card( - shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(6.dp), modifier = Modifier.fillMaxWidth(), ) { - Column(modifier = Modifier.padding(12.dp)) { + Column(modifier = Modifier.padding(10.dp)) { Row( horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { Text( - "Run #${run.id}", - style = MaterialTheme.typography.labelMedium, + text = "Run #${run.id}", + style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, ) Text( - text = (run.outcome ?: run.status).uppercase(), + text = run.status.uppercase(), style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, color = - if (run.outcome == "completed") { - LocalHermesStatusColors.current.success + if (run.status.equals("done", ignoreCase = true)) { + MaterialTheme.colorScheme.primary } else { - MaterialTheme.colorScheme.onSurfaceVariant + MaterialTheme.colorScheme.outline }, ) } - run.profile?.let { - Text("Profile: $it", style = MaterialTheme.typography.bodySmall) - } - val summary = run.error ?: run.summary - if (!summary.isNullOrBlank()) { - Spacer(modifier = Modifier.height(4.dp)) - Text(text = summary, style = MaterialTheme.typography.bodySmall) + run.profile?.let { Text("Profile: $it", style = MaterialTheme.typography.bodySmall) } + run.outcome?.let { Text("Outcome: $it", style = MaterialTheme.typography.bodySmall) } + run.error?.let { + Text( + "Error: $it", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) } } } } } - // Live Log Output if (workerLog != null && workerLog.exists && workerLog.content.isNotBlank()) { item { Spacer(modifier = Modifier.height(16.dp)) @@ -588,19 +861,33 @@ private fun TaskActivityTab( } } else { items(events, key = { it.id }) { event -> - Row(verticalAlignment = Alignment.CenterVertically) { + val formatted = remember(event) { KanbanEventFormatter.format(event) } + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + ) { Box( modifier = Modifier - .size(8.dp) + .padding(top = 5.dp) + .size(6.dp) .background(MaterialTheme.colorScheme.primary, shape = CircleShape), ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = event.kind, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.SemiBold, - ) + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = formatted.label, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + ) + if (!formatted.detail.isNullOrBlank()) { + Text( + text = formatted.detail, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } } } @@ -649,98 +936,75 @@ private fun TaskFilesTab( } } val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream" - val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - if (bytes != null) { + val bytes = + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: byteArrayOf() + if (bytes.isNotEmpty()) { onUpload(filename, mimeType, bytes) } } } - LazyColumn( - modifier = - modifier - .fillMaxSize() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - item { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), + Column(modifier = modifier.fillMaxSize().padding(16.dp)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Attachments", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + Button( + onClick = { launcher.launch("*/*") }, + enabled = !isUploading, ) { - Text( - text = "Attached Files (${attachments.size})", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - Button( - onClick = { launcher.launch("*/*") }, - enabled = !isUploading, - ) { - if (isUploading) { - CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) - Spacer(modifier = Modifier.width(6.dp)) - } - Text("Attach File") + if (isUploading) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(6.dp)) } + Text("Upload") } } - if (attachments.isEmpty()) { - item { - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = "No files attached to this task.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Spacer(modifier = Modifier.height(12.dp)) + + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (attachments.isEmpty()) { + item { + Text("No attachments uploaded.", style = MaterialTheme.typography.bodySmall) } - } - } else { - items(attachments, key = { it.id }) { att -> - Card( - shape = RoundedCornerShape(8.dp), - modifier = Modifier.fillMaxWidth(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .padding(12.dp) - .fillMaxWidth(), + } else { + items(attachments, key = { it.id }) { att -> + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth(), ) { - Icon( - imageVector = Icons.Filled.Description, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = att.filename, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - ) - val sizeStr = - att.size?.let { s -> - if (s > 1024 * 1024) "${s / (1024 * 1024)} MB" else "${s / 1024} KB" - } ?: "Unknown size" - Text( - text = "$sizeStr • ${att.uploadedBy ?: "unknown"}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(12.dp), + ) { + Icon( + Icons.Filled.Description, + contentDescription = null, + modifier = Modifier.size(24.dp), ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = att.filename?.ifBlank { "File #${att.id}" } ?: "File #${att.id}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + att.size?.let { + Text( + text = "${it / 1024} KB", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } } } 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 8fe7cc1c..b188628b 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 @@ -2,19 +2,28 @@ package com.m57.hermescontrol.ui.kanban import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.m57.hermescontrol.data.local.AuthManager +import com.m57.hermescontrol.data.model.KanbanProfile import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse -import com.m57.hermescontrol.data.model.ReassignTaskBody -import com.m57.hermescontrol.data.model.ReclaimTaskBody +import com.m57.hermescontrol.data.model.ModelProvider +import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.data.model.TaskEstimate import com.m57.hermescontrol.data.model.UpdateTaskBody import com.m57.hermescontrol.data.model.WorkerLog +import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.repository.KanbanRepository import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl +import com.m57.hermescontrol.data.ws.KanbanEventsClient import com.m57.hermescontrol.ui.common.ToastHost +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody @@ -24,43 +33,70 @@ data class KanbanTaskUiState( val isLoading: Boolean = false, val detail: KanbanTaskDetailResponse? = null, val workerLog: WorkerLog? = null, + val profiles: List = emptyList(), + val modelProviders: List = emptyList(), + val pinnedModels: List = emptyList(), + val estimate: TaskEstimate? = null, + val isEstimating: Boolean = false, + val isDeleting: Boolean = false, val errorMessage: String? = null, val toastMessage: String? = null, val isSubmittingComment: Boolean = false, val isSavingDescription: Boolean = false, val isReclaiming: Boolean = false, + val isReassigning: Boolean = false, + val isUpdatingModel: Boolean = false, val isUploadingAttachment: Boolean = false, ) class KanbanTaskViewModel( private val repository: KanbanRepository = KanbanRepositoryImpl(), + private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() }, ) : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(KanbanTaskUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private var eventsClient: KanbanEventsClient? = null + private var eventsBoard: String? = null + private var activeTaskId: String? = null + private var currentLoadGen: Int = 0 + private var reloadJob: Job? = null + private var logPollJob: Job? = null + fun loadTask( board: String, taskId: String, ) { + val gen = ++currentLoadGen + activeTaskId = taskId _uiState.update { it.copy(isLoading = true, errorMessage = null) } + loadProfiles() + loadModelOptions() + connectEvents(board) + viewModelScope.launch { when (val result = repository.getTask(taskId = taskId, board = board)) { is NetworkResult.Success -> { + if (gen != currentLoadGen) return@launch + val taskData = result.data _uiState.update { it.copy( isLoading = false, - detail = result.data, + detail = taskData, ) } - if (result.data.task.status - .equals("running", ignoreCase = true) - ) { + if (taskData.task.status.equals("running", ignoreCase = true)) { + startLogPolling(board, taskId) + } else { + stopLogPolling() loadLog(board, taskId) } } is NetworkResult.Failure -> { + if (gen != currentLoadGen) return@launch + stopLogPolling() _uiState.update { it.copy( isLoading = false, @@ -72,19 +108,132 @@ class KanbanTaskViewModel( } } + fun loadProfiles() { + viewModelScope.launch { + when (val result = repository.getProfiles()) { + is NetworkResult.Success -> { + _uiState.update { it.copy(profiles = result.data.profiles) } + } + + is NetworkResult.Failure -> { + // Failures to read profiles don't block detail + } + } + } + } + + fun loadModelOptions() { + viewModelScope.launch { + when (val res = safeApiCall { ApiClient.hermesApi.getModelOptions() }) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + modelProviders = res.data.providers, + pinnedModels = AuthManager.getPinnedModels(), + ) + } + } + + is NetworkResult.Failure -> { + // Failures to read models don't block detail + } + } + } + } + fun loadLog( board: String, taskId: String, ) { viewModelScope.launch { - when (val result = repository.getTaskLog(taskId = taskId, board = board)) { - is NetworkResult.Success -> { - _uiState.update { it.copy(workerLog = result.data) } - } + fetchLogInternal(board, taskId) + } + } - is NetworkResult.Failure -> { - // Failures to read log don't blank the task screen + private suspend fun fetchLogInternal( + board: String, + taskId: String, + ) { + when (val result = repository.getTaskLog(taskId = taskId, board = board)) { + is NetworkResult.Success -> { + _uiState.update { it.copy(workerLog = result.data) } + } + + is NetworkResult.Failure -> { + // Failures to read log don't blank the task screen + } + } + } + + private fun startLogPolling( + board: String, + taskId: String, + ) { + logPollJob?.cancel() + logPollJob = + viewModelScope.launch { + fetchLogInternal(board, taskId) + val isRunning = { + _uiState.value.detail + ?.task + ?.status + ?.equals("running", ignoreCase = true) == true } + while (isActive && isRunning()) { + delay(POLL_INTERVAL_MS) + fetchLogInternal(board, taskId) + } + } + } + + private fun stopLogPolling() { + logPollJob?.cancel() + logPollJob = null + } + + private fun connectEvents(board: String) { + if (eventsBoard == board) return + eventsBoard = board + val client = eventsClient ?: eventsClientProvider().also { eventsClient = it } + client.connect( + scope = viewModelScope, + board = board, + onEvents = { envelope -> + val current = activeTaskId + if (current != null && envelope.events.any { it.taskId == current }) { + scheduleTaskReload(board, current) + } + }, + onStatus = {}, + ) + } + + private fun scheduleTaskReload( + board: String, + taskId: String, + ) { + reloadJob?.cancel() + reloadJob = + viewModelScope.launch { + delay(RELOAD_DEBOUNCE_MS) + loadTaskSilently(board, taskId) + } + } + + private suspend fun loadTaskSilently( + board: String, + taskId: String, + ) { + val gen = currentLoadGen + val result = repository.getTask(taskId = taskId, board = board) + if (gen != currentLoadGen || taskId != activeTaskId) return + if (result is NetworkResult.Success) { + val taskData = result.data + _uiState.update { it.copy(detail = taskData) } + if (taskData.task.status.equals("running", ignoreCase = true)) { + if (logPollJob == null) startLogPolling(board, taskId) + } else { + stopLogPolling() } } } @@ -128,6 +277,117 @@ class KanbanTaskViewModel( } } + fun reassign( + board: String, + taskId: String, + profile: String?, + ) { + _uiState.update { it.copy(isReassigning = true) } + viewModelScope.launch { + when ( + val result = + repository.reassignTask( + taskId = taskId, + board = board, + profile = profile, + reclaimFirst = true, + ) + ) { + is NetworkResult.Success -> { + val msg = if (profile != null) "Task reassigned to $profile" else "Task unassigned" + _uiState.update { it.copy(isReassigning = false, toastMessage = msg) } + loadTask(board, taskId) + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy(isReassigning = false, toastMessage = "Failed to reassign: ${result.error.message}") + } + } + } + } + } + + fun updateModelOverride( + board: String, + taskId: String, + override: KanbanModelOverride, + ) { + _uiState.update { it.copy(isUpdatingModel = true) } + viewModelScope.launch { + when (val res = repository.updateTask(taskId = taskId, board = board, body = override.toUpdateTaskBody())) { + is NetworkResult.Success -> { + _uiState.update { + it.copy(isUpdatingModel = false, toastMessage = "Model override updated") + } + loadTask(board, taskId) + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy(isUpdatingModel = false, toastMessage = "Failed to update model: ${res.error.message}") + } + } + } + } + } + + fun estimateTask( + board: String, + taskId: String, + ) { + if (_uiState.value.isEstimating) return + _uiState.update { it.copy(isEstimating = true) } + viewModelScope.launch { + when (val res = repository.estimateTask(taskId = taskId, board = board)) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + isEstimating = false, + estimate = res.data, + toastMessage = "Estimate completed", + ) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isEstimating = false, + toastMessage = "Estimation failed: ${res.error.message}", + ) + } + } + } + } + } + + fun deleteTask( + board: String, + taskId: String, + onSuccess: () -> Unit, + ) { + if (_uiState.value.isDeleting) return + _uiState.update { it.copy(isDeleting = true) } + viewModelScope.launch { + when (val res = repository.deleteTask(taskId = taskId, board = board)) { + is NetworkResult.Success -> { + _uiState.update { it.copy(isDeleting = false, toastMessage = "Task deleted") } + onSuccess() + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isDeleting = false, + toastMessage = "Failed to delete task: ${res.error.message}", + ) + } + } + } + } + } + fun addComment( board: String, taskId: String, @@ -232,27 +492,6 @@ class KanbanTaskViewModel( } } - fun reassign( - board: String, - taskId: String, - profile: String, - ) { - viewModelScope.launch { - when (val result = repository.reassignTask(taskId = taskId, board = board, profile = profile)) { - is NetworkResult.Success -> { - _uiState.update { it.copy(toastMessage = "Task reassigned to $profile") } - loadTask(board, taskId) - } - - is NetworkResult.Failure -> { - _uiState.update { - it.copy(toastMessage = "Failed to reassign: ${result.error.message}") - } - } - } - } - } - fun uploadAttachment( board: String, taskId: String, @@ -295,7 +534,18 @@ class KanbanTaskViewModel( } } + public override fun onCleared() { + stopLogPolling() + eventsClient?.disconnect() + super.onCleared() + } + override fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } + + private companion object { + const val RELOAD_DEBOUNCE_MS = 250L + const val POLL_INTERVAL_MS = 3000L + } } 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 5c9499f4..3f7e2157 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 @@ -5,17 +5,28 @@ import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore import com.m57.hermescontrol.data.local.KanbanPreferencesStore +import com.m57.hermescontrol.data.model.BoardExportResult +import com.m57.hermescontrol.data.model.BoardImportResult import com.m57.hermescontrol.data.model.BulkTasksBody import com.m57.hermescontrol.data.model.CreateBoardBody import com.m57.hermescontrol.data.model.CreateTaskBody +import com.m57.hermescontrol.data.model.ExportBoardBody +import com.m57.hermescontrol.data.model.ImportBoardBody import com.m57.hermescontrol.data.model.KanbanBoard import com.m57.hermescontrol.data.model.KanbanColumn import com.m57.hermescontrol.data.model.KanbanProfile +import com.m57.hermescontrol.data.model.KanbanProject import com.m57.hermescontrol.data.model.KanbanTask +import com.m57.hermescontrol.data.model.ModelProvider +import com.m57.hermescontrol.data.model.OrchestrationSettings +import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate +import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.model.RenameBoardBody import com.m57.hermescontrol.data.model.TaskEstimate import com.m57.hermescontrol.data.model.UpdateTaskBody +import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.data.repository.KanbanRepository import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl import com.m57.hermescontrol.data.ws.KanbanEventsClient @@ -38,7 +49,14 @@ data class KanbanUiState( val columns: List = emptyList(), val tasks: List = emptyList(), val profiles: List = emptyList(), + val projects: List = emptyList(), + val orchestration: OrchestrationSettings? = null, + val modelProviders: List = emptyList(), + val pinnedModels: List = emptyList(), val isLive: Boolean = false, + val includeArchived: Boolean = false, + val groupRunning: Boolean = false, + val columnCollapseOverrides: Map = emptyMap(), val errorMessage: String? = null, val toastMessage: String? = null, ) @@ -131,7 +149,13 @@ class KanbanViewModel( }, ) : ViewModel(), ToastHost { - private val _uiState = MutableStateFlow(KanbanUiState()) + private val _uiState = + MutableStateFlow( + KanbanUiState( + includeArchived = preferences.getIncludeArchived(), + groupRunning = preferences.getGroupRunning(), + ), + ) val uiState: StateFlow = _uiState.asStateFlow() private var eventsClient: KanbanEventsClient? = null @@ -146,8 +170,11 @@ class KanbanViewModel( _uiState.update { it.copy(isLoading = true, errorMessage = null) } loadProfiles() + loadProjects() + loadOrchestration() + loadModelOptions() viewModelScope.launch { - when (val result = repository.getBoards()) { + when (val result = repository.getBoards(includeArchived = _uiState.value.includeArchived)) { is NetworkResult.Success -> { val boards = result.data.boards _uiState.update { it.copy(isLoading = false, boards = boards) } @@ -186,6 +213,40 @@ class KanbanViewModel( } } + fun setIncludeArchived(include: Boolean) { + preferences.setIncludeArchived(include) + _uiState.update { it.copy(includeArchived = include) } + val board = _uiState.value.selectedBoard ?: return + val gen = ++currentLoadGen + viewModelScope.launch { + loadBoardIntoState(board, gen) + } + } + + fun setGroupRunning(group: Boolean) { + preferences.setGroupRunning(group) + _uiState.update { it.copy(groupRunning = group) } + } + + fun toggleColumnCollapse( + columnName: String, + boardHasWork: Boolean, + currentCount: Int, + ) { + _uiState.update { state -> + val currentlyCollapsed = + KanbanColumnCollapseHelper.isColumnCollapsed( + columnName = columnName, + columnTaskCount = currentCount, + boardHasWork = boardHasWork, + overrides = state.columnCollapseOverrides, + ) + state.copy( + columnCollapseOverrides = state.columnCollapseOverrides + (columnName to !currentlyCollapsed), + ) + } + } + fun loadProfiles() { viewModelScope.launch { when (val result = repository.getProfiles()) { @@ -200,6 +261,123 @@ class KanbanViewModel( } } + fun loadProjects() { + viewModelScope.launch { + when (val result = repository.getProjects()) { + is NetworkResult.Success -> { + _uiState.update { it.copy(projects = result.data.projects) } + } + + is NetworkResult.Failure -> { + // Do not block UI if projects fetch fails + } + } + } + } + + fun loadOrchestration() { + viewModelScope.launch { + when (val result = repository.getOrchestration()) { + is NetworkResult.Success -> { + _uiState.update { it.copy(orchestration = result.data) } + } + + is NetworkResult.Failure -> { + // Do not block UI if orchestration fetch fails + } + } + } + } + + fun loadModelOptions() { + viewModelScope.launch { + when (val res = safeApiCall { ApiClient.hermesApi.getModelOptions() }) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + modelProviders = res.data.providers, + pinnedModels = AuthManager.getPinnedModels(), + ) + } + } + + is NetworkResult.Failure -> { + // Do not block UI if model options fetch fails + } + } + } + } + + fun updateOrchestration(body: OrchestrationSettingsUpdate) { + viewModelScope.launch { + when (val res = repository.updateOrchestration(body)) { + is NetworkResult.Success -> { + _uiState.update { + it.copy( + orchestration = res.data, + toastMessage = "Orchestration settings saved", + ) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy(toastMessage = "Failed to save orchestration: ${res.error.message}") + } + } + } + } + } + + fun updateProfileDescription( + name: String, + description: String, + ) { + viewModelScope.launch { + when (val res = repository.updateProfileDescription(name, description)) { + is NetworkResult.Success -> { + _uiState.update { it.copy(toastMessage = "Profile description saved") } + loadProfiles() + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy(toastMessage = "Failed to save profile description: ${res.error.message}") + } + } + } + } + } + + fun autoDescribeProfile( + name: String, + onComplete: ((String?) -> Unit)? = null, + ) { + viewModelScope.launch { + when (val res = repository.autoDescribeProfile(name)) { + is NetworkResult.Success -> { + if (res.data.ok) { + _uiState.update { it.copy(toastMessage = "Profile auto-described") } + loadProfiles() + onComplete?.invoke(res.data.description) + } else { + _uiState.update { + it.copy(toastMessage = res.data.reason ?: "Auto-describe failed") + } + onComplete?.invoke(null) + } + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy(toastMessage = "Failed to auto-describe: ${res.error.message}") + } + onComplete?.invoke(null) + } + } + } + } + fun createTask( body: CreateTaskBody, targetStatus: String = "todo", @@ -254,11 +432,16 @@ class KanbanViewModel( description: String?, status: String = "todo", ) { + val defaultAssignee = + _uiState.value.orchestration + ?.resolvedDefaultAssignee + ?.ifBlank { null } ?: "default" createTask( body = CreateTaskBody( title = title, body = description, + assignee = defaultAssignee, ), targetStatus = status, ) @@ -277,6 +460,7 @@ class KanbanViewModel( slug: String, name: String? = null, description: String? = null, + projectId: String? = null, ) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } @@ -285,6 +469,7 @@ class KanbanViewModel( slug = slug, name = name, description = description, + projectId = projectId?.ifBlank { null }, ) when (val res = repository.createBoard(body)) { is NetworkResult.Success -> { @@ -305,16 +490,16 @@ class KanbanViewModel( } } - fun renameBoard( + fun updateBoard( slug: String, - newName: String, + body: RenameBoardBody, ) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } - when (val res = repository.updateBoard(slug, RenameBoardBody(name = newName))) { + when (val res = repository.updateBoard(slug, body)) { is NetworkResult.Success -> { - val boardName = res.data.board?.name ?: newName - _uiState.update { it.copy(toastMessage = "Board renamed to $boardName") } + val boardName = res.data.board?.displayName ?: slug + _uiState.update { it.copy(toastMessage = "Board updated: $boardName") } loadBoards() } @@ -322,7 +507,7 @@ class KanbanViewModel( _uiState.update { it.copy( isLoading = false, - toastMessage = "Failed to rename board: ${res.error.message}", + toastMessage = "Failed to update board: ${res.error.message}", ) } } @@ -330,12 +515,23 @@ class KanbanViewModel( } } + fun renameBoard( + slug: String, + newName: String, + ) { + updateBoard(slug, RenameBoardBody(name = newName)) + } + fun deleteBoard(slug: String) { + if (slug.equals("default", ignoreCase = true)) { + _uiState.update { it.copy(toastMessage = "Cannot delete or archive the default board") } + return + } viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } - when (val res = repository.deleteBoard(slug, delete = true)) { + when (val res = repository.deleteBoard(slug, delete = false)) { is NetworkResult.Success -> { - _uiState.update { it.copy(toastMessage = "Board deleted") } + _uiState.update { it.copy(toastMessage = "Board archived") } loadBoards() } @@ -343,7 +539,29 @@ class KanbanViewModel( _uiState.update { it.copy( isLoading = false, - toastMessage = "Failed to delete board: ${res.error.message}", + toastMessage = "Failed to archive board: ${res.error.message}", + ) + } + } + } + } + } + + fun deleteTask(taskId: String) { + val board = _uiState.value.selectedBoard ?: return + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + when (val res = repository.deleteTask(taskId = taskId, board = board.id)) { + is NetworkResult.Success -> { + _uiState.update { it.copy(toastMessage = "Task deleted") } + reloadBoardSilently() + } + + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + isLoading = false, + toastMessage = "Failed to delete task: ${res.error.message}", ) } } @@ -354,15 +572,26 @@ class KanbanViewModel( fun bulkMove( taskIds: List, targetStatus: String, + onComplete: ((failedIds: Set) -> Unit)? = null, ) { val board = _uiState.value.selectedBoard ?: return viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, status = targetStatus))) { is NetworkResult.Success -> { - val count = res.data.results.count { it.ok } - _uiState.update { it.copy(toastMessage = "Moved $count tasks to $targetStatus") } + 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 msg = + when { + failed.isEmpty() -> "Moved ${succeeded.size} tasks to $targetStatus" + succeeded.isEmpty() -> "Failed to move tasks: ${firstError ?: "unknown error"}" + else -> "Moved ${succeeded.size} tasks, ${failed.size} failed: ${firstError ?: ""}" + } + _uiState.update { it.copy(toastMessage = msg) } reloadBoardSilently() + onComplete?.invoke(failed) } is NetworkResult.Failure -> { @@ -372,20 +601,34 @@ class KanbanViewModel( toastMessage = "Bulk move failed: ${res.error.message}", ) } + onComplete?.invoke(taskIds.toSet()) } } } } - fun bulkArchive(taskIds: List) { + fun bulkArchive( + taskIds: List, + onComplete: ((failedIds: Set) -> Unit)? = null, + ) { val board = _uiState.value.selectedBoard ?: return viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, archive = true))) { is NetworkResult.Success -> { - val count = res.data.results.count { it.ok } - _uiState.update { it.copy(toastMessage = "Archived $count tasks") } + 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 msg = + when { + failed.isEmpty() -> "Archived ${succeeded.size} tasks" + succeeded.isEmpty() -> "Failed to archive tasks: ${firstError ?: "unknown error"}" + else -> "Archived ${succeeded.size} tasks, ${failed.size} failed: ${firstError ?: ""}" + } + _uiState.update { it.copy(toastMessage = msg) } reloadBoardSilently() + onComplete?.invoke(failed) } is NetworkResult.Failure -> { @@ -395,6 +638,7 @@ class KanbanViewModel( toastMessage = "Bulk archive failed: ${res.error.message}", ) } + onComplete?.invoke(taskIds.toSet()) } } } @@ -403,15 +647,27 @@ class KanbanViewModel( fun bulkAssign( taskIds: List, assignee: String?, + onComplete: ((failedIds: Set) -> Unit)? = null, ) { 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))) { is NetworkResult.Success -> { - val count = res.data.results.count { it.ok } - _uiState.update { it.copy(toastMessage = "Assigned $count tasks") } + 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 msg = + when { + failed.isEmpty() -> "$actionName ${succeeded.size} tasks" + succeeded.isEmpty() -> "Failed to update assignment: ${firstError ?: "unknown error"}" + else -> "$actionName ${succeeded.size} tasks, ${failed.size} failed: ${firstError ?: ""}" + } + _uiState.update { it.copy(toastMessage = msg) } reloadBoardSilently() + onComplete?.invoke(failed) } is NetworkResult.Failure -> { @@ -421,11 +677,54 @@ class KanbanViewModel( toastMessage = "Bulk assign failed: ${res.error.message}", ) } + onComplete?.invoke(taskIds.toSet()) } } } } + fun bulkDelete( + taskIds: List, + onComplete: ((failedIds: Set) -> Unit)? = null, + ) { + val board = _uiState.value.selectedBoard ?: return + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + val succeeded = mutableSetOf() + val failed = mutableSetOf() + var firstError: String? = null + + taskIds.forEach { taskId -> + when (val res = repository.deleteTask(taskId = taskId, board = board.id)) { + is NetworkResult.Success -> { + succeeded.add(taskId) + } + + is NetworkResult.Failure -> { + failed.add(taskId) + if (firstError == null) firstError = res.error.message + } + } + } + + val msg = + when { + failed.isEmpty() -> "Deleted ${succeeded.size} tasks" + succeeded.isEmpty() -> "Failed to delete tasks: ${firstError ?: "unknown error"}" + else -> "Deleted ${succeeded.size} tasks, ${failed.size} failed: ${firstError ?: ""}" + } + _uiState.update { it.copy(toastMessage = msg) } + reloadBoardSilently() + onComplete?.invoke(failed) + } + } + + suspend fun exportBoard(slug: String): NetworkResult = + repository.exportBoard(slug, ExportBoardBody(output = "")) + + suspend fun importBoard(serverArchive: String): NetworkResult = + repository.importBoard(ImportBoardBody(archive = serverArchive)) + fun moveTask( task: KanbanTask, action: KanbanTaskAction, @@ -576,7 +875,11 @@ class KanbanViewModel( val board = _uiState.value.selectedBoard ?: return val gen = currentLoadGen viewModelScope.launch { - val result = repository.getBoard(board = board.id) + val result = + repository.getBoard( + board = board.id, + includeArchived = _uiState.value.includeArchived, + ) if (gen != currentLoadGen) return@launch if (result is NetworkResult.Success) { val body = result.data @@ -598,7 +901,11 @@ class KanbanViewModel( board: KanbanBoard, gen: Int = currentLoadGen, ) { - val result = repository.getBoard(board = board.id) + val result = + repository.getBoard( + board = board.id, + includeArchived = _uiState.value.includeArchived, + ) if (gen != currentLoadGen) return when (result) { is NetworkResult.Success -> { @@ -632,6 +939,10 @@ class KanbanViewModel( } } + fun showToast(message: String) { + _uiState.update { it.copy(toastMessage = message) } + } + override fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } 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 new file mode 100644 index 00000000..907b8f0f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanBoardSettingsDialog.kt @@ -0,0 +1,155 @@ +package com.m57.hermescontrol.ui.kanban.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.KanbanBoard +import com.m57.hermescontrol.data.model.KanbanProject +import com.m57.hermescontrol.data.model.RenameBoardBody + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun KanbanBoardSettingsDialog( + board: KanbanBoard, + projects: List, + onSave: (RenameBoardBody) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var name by remember(board) { mutableStateOf(board.name ?: "") } + var description by remember(board) { mutableStateOf(board.description ?: "") } + var defaultWorkdir by remember(board) { mutableStateOf(board.defaultWorkdir ?: "") } + var selectedProjectId by remember(board) { mutableStateOf(board.projectId) } + + var projectExpanded by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { Text(stringResource(R.string.kanban_board_settings)) }, + text = { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + OutlinedTextField( + value = name, + onValueChange = { name = it }, + label = { Text("Board Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("Description") }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + ) + + // Project Selector + ExposedDropdownMenuBox( + expanded = projectExpanded, + onExpandedChange = { projectExpanded = it }, + ) { + val displayProject = + projects.find { it.id == selectedProjectId }?.name + ?: stringResource(R.string.kanban_no_project) + + OutlinedTextField( + value = displayProject, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.kanban_project)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = projectExpanded) }, + modifier = + Modifier + .menuAnchor( + ExposedDropdownMenuAnchorType.PrimaryNotEditable, + ).fillMaxWidth(), + ) + ExposedDropdownMenu( + expanded = projectExpanded, + onDismissRequest = { projectExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.kanban_no_project)) }, + onClick = { + selectedProjectId = null + projectExpanded = false + }, + ) + projects.forEach { proj -> + DropdownMenuItem( + text = { Text(proj.name) }, + onClick = { + selectedProjectId = proj.id + projectExpanded = false + }, + ) + } + } + } + + OutlinedTextField( + value = defaultWorkdir, + onValueChange = { defaultWorkdir = it }, + label = { Text("Default Workdir") }, + placeholder = { Text("e.g. /home/user/project") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } + }, + confirmButton = { + Button( + onClick = { + onSave( + RenameBoardBody( + name = name.trim().ifBlank { null }, + description = description.trim().ifBlank { null }, + defaultWorkdir = defaultWorkdir.trim().ifBlank { null }, + projectId = selectedProjectId ?: "", + ), + ) + onDismiss() + }, + enabled = name.isNotBlank(), + ) { + Text(stringResource(R.string.action_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} 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 f2a3ed35..d8586762 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 @@ -29,6 +29,7 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -49,8 +50,12 @@ import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.KanbanColumn import com.m57.hermescontrol.data.model.KanbanProfile import com.m57.hermescontrol.data.model.KanbanTask +import com.m57.hermescontrol.data.model.ModelProvider +import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.model.TaskEstimate import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState +import com.m57.hermescontrol.ui.kanban.KanbanModelOverride +import com.m57.hermescontrol.ui.kanban.KanbanStatusConstraints import kotlinx.coroutines.launch private const val PARKED_VALUE = "__parked__" @@ -66,13 +71,20 @@ fun KanbanCreateTaskDialog( onDismiss: () -> Unit, onConfirm: (body: CreateTaskBody, targetStatus: String) -> Unit, modifier: Modifier = Modifier, + defaultAssignee: String? = null, + boardDefaultWorkspaceKind: String? = null, + boardDefaultWorkdir: String? = null, + modelProviders: List = emptyList(), + pinnedModels: List = emptyList(), onEstimate: (suspend (title: String, body: String?) -> TaskEstimate?)? = null, ) { var title by remember { mutableStateOf("") } var desc by remember { mutableStateOf("") } val descTextFieldState = rememberSyncedTextFieldState(desc) { desc = it } var selectedColumn by remember(defaultColumn) { mutableStateOf(defaultColumn) } - var selectedAssignee by remember { mutableStateOf(null) } + var selectedAssignee by remember(defaultAssignee) { + mutableStateOf(defaultAssignee ?: profiles.firstOrNull()?.name ?: "default") + } var priority by remember { mutableIntStateOf(0) } var goalMode by remember { mutableStateOf(false) } @@ -84,16 +96,22 @@ fun KanbanCreateTaskDialog( // Advanced options var showAdvanced by remember { mutableStateOf(false) } var parentTaskId by remember { mutableStateOf(null) } - var modelOverride by remember { mutableStateOf("") } - var providerOverride by remember { mutableStateOf("") } - var reasoningEffort by remember { mutableStateOf(null) } - var workspaceKind by remember { mutableStateOf(null) } + var modelOverride by remember { mutableStateOf(KanbanModelOverride.EMPTY) } + var workspaceKind by remember(boardDefaultWorkspaceKind) { + mutableStateOf(boardDefaultWorkspaceKind ?: "scratch") + } + var workspacePath by remember { mutableStateOf("") } var skillsText by remember { mutableStateOf("") } var assigneeExpanded by remember { mutableStateOf(false) } var columnExpanded by remember { mutableStateOf(false) } var parentExpanded by remember { mutableStateOf(false) } + val writableColumns = + remember(columns) { + columns.filter { KanbanStatusConstraints.isUserWritableTarget(it.name) } + } + AlertDialog( onDismissRequest = { if (!isCreating) onDismiss() }, modifier = modifier, @@ -127,7 +145,7 @@ fun KanbanCreateTaskDialog( Spacer(modifier = Modifier.height(12.dp)) // Target Column selector - if (columns.isNotEmpty()) { + if (writableColumns.isNotEmpty()) { ExposedDropdownMenuBox( expanded = columnExpanded, onExpandedChange = { if (!isCreating) columnExpanded = it }, @@ -149,7 +167,7 @@ fun KanbanCreateTaskDialog( expanded = columnExpanded, onDismissRequest = { columnExpanded = false }, ) { - columns.forEach { col -> + writableColumns.forEach { col -> DropdownMenuItem( text = { Text(col.name.uppercase()) }, onClick = { @@ -169,10 +187,22 @@ fun KanbanCreateTaskDialog( onExpandedChange = { if (!isCreating) assigneeExpanded = it }, ) { val displayAssignee = - when (selectedAssignee) { - null -> stringResource(R.string.kanban_assignee_unassigned) - PARKED_VALUE -> stringResource(R.string.kanban_assignee_parked) - else -> selectedAssignee ?: "" + when { + selectedAssignee == PARKED_VALUE -> { + stringResource(R.string.kanban_assignee_parked) + } + + selectedAssignee == defaultAssignee && !defaultAssignee.isNullOrBlank() -> { + "$defaultAssignee (Default)" + } + + selectedAssignee.isNullOrBlank() -> { + stringResource(R.string.kanban_assignee_unassigned) + } + + else -> { + selectedAssignee + } } OutlinedTextField( value = displayAssignee, @@ -187,18 +217,12 @@ fun KanbanCreateTaskDialog( expanded = assigneeExpanded, onDismissRequest = { assigneeExpanded = false }, ) { - DropdownMenuItem( - text = { Text(stringResource(R.string.kanban_assignee_unassigned)) }, - onClick = { - selectedAssignee = null - assigneeExpanded = false - }, - ) profiles.forEach { profile -> + val isDef = profile.name == defaultAssignee DropdownMenuItem( text = { Column { - Text(profile.name) + Text(if (isDef) "${profile.name} (Default)" else profile.name) if (profile.model != null) { Text( profile.model, @@ -258,11 +282,7 @@ fun KanbanCreateTaskDialog( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, - modifier = - Modifier - .fillMaxWidth() - .clickable(enabled = !isCreating) { goalMode = !goalMode } - .padding(vertical = 4.dp), + modifier = Modifier.fillMaxWidth(), ) { Column(modifier = Modifier.weight(1f)) { Text( @@ -271,11 +291,10 @@ fun KanbanCreateTaskDialog( ) Text( text = stringResource(R.string.kanban_goal_mode_desc), - style = MaterialTheme.typography.labelSmall, + style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Spacer(modifier = Modifier.width(8.dp)) Switch( checked = goalMode, onCheckedChange = { goalMode = it }, @@ -286,34 +305,42 @@ fun KanbanCreateTaskDialog( Spacer(modifier = Modifier.height(8.dp)) // Advanced Options Toggle - TextButton( - onClick = { showAdvanced = !showAdvanced }, - modifier = Modifier.align(Alignment.Start), + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .clickable { showAdvanced = !showAdvanced } + .padding(vertical = 8.dp), ) { + Text( + text = stringResource(R.string.kanban_advanced_options), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.weight(1f), + ) Icon( imageVector = if (showAdvanced) Icons.Default.ExpandLess else Icons.Default.ExpandMore, contentDescription = null, - modifier = Modifier.size(18.dp), + tint = MaterialTheme.colorScheme.primary, ) - Spacer(modifier = Modifier.width(4.dp)) - Text(stringResource(R.string.kanban_advanced_options)) } AnimatedVisibility(visible = showAdvanced) { - Column(modifier = Modifier.fillMaxWidth()) { + Column { // Parent Task if (existingTasks.isNotEmpty()) { ExposedDropdownMenuBox( expanded = parentExpanded, onExpandedChange = { if (!isCreating) parentExpanded = it }, ) { - val displayParent = - existingTasks.find { it.id == parentTaskId }?.let { - "#${it.id.removePrefix("t_").take(6)} • ${it.title}" + val parentDisplay = + parentTaskId?.let { pid -> + existingTasks.find { it.id == pid }?.title ?: pid } ?: stringResource(R.string.kanban_parent_none) OutlinedTextField( - value = displayParent, + value = parentDisplay, onValueChange = {}, readOnly = true, label = { Text(stringResource(R.string.kanban_parent_task)) }, @@ -342,7 +369,12 @@ fun KanbanCreateTaskDialog( ) existingTasks.forEach { task -> DropdownMenuItem( - text = { Text("#${task.id.removePrefix("t_").take(6)} • ${task.title}") }, + text = { + Text( + task.title, + maxLines = 1, + ) + }, onClick = { parentTaskId = task.id parentExpanded = false @@ -354,60 +386,17 @@ fun KanbanCreateTaskDialog( Spacer(modifier = Modifier.height(8.dp)) } - // Model Override - OutlinedTextField( - value = modelOverride, - onValueChange = { modelOverride = it }, - label = { Text(stringResource(R.string.kanban_model_override)) }, - placeholder = { Text("e.g. claude-3-5-sonnet") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, + // Model Override via shared ModelPickerDialog + KanbanModelOverrideEditor( + override = modelOverride, + onOverrideChange = { modelOverride = it }, + modelProviders = modelProviders, + pinnedModels = pinnedModels, enabled = !isCreating, ) Spacer(modifier = Modifier.height(8.dp)) - // Provider Override - OutlinedTextField( - value = providerOverride, - onValueChange = { providerOverride = it }, - label = { Text(stringResource(R.string.kanban_provider_override)) }, - placeholder = { Text("e.g. anthropic") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - enabled = !isCreating, - ) - - Spacer(modifier = Modifier.height(8.dp)) - - // Reasoning Effort - Text( - text = stringResource(R.string.kanban_reasoning_effort), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(modifier = Modifier.height(4.dp)) - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), - ) { - listOf( - null to "Inherit", - "low" to "Low", - "medium" to "Medium", - "high" to "High", - ).forEach { (effort, label) -> - FilterChip( - selected = reasoningEffort == effort, - onClick = { reasoningEffort = effort }, - label = { Text(label) }, - enabled = !isCreating, - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - // Workspace Kind Text( text = stringResource(R.string.kanban_workspace_kind), @@ -420,7 +409,7 @@ fun KanbanCreateTaskDialog( modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), ) { listOf( - null to "Scratch (Default)", + "scratch" to "Scratch (Default)", "worktree" to "Worktree", "dir" to "Dir", ).forEach { (kind, label) -> @@ -433,6 +422,25 @@ fun KanbanCreateTaskDialog( } } + if (workspaceKind == "worktree" || workspaceKind == "dir") { + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = workspacePath, + onValueChange = { workspacePath = it }, + label = { Text(stringResource(R.string.kanban_workspace_path)) }, + placeholder = { + if (!boardDefaultWorkdir.isNullOrBlank()) { + Text(boardDefaultWorkdir) + } else { + Text(stringResource(R.string.kanban_workspace_path_hint)) + } + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isCreating, + ) + } + Spacer(modifier = Modifier.height(8.dp)) // Skills @@ -448,40 +456,57 @@ fun KanbanCreateTaskDialog( } } + // Estimation Section if (onEstimate != null) { Spacer(modifier = Modifier.height(12.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, + if (estimate != null) { + estimate?.let { est -> + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + ) { + Text( + text = "Estimated Tokens: ${est.tokens ?: "N/A"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + if (!est.complexity.isNullOrBlank()) { + Text( + text = "Complexity: ${est.complexity}", + style = MaterialTheme.typography.bodySmall, + ) + } + if (!est.rationale.isNullOrBlank()) { + Text( + text = est.rationale, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + OutlinedButton( + onClick = { + if (title.isNotBlank() && !isEstimating) { + isEstimating = true + coroutineScope.launch { + val res = onEstimate(title.trim(), desc.trim().ifBlank { null }) + estimate = res + isEstimating = false + } + } + }, + enabled = title.isNotBlank() && !isEstimating && !isCreating, modifier = Modifier.fillMaxWidth(), ) { - if (estimate != null && estimate?.ok == true) { - Text( - text = "~${estimate?.estTokens ?: 0} tokens · ${estimate?.complexity ?: "normal"}", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary, - ) - } else { - Spacer(modifier = Modifier.weight(1f)) - } - TextButton( - onClick = { - if (title.isNotBlank() && !isEstimating) { - coroutineScope.launch { - isEstimating = true - estimate = onEstimate(title, desc.ifBlank { null }) - isEstimating = false - } - } - }, - enabled = title.isNotBlank() && !isEstimating && !isCreating, - ) { - if (isEstimating) { - CircularProgressIndicator(modifier = Modifier.size(12.dp), strokeWidth = 1.5.dp) - Spacer(modifier = Modifier.width(4.dp)) - } - Text(if (estimate != null) "Re-estimate" else "Estimate tokens") + if (isEstimating) { + CircularProgressIndicator(modifier = Modifier.size(12.dp), strokeWidth = 1.5.dp) + Spacer(modifier = Modifier.width(4.dp)) } + Text(if (estimate != null) "Re-estimate" else "Estimate tokens") } } } @@ -497,6 +522,23 @@ fun KanbanCreateTaskDialog( .filter { it.isNotEmpty() } .takeIf { it.isNotEmpty() } + val finalWorkspaceKind = if (workspaceKind == "scratch") null else workspaceKind + val finalWorkspacePath = + if (finalWorkspaceKind != null && workspacePath.isNotBlank()) { + workspacePath.trim() + } else { + null + } + + val finalModel = modelOverride.model.trim().ifBlank { null } + val finalProvider = + if (finalModel != null && modelOverride.provider.isNotBlank()) { + modelOverride.provider.trim() + } else { + null + } + val finalEffort = modelOverride.effort.trim().ifBlank { null } + val body = CreateTaskBody( title = title.trim(), @@ -505,10 +547,11 @@ fun KanbanCreateTaskDialog( priority = priority, goalMode = goalMode, parents = parentTaskId?.let { listOf(it) } ?: emptyList(), - modelOverride = modelOverride.trim().ifBlank { null }, - providerOverride = providerOverride.trim().ifBlank { null }, - reasoningEffort = reasoningEffort, - workspaceKind = workspaceKind, + modelOverride = finalModel, + providerOverride = finalProvider, + reasoningEffort = finalEffort, + workspaceKind = finalWorkspaceKind, + workspacePath = finalWorkspacePath, skills = skillsList, triage = selectedColumn.equals("triage", ignoreCase = true), ) 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 new file mode 100644 index 00000000..c04d3f7e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanModelOverrideEditor.kt @@ -0,0 +1,166 @@ +package com.m57.hermescontrol.ui.kanban.components + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +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.model.components.ModelPickerDialog + +@Composable +fun KanbanModelOverrideEditor( + override: KanbanModelOverride, + onOverrideChange: (KanbanModelOverride) -> Unit, + modelProviders: List, + pinnedModels: List = emptyList(), + enabled: Boolean = true, + modifier: Modifier = Modifier, +) { + var showPickerDialog by remember { mutableStateOf(false) } + + Column(modifier = modifier.fillMaxWidth()) { + Text( + text = stringResource(R.string.kanban_model_override), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + + OutlinedCard( + onClick = { if (enabled) showPickerDialog = true }, + modifier = Modifier.fillMaxWidth(), + enabled = enabled, + ) { + Row( + modifier = + Modifier + .fillMaxWidth() + .height(48.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .weight(1f) + .padding(horizontal = 12.dp), + ) { + Icon( + imageVector = Icons.Default.Tune, + contentDescription = null, + modifier = Modifier.size(18.dp), + tint = + if (override.isInherited) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.primary + }, + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = override.displayLabel(stringResource(R.string.kanban_model_inherit)), + style = MaterialTheme.typography.bodyMedium, + color = + if (override.isInherited) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + MaterialTheme.colorScheme.onSurface + }, + maxLines = 1, + ) + } + + if (!override.isInherited && enabled) { + IconButton( + onClick = { onOverrideChange(override.copy(model = "", provider = "")) }, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.kanban_model_clear), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringResource(R.string.kanban_reasoning_effort), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = + Modifier + .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)) + } + }, + label = { Text(label) }, + enabled = enabled, + ) + } + } + } + + if (showPickerDialog) { + ModelPickerDialog( + providers = modelProviders, + title = stringResource(R.string.kanban_select_model), + pinnedModels = pinnedModels, + onSelect = { provider, model -> + onOverrideChange(override.copy(provider = provider, model = model)) + 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 new file mode 100644 index 00000000..29dc7929 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanOrchestrationDialog.kt @@ -0,0 +1,324 @@ +package com.m57.hermescontrol.ui.kanban.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AutoAwesome +import androidx.compose.material.icons.filled.Save +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.KanbanProfile +import com.m57.hermescontrol.data.model.OrchestrationSettings +import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun KanbanOrchestrationDialog( + settings: OrchestrationSettings?, + profiles: List, + onSaveSettings: (OrchestrationSettingsUpdate) -> Unit, + onSaveProfileDescription: (profile: String, description: String) -> Unit, + onAutoDescribe: (profile: String, onComplete: (String?) -> Unit) -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, +) { + var orchestratorProfile by remember(settings) { + mutableStateOf(settings?.orchestratorProfile ?: "") + } + var defaultAssignee by remember(settings) { + mutableStateOf(settings?.defaultAssignee ?: "") + } + var autoDecompose by remember(settings) { + mutableStateOf(settings?.autoDecompose ?: true) + } + + var orchestratorExpanded by remember { mutableStateOf(false) } + var defaultAssigneeExpanded by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + modifier = modifier, + title = { Text(stringResource(R.string.kanban_orchestration_settings)) }, + text = { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Orchestrator Profile + ExposedDropdownMenuBox( + expanded = orchestratorExpanded, + onExpandedChange = { orchestratorExpanded = it }, + ) { + val display = + if (orchestratorProfile.isBlank()) { + stringResource(R.string.kanban_assignee_unassigned) + } else { + orchestratorProfile + } + OutlinedTextField( + value = display, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.kanban_orchestrator_profile)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = orchestratorExpanded) }, + modifier = + Modifier + .menuAnchor( + ExposedDropdownMenuAnchorType.PrimaryNotEditable, + ).fillMaxWidth(), + ) + ExposedDropdownMenu( + expanded = orchestratorExpanded, + onDismissRequest = { orchestratorExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.kanban_assignee_unassigned)) }, + onClick = { + orchestratorProfile = "" + orchestratorExpanded = false + }, + ) + profiles.forEach { p -> + DropdownMenuItem( + text = { Text(p.name) }, + onClick = { + orchestratorProfile = p.name + orchestratorExpanded = false + }, + ) + } + } + } + + // Default Assignee + ExposedDropdownMenuBox( + expanded = defaultAssigneeExpanded, + onExpandedChange = { defaultAssigneeExpanded = it }, + ) { + val display = + if (defaultAssignee.isBlank()) { + stringResource(R.string.kanban_assignee_unassigned) + } else { + defaultAssignee + } + OutlinedTextField( + value = display, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.kanban_default_assignee)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = defaultAssigneeExpanded) }, + modifier = + Modifier + .menuAnchor( + ExposedDropdownMenuAnchorType.PrimaryNotEditable, + ).fillMaxWidth(), + ) + ExposedDropdownMenu( + expanded = defaultAssigneeExpanded, + onDismissRequest = { defaultAssigneeExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.kanban_assignee_unassigned)) }, + onClick = { + defaultAssignee = "" + defaultAssigneeExpanded = false + }, + ) + profiles.forEach { p -> + DropdownMenuItem( + text = { Text(p.name) }, + onClick = { + defaultAssignee = p.name + defaultAssigneeExpanded = false + }, + ) + } + } + } + + // Auto-decompose Switch + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.kanban_auto_decompose), + style = MaterialTheme.typography.bodyMedium, + ) + Switch( + checked = autoDecompose, + onCheckedChange = { autoDecompose = it }, + ) + } + + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + + // Profile Descriptions + Text( + text = stringResource(R.string.kanban_profile_descriptions), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + + profiles.forEach { profile -> + ProfileDescriptionEditor( + profile = profile, + onSave = { onSaveProfileDescription(profile.name, it) }, + onAutoDescribe = { cb -> onAutoDescribe(profile.name, cb) }, + ) + } + } + }, + confirmButton = { + Button( + onClick = { + onSaveSettings( + OrchestrationSettingsUpdate( + orchestratorProfile = orchestratorProfile, + defaultAssignee = defaultAssignee, + autoDecompose = autoDecompose, + ), + ) + onDismiss() + }, + ) { + Text(stringResource(R.string.action_save)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} + +@Composable +private fun ProfileDescriptionEditor( + profile: KanbanProfile, + onSave: (String) -> Unit, + onAutoDescribe: ((String?) -> Unit) -> Unit, + modifier: Modifier = Modifier, +) { + var draft by remember(profile.description) { mutableStateOf(profile.description) } + var isDescribing by remember { mutableStateOf(false) } + + OutlinedCard(modifier = modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(10.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = profile.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + if (profile.isDefault) { + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = "(Default)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + + Row(verticalAlignment = Alignment.CenterVertically) { + if (isDescribing) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + } else { + IconButton( + onClick = { + isDescribing = true + onAutoDescribe { desc -> + isDescribing = false + if (desc != null) draft = desc + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Default.AutoAwesome, + contentDescription = stringResource(R.string.kanban_auto_describe), + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + IconButton( + onClick = { onSave(draft.trim()) }, + enabled = draft.trim() != profile.description.trim(), + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Default.Save, + contentDescription = stringResource(R.string.action_save), + modifier = Modifier.size(16.dp), + tint = + if (draft.trim() != profile.description.trim()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + }, + ) + } + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + maxLines = 4, + placeholder = { Text("Profile description for routing") }, + ) + } + } +} 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 959f6ab3..0f725509 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 @@ -37,6 +37,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.m57.hermescontrol.data.model.KanbanTask import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.kanban.KanbanArcState +import com.m57.hermescontrol.ui.kanban.KanbanRuntimeHelper @Composable fun KanbanTaskCard( @@ -44,6 +46,7 @@ fun KanbanTaskCard( onTaskClick: (KanbanTask) -> Unit, modifier: Modifier = Modifier, isSelected: Boolean = false, + defaultAssignee: String? = null, onActionClick: ((KanbanTask) -> Unit)? = null, ) { val statusColors = LocalHermesStatusColors.current @@ -57,6 +60,10 @@ fun KanbanTaskCard( else -> MaterialTheme.colorScheme.outline } + val arcState = KanbanRuntimeHelper.arcState(task, defaultAssignee) + val isWontRun = KanbanRuntimeHelper.isWontRun(task, defaultAssignee) + val elapsed = KanbanRuntimeHelper.formatElapsed(task.startedAt) + val cardColors = if (isSelected) { CardDefaults.cardColors( @@ -92,6 +99,37 @@ fun KanbanTaskCard( fontWeight = FontWeight.Bold, color = statusTone, ) + when { + arcState == KanbanArcState.RUNNING -> { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "· ${elapsed ?: "working"}", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = statusColors.success, + ) + } + + arcState == KanbanArcState.STALE -> { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "· STALE", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = statusColors.warning, + ) + } + + isWontRun -> { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "· WON'T RUN", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = statusColors.error, + ) + } + } } Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -145,7 +183,14 @@ fun KanbanTaskCard( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { - task.assignee?.let { assignee -> + val displayAssignee = + task.assignee + ?: if (arcState == KanbanArcState.QUEUED && !defaultAssignee.isNullOrBlank()) { + "$defaultAssignee (Default)" + } else { + null + } + displayAssignee?.let { assignee -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(end = 8.dp), From f533477bd97a67f5c63fc7922e8c785e4223e047 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:15:14 +0300 Subject: [PATCH 04/12] feat(kanban): localize board settings, runtime and task controls (#1137) --- app/src/main/res/values-ar/strings.xml | 66 ++++++++++++++++++++- app/src/main/res/values-ko/strings.xml | 81 ++++++++++++++++++++++++++ app/src/main/res/values-zh/strings.xml | 64 ++++++++++++++++++++ app/src/main/res/values/strings.xml | 45 ++++++++++++++ 4 files changed, 255 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 79f016d9..aa5a2bc9 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -318,7 +318,71 @@ مباشر غير متصل لا توجد مهام -إضافة + الأولوية + 0 (عادي) + 1 (متوسط) + 2 (مرتفع) + 3 (عاجل) + المسؤول + افتراضي (غير معيَّن) + موقوف (بدون عامل) + وضع الهدف الذاتي + يتحقق القاضي من اكتمال المهمة عبر تكرارات متعددة + خيارات متقدمة + المهمة الأصلية + لا يوجد + تجاوز النموذج + تجاوز المزود + جهد الاستنتاج + نوع مساحة العمل + تجاوز مسار مساحة العمل + مسار اختياري (الافتراضي مجلد المشروع) + المهارات + العمود + وراثة الملف الشخصي + مسح تجاوز النموذج + اختيار النموذج + تلقائي + معطل + منخفض + متوسط + مرتفع + نطاق المشروع + بدون مشروع + أرشفة اللوحة + هل أنت متأكد من أرشفة اللوحة \'%1$s\'؟ يمكنك استعادتها لاحقًا من اللوحات المؤرشفة. + إعدادات اللوحة + إعدادات التنسيق + الملف المنسق + المسؤول الافتراضي + تفكيك تلقائي + أوصاف الملفات الشخصية + وصف تلقائي + الارتباطات والتبعيات + محظورة بواسطة + تحظر + تقدير الجهد + تقدير الجهد + إعادة التقدير + الرموز المقدرة: %1$d + التعقيد: %1$s + السبب والمنطق + حذف المهمة + هل أنت متأكد من حذف هذه المهمة؟ لا يمكن التراجع عن هذا الإجراء. + تعيين + إلغاء التعيين + حذف + هل تريد حذف %1$d مهمة محددة؟ لا يمكن التراجع عن هذا الإجراء. + تصدير اللوحة + استيراد لوحة + قيد التشغيل + متوقف (لا توجد إشارة) + لن تعمل: غير معيّنة + غير معيّن + توسيع %1$s + طي %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 d9bf59f6..5ae97979 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -299,6 +299,87 @@ 새 작업 추가 작업 제목 작업 설명 + 작업 추가 + 분류 + 대기 + 차단 + 차단 해제 + 완료 + 보관 + 이 작업을 차단하시겠습니까? 워커 점유가 해제됩니다. + 이 작업을 완료하시겠습니까? 워커 점유가 해제됩니다. + 이 작업을 보관하시겠습니까? 기본 보드 뷰에서 숨겨집니다. + 작업 완료 + 완료 요약 + 작업 결과로 저장됩니다 + 이동 실패: %1$s + 실시간 + 오프라인 + 작업 없음 + 우선순위 + 0 (일반) + 1 (중간) + 2 (높음) + 3 (긴급) + 담당자 + 기본 (미지정) + 보류 (워커 없음) + 자율 목표 모드 + 판정자가 다회차 반복 작업을 통해 완료 여부를 검증합니다 + 고급 옵션 + 상위 작업 + 없음 + 모델 오버라이드 + 공급자 오버라이드 + 추론 강도 + 작업공간 종류 + 작업공간 경로 오버라이드 + 선택적 경로 (기본값: 프로젝트 디렉터리) + 스킬 + 컬럼 + 프로필 기본값 상속 + 모델 오버라이드 초기화 + 모델 선택 + 상속 + 비활성화 + 낮음 + 중간 + 높음 + 프로젝트 범위 + 프로젝트 없음 + 보드 보관 + \'%1$s\' 보드를 보관하시겠습니까? 나중에 보관된 보드에서 복구할 수 있습니다. + 보드 설정 + 오케스트레이션 설정 + 오케스트레이터 프로필 + 기본 담당자 + 자동 분해 + 프로필 설명 + 자동 설명 생성 + 종속성 + 차단됨 (상위) + 차단함 (하위) + 노력 추정 + 노력 추정 + 재추정 + 예상 토큰: %1$d + 복잡도: %1$s + 근거 + 작업 삭제 + 이 작업을 삭제하시겠습니까? 이 동작은 되돌릴 수 없습니다. + 담당자 지정 + 담당자 해제 + 삭제 + 선택한 %1$d개 작업을 삭제하시겠습니까? 이 동작은 되돌릴 수 없습니다. + 보드 내보내기 + 보드 가져오기 + 실행 중 + 응답 없음 (하트비트 지연) + 실행 안 됨: 담당자 미지정 + 미지정 + %1$s 펼치기 + %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 e28fcf93..9dfb6330 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -317,6 +317,70 @@ 实时 离线 暂无任务 + 优先级 + 0 (普通) + 1 (中等) + 2 (高) + 3 (紧急) + 负责人 + 默认 (未指定) + 暂挂 (无 worker) + 自主目标模式 + 由评判者在多轮迭代中验证任务是否完成 + 高级选项 + 父任务 + 无 + 覆盖模型 + 覆盖提供商 + 推理强度 + 工作区类型 + 覆盖工作区路径 + 可选路径(默认为项目目录) + 技能 + 列 + 继承配置默认模型 + 清除模型覆盖 + 选择模型 + 继承 + 禁用 + 低 + 中 + 高 + 项目范围 + 无项目 + 归档看板 + 确定要归档看板 \'%1$s\' 吗?稍后可从已归档看板中恢复。 + 看板设置 + 编排设置 + 编排配置文件 + 默认负责人 + 自动分解 + 配置文件描述 + 自动生成描述 + 依赖关系 + 阻塞于 (父级) + 阻塞 (子级) + 工作量评估 + 评估工作量 + 重新评估 + 预计 Token:%1$d + 复杂度:%1$s + 评估依据 + 删除任务 + 确定要删除此任务吗?此操作无法撤销。 + 分配负责人 + 取消分配 + 删除 + 确定要删除所选的 %1$d 个任务吗?此操作无法撤销。 + 导出看板 + 导入看板 + 运行中 + 无响应 (心跳超时) + 无法运行:未指定负责人 + 未分配 + 展开 %1$s + 折叠 %1$s + 添加任务到 %1$s 添加 全部状态 已启用 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c1c469a1..59775a15 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -370,8 +370,53 @@ Provider Override Reasoning Effort Workspace Kind + Workspace Path Override + Optional path (defaults to project dir) Skills Column + Inherit profile + Clear model override + Select Model + Inherit + None + Low + Medium + High + 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 + Orchestration Settings + Orchestrator Profile + Default Assignee + Auto-decompose + Profile Descriptions + Auto-describe + Dependencies + Blocked by + Blocks + Effort Estimation + Estimate effort + Re-estimate + Tokens: %1$d + Complexity: %1$s + Rationale + Delete Task + Are you sure you want to delete this task? This cannot be undone. + Assign + Unassign + Delete + Delete %1$d selected tasks? This cannot be undone. + Export Board + Import Board + Working + Stale (no heartbeat) + Won\'t run: unassigned + Unassigned + Expand %1$s + Collapse %1$s + Add task to %1$s Add All Statuses From 190a93a4cf5ec52219d0b546d43c9b70c3005783 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:15:36 +0300 Subject: [PATCH 05/12] test(kanban): add payload, event formatter, runtime and instrumentation tests (#1137) --- .../ui/kanban/KanbanScreenTest.kt | 102 +++++++++ .../kanban/KanbanColumnCollapseHelperTest.kt | 63 ++++++ .../ui/kanban/KanbanCreateTaskPayloadTest.kt | 174 ++++++++++++++++ .../ui/kanban/KanbanDispatcherNudgerTest.kt | 89 ++++++++ .../ui/kanban/KanbanEventFormatterTest.kt | 197 ++++++++++++++++++ .../ui/kanban/KanbanLaneGroupingTest.kt | 50 +++++ .../ui/kanban/KanbanModelOverrideTest.kt | 66 ++++++ .../ui/kanban/KanbanMutationTest.kt | 104 ++++++++- .../ui/kanban/KanbanRuntimeHelperTest.kt | 71 +++++++ .../ui/kanban/KanbanTaskViewModelTest.kt | 92 +++++++- 10 files changed, 1001 insertions(+), 7 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanScreenTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanColumnCollapseHelperTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanCreateTaskPayloadTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanDispatcherNudgerTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanEventFormatterTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanLaneGroupingTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanModelOverrideTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanRuntimeHelperTest.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 new file mode 100644 index 00000000..3a7a6d27 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanScreenTest.kt @@ -0,0 +1,102 @@ +package com.m57.hermescontrol.ui.kanban + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.KanbanColumn +import com.m57.hermescontrol.data.model.KanbanProfile +import com.m57.hermescontrol.data.model.KanbanTask +import com.m57.hermescontrol.theme.HermesControlTheme +import com.m57.hermescontrol.ui.kanban.components.KanbanCreateTaskDialog +import com.m57.hermescontrol.ui.kanban.components.KanbanFilterSheet +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class KanbanScreenTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testShowArchivedToggleInvokesCallback() { + var toggled = false + composeTestRule.setContent { + HermesControlTheme { + KanbanFilterSheet( + assignees = emptyList(), + tenants = emptyList(), + selectedAssignee = null, + selectedTenant = null, + includeArchived = false, + groupRunning = false, + onSelectAssignee = {}, + onSelectTenant = {}, + onToggleIncludeArchived = { toggled = it }, + onToggleGroupRunning = {}, + onClearFilters = {}, + onDismiss = {}, + ) + } + } + + composeTestRule.onNodeWithText("Show archived").performClick() + assertTrue(toggled) + } + + @Test + fun testWorkspacePathVisibilityInCreateTaskDialog() { + composeTestRule.setContent { + HermesControlTheme { + KanbanCreateTaskDialog( + columns = listOf(KanbanColumn(name = "todo")), + defaultColumn = "todo", + profiles = listOf(KanbanProfile(name = "researcher")), + existingTasks = emptyList(), + isCreating = false, + onDismiss = {}, + onConfirm = { _, _ -> }, + ) + } + } + + // Expand advanced options + composeTestRule.onNodeWithText("Advanced Options").performClick() + + // By default scratch is selected, workspace path is not visible + composeTestRule.onNodeWithText("Workspace Path Override").assertDoesNotExist() + + // Select Worktree + composeTestRule.onNodeWithText("Worktree").performClick() + composeTestRule.onNodeWithText("Workspace Path Override").assertIsDisplayed() + + // Select Scratch again + composeTestRule.onNodeWithText("Scratch (Default)").performClick() + composeTestRule.onNodeWithText("Workspace Path Override").assertDoesNotExist() + } + + @Test + fun testDefaultAssigneeOptionPresent() { + composeTestRule.setContent { + HermesControlTheme { + KanbanCreateTaskDialog( + columns = listOf(KanbanColumn(name = "todo")), + defaultColumn = "todo", + profiles = listOf(KanbanProfile(name = "researcher")), + existingTasks = emptyList(), + isCreating = false, + defaultAssignee = "researcher", + onDismiss = {}, + onConfirm = { _, _ -> }, + ) + } + } + + composeTestRule.onNodeWithText("researcher (Default)").assertIsDisplayed() + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanColumnCollapseHelperTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanColumnCollapseHelperTest.kt new file mode 100644 index 00000000..51b7b6de --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanColumnCollapseHelperTest.kt @@ -0,0 +1,63 @@ +package com.m57.hermescontrol.ui.kanban + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class KanbanColumnCollapseHelperTest { + @Test + fun testBoardWithoutWorkDoesNotCollapse() { + assertFalse( + KanbanColumnCollapseHelper.isColumnCollapsed( + columnName = "todo", + columnTaskCount = 0, + boardHasWork = false, + overrides = emptyMap(), + ), + ) + } + + @Test + fun testBoardWithWorkAutoCollapsesEmptyColumn() { + assertTrue( + KanbanColumnCollapseHelper.isColumnCollapsed( + columnName = "todo", + columnTaskCount = 0, + boardHasWork = true, + overrides = emptyMap(), + ), + ) + + assertFalse( + KanbanColumnCollapseHelper.isColumnCollapsed( + columnName = "ready", + columnTaskCount = 3, + boardHasWork = true, + overrides = emptyMap(), + ), + ) + } + + @Test + fun testManualOverrideTakesPrecedence() { + // Force expand an empty column + assertFalse( + KanbanColumnCollapseHelper.isColumnCollapsed( + columnName = "todo", + columnTaskCount = 0, + boardHasWork = true, + overrides = mapOf("todo" to false), + ), + ) + + // Force collapse an occupied column + assertTrue( + KanbanColumnCollapseHelper.isColumnCollapsed( + columnName = "ready", + columnTaskCount = 3, + boardHasWork = true, + overrides = mapOf("ready" to true), + ), + ) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanCreateTaskPayloadTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanCreateTaskPayloadTest.kt new file mode 100644 index 00000000..648ef50b --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanCreateTaskPayloadTest.kt @@ -0,0 +1,174 @@ +package com.m57.hermescontrol.ui.kanban + +import com.m57.hermescontrol.data.model.CreateTaskBody +import com.m57.hermescontrol.data.model.CreateTaskResponse +import com.m57.hermescontrol.data.model.KanbanBoard +import com.m57.hermescontrol.data.model.KanbanBoardResponse +import com.m57.hermescontrol.data.model.KanbanColumn +import com.m57.hermescontrol.data.model.KanbanTask +import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.data.repository.KanbanRepository +import com.m57.hermescontrol.data.ws.KanbanEventsClient +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class KanbanCreateTaskPayloadTest { + private val testDispatcher = StandardTestDispatcher() + private val mockRepository = mockk(relaxed = true) + private val mockEventsClient = mockk(relaxed = true) + + private fun createViewModel(): KanbanViewModel = + KanbanViewModel( + repository = mockRepository, + eventsClientProvider = { mockEventsClient }, + ) + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + @Test + fun testCreateTaskPayloadWithAllFields() = + runTest(testDispatcher) { + val board = KanbanBoard(id = "dev", name = "Dev") + val newTask = KanbanTask(id = "t_100", title = "Full task", status = "todo") + + coEvery { mockRepository.getBoard("dev", any(), any()) } returns + NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList())))) + coEvery { mockRepository.createTask("dev", any()) } returns + NetworkResult.Success(CreateTaskResponse(task = newTask)) + + val vm = createViewModel() + vm.selectBoard(board) + testDispatcher.scheduler.advanceUntilIdle() + + val body = + CreateTaskBody( + title = "Full task", + body = "Rich details", + assignee = "researcher", + priority = 2, + goalMode = true, + parents = listOf("p_1", "p_2"), + modelOverride = "claude-3-5-sonnet", + providerOverride = "anthropic", + reasoningEffort = "high", + workspaceKind = "worktree", + workspacePath = "/custom/wt/path", + skills = listOf("git", "testing"), + triage = true, + ) + + vm.createTask(body, targetStatus = "triage") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { + mockRepository.createTask( + "dev", + match { + it.title == "Full task" && + it.body == "Rich details" && + it.assignee == "researcher" && + it.priority == 2 && + it.goalMode && + it.parents == listOf("p_1", "p_2") && + it.modelOverride == "claude-3-5-sonnet" && + it.providerOverride == "anthropic" && + it.reasoningEffort == "high" && + it.workspaceKind == "worktree" && + it.workspacePath == "/custom/wt/path" && + it.skills == listOf("git", "testing") && + it.triage + }, + ) + } + } + + @Test + fun testScratchWorkspaceNeverSendsPath() = + runTest(testDispatcher) { + val board = KanbanBoard(id = "dev", name = "Dev") + coEvery { mockRepository.getBoard("dev", any(), any()) } returns + NetworkResult.Success(KanbanBoardResponse(columns = emptyList())) + coEvery { mockRepository.createTask("dev", any()) } returns + NetworkResult.Success( + CreateTaskResponse(task = KanbanTask(id = "t_1", title = "Task", status = "todo")), + ) + + val vm = createViewModel() + vm.selectBoard(board) + testDispatcher.scheduler.advanceUntilIdle() + + // In our dialog logic, scratch sets workspaceKind = null (or scratch) and workspacePath = null + val body = + CreateTaskBody( + title = "Scratch Task", + workspaceKind = null, + workspacePath = null, + ) + + vm.createTask(body) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { + mockRepository.createTask( + "dev", + match { + it.workspaceKind == null && it.workspacePath == null + }, + ) + } + } + + @Test + fun testParkedAssigneeSendsNull() = + runTest(testDispatcher) { + val board = KanbanBoard(id = "dev", name = "Dev") + coEvery { mockRepository.getBoard("dev", any(), any()) } returns + NetworkResult.Success(KanbanBoardResponse(columns = emptyList())) + coEvery { mockRepository.createTask("dev", any()) } returns + NetworkResult.Success( + CreateTaskResponse(task = KanbanTask(id = "t_1", title = "Task", status = "todo")), + ) + + val vm = createViewModel() + vm.selectBoard(board) + testDispatcher.scheduler.advanceUntilIdle() + + val body = + CreateTaskBody( + title = "Parked Task", + assignee = null, // Parked + ) + + vm.createTask(body) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { + mockRepository.createTask( + "dev", + match { it.assignee == null }, + ) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanDispatcherNudgerTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanDispatcherNudgerTest.kt new file mode 100644 index 00000000..7f68a1bd --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanDispatcherNudgerTest.kt @@ -0,0 +1,89 @@ +package com.m57.hermescontrol.ui.kanban + +import com.m57.hermescontrol.data.model.DispatchResult +import com.m57.hermescontrol.data.remote.KanbanApiService +import com.m57.hermescontrol.data.repository.DefaultKanbanDispatcherNudger +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.junit.Test +import retrofit2.Response + +@OptIn(ExperimentalCoroutinesApi::class) +class KanbanDispatcherNudgerTest { + private val testDispatcher = StandardTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val mockApi = mockk() + + @Test + fun testNudgeDebouncingSchedulesSingleCall() = + testScope.runTest { + coEvery { mockApi.nudgeDispatcher(any()) } returns Response.success(DispatchResult()) + + val nudger = + DefaultKanbanDispatcherNudger( + apiProvider = { mockApi }, + scope = this, + debounceMs = 400L, + ) + + nudger.scheduleNudge("board-a") + advanceTimeBy(100L) + nudger.scheduleNudge("board-a") + advanceTimeBy(100L) + nudger.scheduleNudge("board-a") + + // Before 400ms passes from last call + advanceTimeBy(300L) + coVerify(exactly = 0) { mockApi.nudgeDispatcher("board-a") } + + // After 400ms passes + advanceTimeBy(150L) + coVerify(exactly = 1) { mockApi.nudgeDispatcher("board-a") } + } + + @Test + fun testNudgeScopedPerBoard() = + testScope.runTest { + coEvery { mockApi.nudgeDispatcher(any()) } returns Response.success(DispatchResult()) + + val nudger = + DefaultKanbanDispatcherNudger( + apiProvider = { mockApi }, + scope = this, + debounceMs = 400L, + ) + + nudger.scheduleNudge("board-a") + nudger.scheduleNudge("board-b") + + advanceTimeBy(450L) + + coVerify(exactly = 1) { mockApi.nudgeDispatcher("board-a") } + coVerify(exactly = 1) { mockApi.nudgeDispatcher("board-b") } + } + + @Test + fun testNudgeFailureAbsorbedSilently() = + testScope.runTest { + coEvery { mockApi.nudgeDispatcher(any()) } throws RuntimeException("Network timeout") + + val nudger = + DefaultKanbanDispatcherNudger( + apiProvider = { mockApi }, + scope = this, + debounceMs = 100L, + ) + + nudger.scheduleNudge("board-a") + advanceTimeBy(150L) + + coVerify(exactly = 1) { mockApi.nudgeDispatcher("board-a") } + // No unhandled exception thrown + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanEventFormatterTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanEventFormatterTest.kt new file mode 100644 index 00000000..c882cc1f --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanEventFormatterTest.kt @@ -0,0 +1,197 @@ +package com.m57.hermescontrol.ui.kanban + +import com.m57.hermescontrol.data.model.KanbanDetailEvent +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class KanbanEventFormatterTest { + @Test + fun testCreatedEvent() { + val payload = + buildJsonObject { + put("status", "ready") + put("assignee", "coder") + } + val event = KanbanDetailEvent(id = 1, kind = "created", payload = payload, createdAt = 1000) + val formatted = KanbanEventFormatter.format(event) + assertEquals("Created in Ready by coder", formatted.label) + assertNull(formatted.detail) + } + + @Test + fun testStatusEventWithReason() { + val payload = + buildJsonObject { + put("status", "done") + put("reason", "completed successfully") + } + val event = KanbanDetailEvent(id = 2, kind = "status", payload = payload, createdAt = 1000) + val formatted = KanbanEventFormatter.format(event) + assertEquals("Moved to Done", formatted.label) + assertEquals("completed successfully", formatted.detail) + } + + @Test + fun testParentReopenedStatusEvent() { + val payload = + buildJsonObject { + put("status", "todo") + put("reason", "parent_reopened") + put("parent", "t_parent1") + } + val event = KanbanDetailEvent(id = 3, kind = "status", payload = payload, createdAt = 1000) + val formatted = KanbanEventFormatter.format(event) + assertEquals("Moved to Todo", formatted.label) + assertEquals("Parent t_parent1 reopened", formatted.detail) + } + + @Test + fun testAssignedEvent() { + val payload = buildJsonObject { put("assignee", "reviewer") } + val event = KanbanDetailEvent(id = 4, kind = "assigned", payload = payload, createdAt = 1000) + val formatted = KanbanEventFormatter.format(event) + assertEquals("Assigned to reviewer", formatted.label) + + val unassignedEvent = KanbanDetailEvent(id = 5, kind = "assigned", payload = null, createdAt = 1000) + assertEquals("Unassigned", KanbanEventFormatter.format(unassignedEvent).label) + } + + @Test + fun testClaimedReviewAndWorker() { + val reviewClaim = + KanbanDetailEvent( + id = 6, + kind = "claimed", + payload = buildJsonObject { put("source_status", "review") }, + createdAt = 1000, + ) + assertEquals("Claimed for review", KanbanEventFormatter.format(reviewClaim).label) + + val workerClaim = + KanbanDetailEvent( + id = 7, + kind = "claimed", + payload = buildJsonObject { put("source_status", "ready") }, + createdAt = 1000, + ) + assertEquals("Claimed by worker", KanbanEventFormatter.format(workerClaim).label) + } + + @Test + fun testSpawnedEvent() { + val event = + KanbanDetailEvent( + id = 8, + kind = "spawned", + payload = buildJsonObject { put("pid", 12345) }, + createdAt = 1000, + ) + val formatted = KanbanEventFormatter.format(event) + assertEquals("Worker started", formatted.label) + assertEquals("PID 12345", formatted.detail) + } + + @Test + fun testLifecycleEvents() { + assertEquals("Completed", KanbanEventFormatter.format(KanbanDetailEvent(9, "completed", null, 1000)).label) + assertEquals( + "Blocked", + KanbanEventFormatter + .format( + KanbanDetailEvent( + 10, + "blocked", + buildJsonObject { put("reason", "waiting on CI") }, + 1000, + ), + ).label, + ) + assertEquals( + "Unblocked (Ready)", + KanbanEventFormatter + .format( + KanbanDetailEvent( + 11, + "unblocked", + buildJsonObject { put("status", "ready") }, + 1000, + ), + ).label, + ) + assertEquals( + "Reclaimed", + KanbanEventFormatter + .format( + KanbanDetailEvent( + 12, + "reclaimed", + buildJsonObject { put("reason", "timeout") }, + 1000, + ), + ).label, + ) + assertEquals("Specified", KanbanEventFormatter.format(KanbanDetailEvent(13, "specified", null, 1000)).label) + assertEquals( + "Promoted to ready", + KanbanEventFormatter.format(KanbanDetailEvent(14, "promoted", null, 1000)).label, + ) + assertEquals("Archived", KanbanEventFormatter.format(KanbanDetailEvent(15, "archived", null, 1000)).label) + assertEquals( + "Priority set to 2", + KanbanEventFormatter + .format( + KanbanDetailEvent( + 16, + "reprioritized", + buildJsonObject { put("priority", 2) }, + 1000, + ), + ).label, + ) + } + + @Test + fun testUnknownEventWithScalarPayload() { + val payload = + buildJsonObject { + put("foo", "bar") + put("count", 42) + } + val event = KanbanDetailEvent(id = 17, kind = "custom_hook", payload = payload, createdAt = 1000) + val formatted = KanbanEventFormatter.format(event) + assertEquals("Custom hook", formatted.label) + assertEquals("foo=bar count=42", formatted.detail) + } + + @Test + fun testStringJsonPayload() { + val event = + KanbanDetailEvent( + id = 18, + kind = "status", + payload = JsonPrimitive("{\"status\":\"blocked\",\"reason\":\"rate limit\"}"), + createdAt = 1000, + ) + val formatted = KanbanEventFormatter.format(event) + assertEquals("Moved to Blocked", formatted.label) + assertEquals("rate limit", formatted.detail) + } + + @Test + fun testMalformedPayloadGraceful() { + val event = + KanbanDetailEvent( + id = 19, + kind = "custom_test", + payload = JsonPrimitive("{not valid json"), + createdAt = 1000, + ) + val formatted = KanbanEventFormatter.format(event) + assertEquals("Custom test", formatted.label) + assertEquals("raw={not valid json", formatted.detail) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanLaneGroupingTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanLaneGroupingTest.kt new file mode 100644 index 00000000..a033a109 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanLaneGroupingTest.kt @@ -0,0 +1,50 @@ +package com.m57.hermescontrol.ui.kanban + +import com.m57.hermescontrol.data.model.KanbanTask +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class KanbanLaneGroupingTest { + @Test + fun testGroupRunningTasksSeparatesProfilesDeterministically() { + val tasks = + listOf( + KanbanTask(id = "t1", title = "Task 1", status = "running", assignee = "reviewer"), + KanbanTask(id = "t2", title = "Task 2", status = "running", assignee = "coder"), + KanbanTask(id = "t3", title = "Task 3", status = "running", assignee = null), + KanbanTask(id = "t4", title = "Task 4", status = "running", assignee = "coder"), + ) + + val groups = KanbanLaneGrouping.groupRunningTasks(tasks) + + // Coder first (alphabetical), then Reviewer, then Unassigned + assertEquals(3, groups.size) + + assertEquals("coder", groups[0].assignee) + assertEquals(listOf("t2", "t4"), groups[0].tasks.map { it.id }) + + assertEquals("reviewer", groups[1].assignee) + assertEquals(listOf("t1"), groups[1].tasks.map { it.id }) + + assertNull(groups[2].assignee) + assertEquals(listOf("t3"), groups[2].tasks.map { it.id }) + } + + @Test + fun testGroupRunningTasksWithoutUnassigned() { + val tasks = + listOf( + KanbanTask(id = "t1", title = "Task 1", status = "running", assignee = "coder"), + ) + val groups = KanbanLaneGrouping.groupRunningTasks(tasks) + assertEquals(1, groups.size) + assertEquals("coder", groups[0].assignee) + } + + @Test + fun testGroupRunningTasksEmpty() { + val groups = KanbanLaneGrouping.groupRunningTasks(emptyList()) + assertEquals(0, groups.size) + } +} 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 new file mode 100644 index 00000000..4d67d94a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanModelOverrideTest.kt @@ -0,0 +1,66 @@ +package com.m57.hermescontrol.ui.kanban + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class KanbanModelOverrideTest { + @Test + fun testEmptyOverrideIsInherited() { + val override = KanbanModelOverride.EMPTY + assertTrue(override.isInherited) + assertEquals("Inherit profile", override.displayLabel("Inherit profile")) + + val patch = override.toUpdateTaskBody() + assertNull(patch.modelOverride) + assertNull(patch.providerOverride) + assertTrue(patch.clearModelOverride) + assertNull(patch.reasoningEffort) + assertTrue(patch.clearReasoningEffort) + } + + @Test + fun testCustomModelAndEffortPatch() { + val override = + KanbanModelOverride( + model = "claude-3-5-sonnet", + provider = "anthropic", + effort = "high", + ) + assertFalse(override.isInherited) + assertEquals("anthropic: claude-3-5-sonnet · High", override.displayLabel()) + + val patch = override.toUpdateTaskBody() + assertEquals("claude-3-5-sonnet", patch.modelOverride) + assertEquals("anthropic", patch.providerOverride) + assertFalse(patch.clearModelOverride) + assertEquals("high", patch.reasoningEffort) + assertFalse(patch.clearReasoningEffort) + } + + @Test + fun testExplicitNoneReasoningEffort() { + val override = + KanbanModelOverride( + model = "gpt-4o", + provider = "openai", + effort = "none", + ) + assertEquals("openai: gpt-4o · None", override.displayLabel()) + + val patch = override.toUpdateTaskBody() + assertEquals("none", patch.reasoningEffort) + assertFalse(patch.clearReasoningEffort) + } + + @Test + fun testModelClearedLeavesEffortIntact() { + val override = KanbanModelOverride(model = "", provider = "", effort = "low") + val patch = override.toUpdateTaskBody() + assertTrue(patch.clearModelOverride) + assertEquals("low", patch.reasoningEffort) + assertFalse(patch.clearReasoningEffort) + } +} 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 fa9d51ab..83a031e3 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 @@ -393,15 +393,15 @@ class KanbanMutationTest { coVerify { mockRepository.updateBoard("dev", match { it.name == "Dev Renamed" }) } - assertEquals("Board renamed to Dev Renamed", vm.uiState.value.toastMessage) + assertEquals("Board updated: Dev Renamed", vm.uiState.value.toastMessage) } @Test fun testDeleteBoardSuccess() = runTest(testDispatcher) { - coEvery { mockRepository.deleteBoard("old-board", delete = true) } returns + coEvery { mockRepository.deleteBoard("old-board", delete = false) } returns NetworkResult.Success(DeleteBoardResponse(current = null)) - coEvery { mockRepository.getBoards() } returns + coEvery { mockRepository.getBoards(any()) } returns NetworkResult.Success( com.m57.hermescontrol.data.model .KanbanBoardsResponse(emptyList(), null), @@ -411,8 +411,102 @@ class KanbanMutationTest { vm.deleteBoard("old-board") testDispatcher.scheduler.advanceUntilIdle() - coVerify { mockRepository.deleteBoard("old-board", delete = true) } - assertEquals("Board deleted", vm.uiState.value.toastMessage) + coVerify { mockRepository.deleteBoard("old-board", delete = false) } + assertEquals("Board archived", vm.uiState.value.toastMessage) + } + + @Test + fun testDeleteDefaultBoardRefused() = + runTest(testDispatcher) { + val vm = createViewModel() + vm.deleteBoard("default") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify(exactly = 0) { mockRepository.deleteBoard(any(), any()) } + assertTrue( + vm.uiState.value.toastMessage + ?.contains("default board") == true, + ) + } + + @Test + fun testSetIncludeArchivedTogglesAndReloadsBoard() = + runTest(testDispatcher) { + val board = KanbanBoard(id = "dev", name = "Dev") + coEvery { mockRepository.getBoard(board = "dev", includeArchived = any(), any()) } returns + NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList())))) + + val vm = createViewModel() + vm.selectBoard(board) + testDispatcher.scheduler.advanceUntilIdle() + + vm.setIncludeArchived(true) + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(vm.uiState.value.includeArchived) + coVerify { mockRepository.getBoard(board = "dev", includeArchived = true, any()) } + + vm.setIncludeArchived(false) + testDispatcher.scheduler.advanceUntilIdle() + + assertFalse(vm.uiState.value.includeArchived) + coVerify { mockRepository.getBoard(board = "dev", includeArchived = false, any()) } + } + + @Test + fun testBulkMovePartialFailureKeepsFailedIds() = + runTest(testDispatcher) { + val board = KanbanBoard(id = "dev", name = "Dev") + coEvery { mockRepository.getBoard("dev", any(), any()) } returns + NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("done", emptyList())))) + coEvery { mockRepository.bulkTasks("dev", any()) } returns + NetworkResult.Success( + BulkTasksResponse( + listOf( + BulkTaskResult("t_1", true), + BulkTaskResult("t_2", false, "Conflict"), + ), + ), + ) + + val vm = createViewModel() + vm.selectBoard(board) + testDispatcher.scheduler.advanceUntilIdle() + + var reportedFailed: Set? = null + vm.bulkMove(listOf("t_1", "t_2"), "done") { failed -> + reportedFailed = failed + } + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(setOf("t_2"), reportedFailed) + assertTrue( + vm.uiState.value.toastMessage + ?.contains("1 failed") == true, + ) + } + + @Test + fun testCreateBoardWithProject() = + runTest(testDispatcher) { + val board = KanbanBoard(id = "scoped", name = "Scoped", projectId = "proj-123") + coEvery { mockRepository.createBoard(any()) } returns + NetworkResult.Success(CreateBoardResponse(board = board)) + coEvery { mockRepository.getBoards(any()) } returns + NetworkResult.Success( + com.m57.hermescontrol.data.model + .KanbanBoardsResponse(listOf(board), "scoped"), + ) + + val vm = createViewModel() + vm.createBoard(slug = "scoped", name = "Scoped", projectId = "proj-123") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { + mockRepository.createBoard( + match { it.slug == "scoped" && it.projectId == "proj-123" }, + ) + } } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanRuntimeHelperTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanRuntimeHelperTest.kt new file mode 100644 index 00000000..5acafe4c --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanRuntimeHelperTest.kt @@ -0,0 +1,71 @@ +package com.m57.hermescontrol.ui.kanban + +import com.m57.hermescontrol.data.model.KanbanTask +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class KanbanRuntimeHelperTest { + @Test + fun testRunningTaskWithFreshHeartbeat() { + val now = 1000L + val task = KanbanTask(id = "t1", title = "Task 1", status = "running", lastHeartbeatAt = now - 30) + val state = KanbanRuntimeHelper.arcState(task, nowSeconds = now) + assertEquals(KanbanArcState.RUNNING, state) + } + + @Test + fun testRunningTaskWithStaleHeartbeat() { + val now = 1000L + val task = KanbanTask(id = "t1", title = "Task 1", status = "running", lastHeartbeatAt = now - 150) + val state = KanbanRuntimeHelper.arcState(task, nowSeconds = now) + assertEquals(KanbanArcState.STALE, state) + } + + @Test + fun testQueuedTask() { + val triageTask = KanbanTask(id = "t1", title = "Task 1", status = "triage") + assertEquals(KanbanArcState.QUEUED, KanbanRuntimeHelper.arcState(triageTask)) + + val reviewTask = KanbanTask(id = "t2", title = "Task 2", status = "review") + assertEquals(KanbanArcState.QUEUED, KanbanRuntimeHelper.arcState(reviewTask)) + + val readyAssigned = KanbanTask(id = "t3", title = "Task 3", status = "ready", assignee = "coder") + assertEquals(KanbanArcState.QUEUED, KanbanRuntimeHelper.arcState(readyAssigned)) + + val readyWithFallback = KanbanTask(id = "t4", title = "Task 4", status = "ready", assignee = null) + assertEquals( + KanbanArcState.QUEUED, + KanbanRuntimeHelper.arcState(readyWithFallback, fallbackAssignee = "default"), + ) + + val readyUnassigned = KanbanTask(id = "t5", title = "Task 5", status = "ready", assignee = null) + assertNull(KanbanRuntimeHelper.arcState(readyUnassigned, fallbackAssignee = null)) + } + + @Test + fun testIsWontRun() { + val readyUnassigned = KanbanTask(id = "t1", title = "Task 1", status = "ready", assignee = null) + assertTrue(KanbanRuntimeHelper.isWontRun(readyUnassigned, fallbackAssignee = null)) + assertFalse(KanbanRuntimeHelper.isWontRun(readyUnassigned, fallbackAssignee = "default")) + + val readyAssigned = KanbanTask(id = "t2", title = "Task 2", status = "ready", assignee = "coder") + assertFalse(KanbanRuntimeHelper.isWontRun(readyAssigned, fallbackAssignee = null)) + + val todoUnassigned = KanbanTask(id = "t3", title = "Task 3", status = "todo", assignee = null) + assertFalse(KanbanRuntimeHelper.isWontRun(todoUnassigned)) + } + + @Test + fun testFormatElapsed() { + val now = 200_000L + assertEquals("45s", KanbanRuntimeHelper.formatElapsed(now - 45, nowSeconds = now)) + assertEquals("5m", KanbanRuntimeHelper.formatElapsed(now - 300, nowSeconds = now)) + assertEquals("2h", KanbanRuntimeHelper.formatElapsed(now - 7200, nowSeconds = now)) + assertEquals("1d", KanbanRuntimeHelper.formatElapsed(now - 90000, nowSeconds = now)) + assertNull(KanbanRuntimeHelper.formatElapsed(null)) + assertNull(KanbanRuntimeHelper.formatElapsed(now + 10, nowSeconds = now)) + } +} 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 fd6c612a..780ea755 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 @@ -8,6 +8,7 @@ import com.m57.hermescontrol.data.model.UpdateTaskResponse import com.m57.hermescontrol.data.model.WorkerLog import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.repository.KanbanRepository +import com.m57.hermescontrol.data.ws.KanbanEventsClient import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -28,10 +29,12 @@ import org.junit.Test class KanbanTaskViewModelTest { private val testDispatcher = StandardTestDispatcher() private val mockRepository = mockk(relaxed = true) + private val mockEventsClient = mockk(relaxed = true) private fun createViewModel(): KanbanTaskViewModel = KanbanTaskViewModel( repository = mockRepository, + eventsClientProvider = { mockEventsClient }, ) @Before @@ -75,7 +78,7 @@ class KanbanTaskViewModelTest { val vm = createViewModel() vm.loadTask("dev", "t_1") - testDispatcher.scheduler.advanceUntilIdle() + testDispatcher.scheduler.runCurrent() assertEquals( "Log line 1", @@ -83,6 +86,7 @@ class KanbanTaskViewModelTest { ?.content, ) coVerify { mockRepository.getTaskLog("t_1", "dev", any()) } + vm.onCleared() } @Test @@ -133,7 +137,7 @@ class KanbanTaskViewModelTest { @Test fun testPostNoteAndRequeue() = runTest(testDispatcher) { - val task = KanbanTaskFull(id = "t_1", title = "Running task", status = "running") + val task = KanbanTaskFull(id = "t_1", title = "Task 1", status = "todo") coEvery { mockRepository.getTask("t_1", "dev") } returns NetworkResult.Success(KanbanTaskDetailResponse(task = task)) coEvery { mockRepository.addComment("t_1", "dev", "Stop now") } returns @@ -197,4 +201,88 @@ class KanbanTaskViewModelTest { coVerify { mockRepository.uploadAttachment("t_1", "dev", any()) } assertEquals("Attachment uploaded", vm.uiState.value.toastMessage) } + + @Test + fun testUpdateModelOverride() = + runTest(testDispatcher) { + val task = KanbanTaskFull(id = "t_1", title = "Task 1", status = "todo") + coEvery { mockRepository.getTask("t_1", "dev") } returns + NetworkResult.Success(KanbanTaskDetailResponse(task = task)) + coEvery { mockRepository.updateTask("t_1", "dev", any()) } returns + NetworkResult.Success(UpdateTaskResponse()) + + val vm = createViewModel() + vm.loadTask("dev", "t_1") + testDispatcher.scheduler.advanceUntilIdle() + + val override = KanbanModelOverride(model = "claude-3-5-sonnet", provider = "anthropic", effort = "high") + vm.updateModelOverride("dev", "t_1", override) + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { + mockRepository.updateTask( + "t_1", + "dev", + match { + it.modelOverride == "claude-3-5-sonnet" && + it.providerOverride == "anthropic" && + it.reasoningEffort == "high" && + !it.clearModelOverride + }, + ) + } + assertEquals("Model override updated", vm.uiState.value.toastMessage) + } + + @Test + fun testEstimateTask() = + runTest(testDispatcher) { + val task = KanbanTaskFull(id = "t_1", title = "Task 1", status = "todo") + coEvery { mockRepository.getTask("t_1", "dev") } returns + NetworkResult.Success(KanbanTaskDetailResponse(task = task)) + coEvery { mockRepository.estimateTask("t_1", "dev") } returns + NetworkResult.Success( + com.m57.hermescontrol.data.model.TaskEstimate( + ok = true, + estTokens = 1500, + complexity = "medium", + ), + ) + + val vm = createViewModel() + vm.loadTask("dev", "t_1") + testDispatcher.scheduler.advanceUntilIdle() + + vm.estimateTask("dev", "t_1") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { mockRepository.estimateTask("t_1", "dev") } + assertEquals( + 1500, + vm.uiState.value.estimate + ?.tokens, + ) + assertEquals( + "medium", + vm.uiState.value.estimate + ?.complexity, + ) + } + + @Test + fun testDeleteTask() = + runTest(testDispatcher) { + coEvery { mockRepository.deleteTask("t_1", "dev") } returns NetworkResult.Success(Unit) + + val vm = createViewModel() + var deleted = false + vm.deleteTask("dev", "t_1") { + deleted = true + } + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { mockRepository.deleteTask("t_1", "dev") } + assertTrue(deleted) + assertEquals("Task deleted", vm.uiState.value.toastMessage) + } } 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 06/12] 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) + } +} From d357d604c720fce2fa9ce172d392dad82da61885 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:56:51 +0300 Subject: [PATCH 07/12] fix(kanban): localize activity event labels (#1137) --- .../ui/kanban/KanbanEventFormatter.kt | 76 +++++++++++++--- .../ui/kanban/KanbanTaskScreen.kt | 90 ++++++++++++++++++- app/src/main/res/values-ar/strings.xml | 19 ++++ app/src/main/res/values-ko/strings.xml | 19 ++++ app/src/main/res/values-zh/strings.xml | 19 ++++ app/src/main/res/values/strings.xml | 19 ++++ 6 files changed, 230 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanEventFormatter.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanEventFormatter.kt index 6aaf5164..0a90454e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanEventFormatter.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanEventFormatter.kt @@ -6,9 +6,33 @@ import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +enum class KanbanEventMessage { + CREATED, + CREATED_BY, + MOVED, + ASSIGNED, + UNASSIGNED, + COMMENTED, + CLAIMED_REVIEW, + CLAIMED_WORKER, + WORKER_STARTED, + COMPLETED, + BLOCKED, + UNBLOCKED, + RECLAIMED, + SPECIFIED, + PROMOTED, + SCHEDULED, + ARCHIVED, + PRIORITY, + UNKNOWN, +} + data class FormattedKanbanEvent( val label: String, val detail: String? = null, + val message: KanbanEventMessage = KanbanEventMessage.UNKNOWN, + val arguments: List = emptyList(), ) object KanbanEventFormatter { @@ -51,7 +75,11 @@ object KanbanEventFormatter { val status = col("status") ?: "Todo" val assignee = str("assignee") val label = if (assignee != null) "Created in $status by $assignee" else "Created in $status" - FormattedKanbanEvent(label = label) + FormattedKanbanEvent( + label = label, + message = if (assignee != null) KanbanEventMessage.CREATED_BY else KanbanEventMessage.CREATED, + arguments = listOfNotNull(status, assignee), + ) } "status" -> { @@ -66,6 +94,8 @@ object KanbanEventFormatter { FormattedKanbanEvent( label = "Moved to $targetStatus", detail = detail, + message = KanbanEventMessage.MOVED, + arguments = listOf(targetStatus), ) } @@ -73,18 +103,32 @@ object KanbanEventFormatter { val assignee = str("assignee") FormattedKanbanEvent( label = if (assignee != null) "Assigned to $assignee" else "Unassigned", + message = if (assignee != null) KanbanEventMessage.ASSIGNED else KanbanEventMessage.UNASSIGNED, + arguments = listOfNotNull(assignee), ) } "commented" -> { val author = str("author") ?: "someone" - FormattedKanbanEvent(label = "Comment by $author") + FormattedKanbanEvent( + label = "Comment by $author", + message = KanbanEventMessage.COMMENTED, + arguments = listOf(author), + ) } "claimed" -> { val source = str("source_status") FormattedKanbanEvent( label = if (source == "review") "Claimed for review" else "Claimed by worker", + message = + if (source == + "review" + ) { + KanbanEventMessage.CLAIMED_REVIEW + } else { + KanbanEventMessage.CLAIMED_WORKER + }, ) } @@ -93,49 +137,59 @@ object KanbanEventFormatter { FormattedKanbanEvent( label = "Worker started", detail = if (pid != null && pid != "null") "PID $pid" else null, + message = KanbanEventMessage.WORKER_STARTED, + arguments = listOfNotNull(pid?.takeIf { it != "null" }), ) } "completed" -> { - FormattedKanbanEvent(label = "Completed") + FormattedKanbanEvent(label = "Completed", message = KanbanEventMessage.COMPLETED) } "blocked" -> { val reason = str("reason") - FormattedKanbanEvent(label = "Blocked", detail = reason) + FormattedKanbanEvent(label = "Blocked", detail = reason, message = KanbanEventMessage.BLOCKED) } "unblocked" -> { val status = col("status") val label = if (status != null) "Unblocked ($status)" else "Unblocked" - FormattedKanbanEvent(label = label) + FormattedKanbanEvent( + label = label, + message = KanbanEventMessage.UNBLOCKED, + arguments = listOfNotNull(status), + ) } "reclaimed" -> { val reason = str("reason") - FormattedKanbanEvent(label = "Reclaimed", detail = reason) + FormattedKanbanEvent(label = "Reclaimed", detail = reason, message = KanbanEventMessage.RECLAIMED) } "specified" -> { - FormattedKanbanEvent(label = "Specified") + FormattedKanbanEvent(label = "Specified", message = KanbanEventMessage.SPECIFIED) } "promoted" -> { - FormattedKanbanEvent(label = "Promoted to ready") + FormattedKanbanEvent(label = "Promoted to ready", message = KanbanEventMessage.PROMOTED) } "scheduled" -> { val reason = str("reason") - FormattedKanbanEvent(label = "Scheduled", detail = reason) + FormattedKanbanEvent(label = "Scheduled", detail = reason, message = KanbanEventMessage.SCHEDULED) } "archived" -> { - FormattedKanbanEvent(label = "Archived") + FormattedKanbanEvent(label = "Archived", message = KanbanEventMessage.ARCHIVED) } "reprioritized" -> { val priority = str("priority") ?: payloadMap["priority"]?.toString() ?: "?" - FormattedKanbanEvent(label = "Priority set to $priority") + FormattedKanbanEvent( + label = "Priority set to $priority", + message = KanbanEventMessage.PRIORITY, + arguments = listOf(priority), + ) } else -> { 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 b972a14f..228ad295 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 @@ -882,6 +882,94 @@ private fun TaskRunsTab( } } +@Composable +private fun localizedKanbanEventLabel(event: FormattedKanbanEvent): String = + when (event.message) { + KanbanEventMessage.CREATED -> { + stringResource(R.string.kanban_event_created, event.arguments.getOrElse(0) { "Todo" }) + } + + KanbanEventMessage.CREATED_BY -> { + stringResource( + R.string.kanban_event_created_by, + event.arguments.getOrElse(0) { "Todo" }, + event.arguments.getOrElse(1) { "" }, + ) + } + + KanbanEventMessage.MOVED -> { + stringResource(R.string.kanban_event_moved, event.arguments.getOrElse(0) { "?" }) + } + + KanbanEventMessage.ASSIGNED -> { + stringResource(R.string.kanban_event_assigned, event.arguments.getOrElse(0) { "" }) + } + + KanbanEventMessage.UNASSIGNED -> { + stringResource(R.string.kanban_event_unassigned) + } + + KanbanEventMessage.COMMENTED -> { + stringResource(R.string.kanban_event_commented, event.arguments.getOrElse(0) { "someone" }) + } + + KanbanEventMessage.CLAIMED_REVIEW -> { + stringResource(R.string.kanban_event_claimed_review) + } + + KanbanEventMessage.CLAIMED_WORKER -> { + stringResource(R.string.kanban_event_claimed_worker) + } + + KanbanEventMessage.WORKER_STARTED -> { + stringResource(R.string.kanban_event_worker_started) + } + + KanbanEventMessage.COMPLETED -> { + stringResource(R.string.kanban_event_completed) + } + + KanbanEventMessage.BLOCKED -> { + stringResource(R.string.kanban_event_blocked) + } + + KanbanEventMessage.UNBLOCKED -> { + if (event.arguments.isEmpty()) { + stringResource(R.string.kanban_event_unblocked) + } else { + stringResource(R.string.kanban_event_unblocked_to, event.arguments[0]) + } + } + + KanbanEventMessage.RECLAIMED -> { + stringResource(R.string.kanban_event_reclaimed) + } + + KanbanEventMessage.SPECIFIED -> { + stringResource(R.string.kanban_event_specified) + } + + KanbanEventMessage.PROMOTED -> { + stringResource(R.string.kanban_event_promoted) + } + + KanbanEventMessage.SCHEDULED -> { + stringResource(R.string.kanban_event_scheduled) + } + + KanbanEventMessage.ARCHIVED -> { + stringResource(R.string.kanban_event_archived) + } + + KanbanEventMessage.PRIORITY -> { + stringResource(R.string.kanban_event_priority, event.arguments.getOrElse(0) { "?" }) + } + + KanbanEventMessage.UNKNOWN -> { + event.label + } + } + @Composable private fun TaskActivityTab( events: List, @@ -920,7 +1008,7 @@ private fun TaskActivityTab( Spacer(modifier = Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = formatted.label, + text = localizedKanbanEventLabel(formatted), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.SemiBold, ) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 64f7abeb..bf4e4366 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -432,6 +432,25 @@ عوامل التصفية تأكيد الإجراء تعديل الوصف + أُنشئت في %1$s + أُنشئت في %1$s بواسطة %2$s + نُقلت إلى %1$s + عُيّنت إلى %1$s + غير معيّنة + تعليق بواسطة %1$s + تمت المطالبة للمراجعة + تمت المطالبة بواسطة العامل + بدأ العامل + مكتملة + محظورة + أزيل حظرها + أزيل حظرها (%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 c0b78d96..d9743130 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -430,6 +430,25 @@ 필터 작업 확인 설명 편집 + %1$s에서 생성됨 + %1$s에서 %2$s가 생성함 + %1$s로 이동됨 + %1$s에게 할당됨 + 미지정 + %1$s의 댓글 + 검토를 위해 할당됨 + 워커가 할당함 + 워커 시작됨 + 완료됨 + 차단됨 + 차단 해제됨 + 차단 해제됨 (%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 116b4a8d..6a5b472b 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -431,6 +431,25 @@ 筛选 确认操作 编辑描述 + 创建于 %1$s + 由 %2$s 创建于 %1$s + 移动到 %1$s + 分配给 %1$s + 未分配 + %1$s 的评论 + 已领取进行审核 + 已由工作器领取 + 工作器已启动 + 已完成 + 已阻塞 + 已解除阻塞 + 已解除阻塞(%1$s) + 已回收 + 已指定 + 已提升为就绪 + 已计划 + 已归档 + 优先级设置为 %1$s 临时(默认) 工作树 目录 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 47e97456..e5873dc0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -467,6 +467,25 @@ Filters Confirm action Edit Description + Created in %1$s + Created in %1$s by %2$s + Moved to %1$s + Assigned to %1$s + Unassigned + Comment by %1$s + Claimed for review + Claimed by worker + Worker started + Completed + Blocked + Unblocked + Unblocked (%1$s) + Reclaimed + Specified + Promoted to ready + Scheduled + Archived + Priority set to %1$s Scratch (Default) Worktree Dir From cec2d567e0b4048abf64467ff7d22e119d90d748 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:14:50 +0300 Subject: [PATCH 08/12] fix(kanban): localize task detail tabs and fallback labels (#1137) --- .../hermescontrol/ui/kanban/KanbanTaskScreen.kt | 15 +++++++++++---- app/src/main/res/values-ar/strings.xml | 6 ++++++ app/src/main/res/values-ko/strings.xml | 6 ++++++ app/src/main/res/values-zh/strings.xml | 6 ++++++ app/src/main/res/values/strings.xml | 6 ++++++ 5 files changed, 35 insertions(+), 4 deletions(-) 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 228ad295..98404bea 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 @@ -101,7 +101,14 @@ fun KanbanTaskScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() var selectedTabIndex by remember { mutableIntStateOf(0) } - val tabs = listOf("Overview", "Discussion", "Runs", "Activity", "Files") + val tabs = + listOf( + stringResource(R.string.kanban_tab_overview), + stringResource(R.string.kanban_tab_discussion), + stringResource(R.string.kanban_tab_runs), + stringResource(R.string.kanban_tab_activity), + stringResource(R.string.kanban_tab_files), + ) var showDeleteDialog by remember { mutableStateOf(false) } LaunchedEffect(boardSlug, taskId) { @@ -114,7 +121,7 @@ fun KanbanTaskScreen( title = { Column { Text( - text = state.detail?.task?.title ?: "Task Details", + text = state.detail?.task?.title ?: stringResource(R.string.kanban_task_details), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 1, @@ -379,7 +386,7 @@ private fun TaskOverviewTab( modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), ) { Text( - text = "Assignee", + text = stringResource(R.string.kanban_assignee), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -582,7 +589,7 @@ private fun TaskOverviewTab( modifier = Modifier.fillMaxWidth(), ) { Text( - text = "Description", + text = stringResource(R.string.kanban_description), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index bf4e4366..979a86cf 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -432,6 +432,12 @@ عوامل التصفية تأكيد الإجراء تعديل الوصف + تفاصيل المهمة + نظرة عامة + النقاش + التنفيذات + النشاط + الملفات أُنشئت في %1$s أُنشئت في %1$s بواسطة %2$s نُقلت إلى %1$s diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index d9743130..be497804 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -430,6 +430,12 @@ 필터 작업 확인 설명 편집 + 작업 세부정보 + 개요 + 토론 + 실행 + 활동 + 파일 %1$s에서 생성됨 %1$s에서 %2$s가 생성함 %1$s로 이동됨 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 6a5b472b..8f537e16 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -431,6 +431,12 @@ 筛选 确认操作 编辑描述 + 任务详情 + 概览 + 讨论 + 执行 + 活动 + 文件 创建于 %1$s 由 %2$s 创建于 %1$s 移动到 %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e5873dc0..2059463d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -467,6 +467,12 @@ Filters Confirm action Edit Description + Task Details + Overview + Discussion + Runs + Activity + Files Created in %1$s Created in %1$s by %2$s Moved to %1$s From aa428b80f8e289a4e249e2bc752762bf72b455d2 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:17:45 +0300 Subject: [PATCH 09/12] fix(kanban): compile instrumented regression tests (#1137) --- .../java/com/m57/hermescontrol/ui/kanban/KanbanScreenTest.kt | 1 - 1 file changed, 1 deletion(-) 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 2e88773f..4d7ef777 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,6 +1,5 @@ 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 From 748f7854d2b21dfc05e289b7c3b0841dd1902bbe Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:53:27 +0300 Subject: [PATCH 10/12] fix(kanban): add missing ko filter placeholder translation (#1137) --- app/src/main/res/values-ko/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index be497804..6e41aff4 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -417,6 +417,7 @@ 예: git, coding, tests 라우팅에 사용할 프로필 설명 필터 + 제목, 상태 또는 담당자로 작업 필터링... 보드 옵션 새 보드 지우기 From 6f076a79d3356afadbeb9f7c2b794354a00ed0b7 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:33:57 +0300 Subject: [PATCH 11/12] fix(kanban): preserve transfer cleanup and screen lifecycle (#1137) --- .../ui/kanban/KanbanLifecycleTest.kt | 81 +++++++++++++ .../ui/kanban/KanbanExportState.kt | 10 ++ .../ui/kanban/KanbanImportCleanup.kt | 26 +++++ .../ui/kanban/KanbanRuntimeState.kt | 26 ++++- .../hermescontrol/ui/kanban/KanbanScreen.kt | 80 ++++++++----- .../ui/kanban/KanbanImportCleanupTest.kt | 108 ++++++++++++++++++ 6 files changed, 295 insertions(+), 36 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanLifecycleTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanExportState.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanImportCleanup.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanImportCleanupTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanLifecycleTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanLifecycleTest.kt new file mode 100644 index 00000000..c17eeba3 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/kanban/KanbanLifecycleTest.kt @@ -0,0 +1,81 @@ +package com.m57.hermescontrol.ui.kanban + +import androidx.compose.material3.Text +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.StateRestorationTester +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class KanbanLifecycleTest { + @get:Rule + val compose = createComposeRule() + + @Test + fun pendingExportSurvivesSavedStateRestoration() { + val restoration = StateRestorationTester(compose) + lateinit var path: MutableState + restoration.setContent { + path = rememberPendingKanbanExportPath() + } + compose.runOnIdle { path.value = "/cache/board.tar.gz" } + restoration.emulateSavedInstanceStateRestore() + compose.runOnIdle { assertEquals("/cache/board.tar.gz", path.value) } + } + + @Test + fun tickerStopsInBackgroundRefreshesOnResumeAndStopsOnDisposal() { + lateinit var registry: LifecycleRegistry + val owner = + object : LifecycleOwner { + override val lifecycle: Lifecycle get() = registry + } + compose.runOnUiThread { + registry = LifecycleRegistry(owner) + registry.currentState = Lifecycle.State.STARTED + } + var now = 100L + val visible = mutableStateOf(true) + var displayed = 0L + compose.mainClock.autoAdvance = false + compose.setContent { + CompositionLocalProvider(LocalLifecycleOwner provides owner) { + if (visible.value) { + displayed = rememberKanbanNowSeconds(true) { now } + Text("$displayed") + } + } + } + compose.mainClock.advanceTimeBy(32) + compose.onNodeWithText("100").assertTextEquals("100") + compose.runOnUiThread { now = 105L } + compose.mainClock.advanceTimeBy(5_100) + compose.onNodeWithText("105").assertTextEquals("105") + compose.runOnUiThread { + registry.currentState = Lifecycle.State.CREATED + now = 200L + } + compose.mainClock.advanceTimeBy(10_100) + compose.onNodeWithText("105").assertTextEquals("105") + compose.runOnUiThread { registry.currentState = Lifecycle.State.STARTED } + compose.mainClock.advanceTimeBy(64) + compose.onNodeWithText("200").assertTextEquals("200") + compose.runOnUiThread { visible.value = false } + compose.mainClock.advanceTimeBy(64) + compose.runOnUiThread { now = 300L } + compose.mainClock.advanceTimeBy(10_100) + compose.runOnIdle { assertEquals(200L, displayed) } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanExportState.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanExportState.kt new file mode 100644 index 00000000..aab46350 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanExportState.kt @@ -0,0 +1,10 @@ +package com.m57.hermescontrol.ui.kanban + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable + +/** Keep the cache path while the external document picker recreates the Activity (#1137). */ +@Composable +internal fun rememberPendingKanbanExportPath(): MutableState = rememberSaveable { mutableStateOf(null) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanImportCleanup.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanImportCleanup.kt new file mode 100644 index 00000000..865f6488 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanImportCleanup.kt @@ -0,0 +1,26 @@ +package com.m57.hermescontrol.ui.kanban + +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout + +/** Own only confirmed uploads; cancellation must not strand their temporary archive (#1137). */ +internal suspend fun withKanbanImportCleanup( + cleanup: suspend () -> Unit, + onCleanupFailure: (Exception) -> Unit, + dispatcher: CoroutineDispatcher = Dispatchers.IO, + operation: suspend () -> T, +): T = + try { + operation() + } finally { + withContext(NonCancellable + dispatcher) { + try { + withTimeout(10_000L) { cleanup() } + } catch (e: Exception) { + onCleanupFailure(e) + } + } + } 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 d9acd1f3..e47c5a19 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 @@ -2,8 +2,12 @@ package com.m57.hermescontrol.ui.kanban import androidx.compose.runtime.Composable import androidx.compose.runtime.produceState +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle import com.m57.hermescontrol.data.model.KanbanTask import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive enum class KanbanArcState { QUEUED, @@ -54,13 +58,23 @@ 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, +fun rememberKanbanNowSeconds( + enabled: Boolean, + clock: () -> Long = { System.currentTimeMillis() / 1000L }, +): Long { + val lifecycle = LocalLifecycleOwner.current.lifecycle + return produceState( + initialValue = clock(), key1 = enabled, + key2 = lifecycle, ) { - while (enabled) { - delay(5_000L) - value = System.currentTimeMillis() / 1000L + if (enabled) { + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + while (isActive) { + value = clock() + delay(5_000L) + } + } } }.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 d5274829..d64d5f75 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 @@ -90,13 +90,16 @@ 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.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MultipartBody import okhttp3.RequestBody.Companion.toRequestBody import java.io.File +import kotlin.coroutines.coroutineContext private const val DEFAULT_COLUMN = "todo" @@ -159,26 +162,27 @@ fun KanbanScreen( var showOrchestrationDialog by remember { mutableStateOf(false) } // Board Transfer (Export / Import) - var pendingExportPath by remember { mutableStateOf(null) } + var pendingExportPath by rememberPendingKanbanExportPath() val exportLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("application/gzip"), ) { uri -> - if (uri != null && pendingExportPath != null) { + if (uri != null) { coroutineScope.launch { try { - val cachedFile = File(pendingExportPath!!) - if (cachedFile.exists()) { - 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") + val cachedFile = File(checkNotNull(pendingExportPath) { "No pending board export" }) + withContext(Dispatchers.IO) { + check(cachedFile.isFile) { "Cached board export is missing; export the board again" } + context.contentResolver.openOutputStream(uri)?.use { output -> + cachedFile.inputStream().use { input -> + input.copyTo(output) + } + } ?: error("Unable to open selected destination") } + viewModel.showToast("Board exported successfully") + } catch (e: CancellationException) { + throw e } catch (e: Exception) { viewModel.showToast("Failed to save export: ${e.message}") } finally { @@ -230,39 +234,55 @@ fun KanbanScreen( contentLength = contentLength, ) val filePart = MultipartBody.Part.createFormData("file", fileName, requestBody) + // Retrofit streams the body on OkHttp's worker; avoid losing a confirmed + // response to withContext's prompt cancellation on the return hop (#1137). val uploadResult = - withContext(Dispatchers.IO) { - safeApiCall { - ApiClient.hermesApi.uploadManagedFileStream(pathBody, overwriteBody, filePart) - } + 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() - } + withKanbanImportCleanup( + cleanup = { + val response = + ApiClient.hermesApi.deleteManagedFile( + ManagedFileDelete(serverPath), + ) + check( + response.isSuccessful, + ) { "Archive cleanup failed (HTTP ${response.code()})" } + }, + onCleanupFailure = { + viewModel.showToast("Temporary import archive cleanup failed: ${it.message}") + }, + ) { + val importResult = viewModel.importBoard(serverPath) + coroutineContext.ensureActive() + 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}") + is NetworkResult.Failure -> { + viewModel.showToast("Import failed: ${importResult.error.message}") + } } } - runCatching { - ApiClient.hermesApi.deleteManagedFile(ManagedFileDelete(serverPath)) - } } is NetworkResult.Failure -> { + coroutineContext.ensureActive() viewModel.showToast("Failed to upload archive: ${uploadResult.error.message}") } } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { viewModel.showToast("Import failed: ${e.message}") } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanImportCleanupTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanImportCleanupTest.kt new file mode 100644 index 00000000..13f93af7 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanImportCleanupTest.kt @@ -0,0 +1,108 @@ +package com.m57.hermescontrol.ui.kanban + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class KanbanImportCleanupTest { + @Test + fun cleansAfterSuccess() = + runTest { + var cleaned = false + val result = + withKanbanImportCleanup( + cleanup = { cleaned = true }, + onCleanupFailure = { throw AssertionError(it) }, + dispatcher = StandardTestDispatcher(testScheduler), + ) { "imported" } + assertEquals("imported", result) + assertTrue(cleaned) + } + + @Test + fun cleansAfterImportFailureWithoutMaskingIt() = + runTest { + val failure = IllegalStateException("import failed") + var cleaned = false + try { + withKanbanImportCleanup( + cleanup = { cleaned = true }, + onCleanupFailure = { throw AssertionError(it) }, + dispatcher = StandardTestDispatcher(testScheduler), + ) { throw failure } + } catch (e: IllegalStateException) { + assertSame(failure, e) + } + assertTrue(cleaned) + } + + @Test + fun cancellationWaitsForSuspendingCleanupAndRemainsCancelled() = + runTest { + var cleaned = false + var cancellationPropagated = false + val job = + launch { + try { + withKanbanImportCleanup( + cleanup = { + delay(100) + cleaned = true + }, + onCleanupFailure = { throw AssertionError(it) }, + dispatcher = StandardTestDispatcher(testScheduler), + ) { awaitCancellation() } + } catch (e: CancellationException) { + cancellationPropagated = true + throw e + } + } + runCurrent() + job.cancel() + advanceUntilIdle() + assertTrue(cleaned) + assertTrue(cancellationPropagated) + assertTrue(job.isCancelled) + } + + @Test + fun cleanupFailureIsReportedWithoutMaskingImportResult() = + runTest { + val failure = IllegalStateException("HTTP 500") + var reported: Exception? = null + val result = + withKanbanImportCleanup( + cleanup = { throw failure }, + onCleanupFailure = { reported = it }, + dispatcher = StandardTestDispatcher(testScheduler), + ) { "imported" } + assertEquals("imported", result) + // Coroutine stack-trace recovery can copy exceptions across dispatcher boundaries. + assertEquals(failure.message, reported?.message) + assertTrue(reported is IllegalStateException) + } + + @Test + fun cleanupIsBounded() = + runTest { + var reported = false + withKanbanImportCleanup( + cleanup = { awaitCancellation() }, + onCleanupFailure = { reported = true }, + dispatcher = StandardTestDispatcher(testScheduler), + ) { Unit } + assertTrue(reported) + assertEquals(10_000L, testScheduler.currentTime) + } +} From 1bb91bce65b6d5e81b7593eea33775911ad81622 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Thu, 17 Sep 2026 00:54:53 +0300 Subject: [PATCH 12/12] fix(kanban): scroll workspace row before instrumented clicks (#1137) --- .../com/m57/hermescontrol/ui/kanban/KanbanScreenTest.kt | 4 ++++ .../ui/kanban/components/KanbanCreateTaskDialog.kt | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) 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 4d7ef777..52c65fde 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,6 +1,7 @@ package com.m57.hermescontrol.ui.kanban import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText @@ -74,10 +75,13 @@ class KanbanScreenTest { composeTestRule.onNodeWithTag("kanban_workspace_path").assertDoesNotExist() // Select Worktree + composeTestRule.onNodeWithTag("kanban_workspace_kinds").performScrollTo() composeTestRule.onNodeWithTag("kanban_workspace_kind_worktree").performClick() + composeTestRule.onNodeWithTag("kanban_workspace_kind_worktree").assertIsSelected() composeTestRule.onNodeWithTag("kanban_workspace_path").performScrollTo().assertIsDisplayed() // Select Scratch again + composeTestRule.onNodeWithTag("kanban_workspace_kinds").performScrollTo() composeTestRule.onNodeWithTag("kanban_workspace_kind_scratch").performClick() composeTestRule.onNodeWithTag("kanban_workspace_path").assertDoesNotExist() } 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 dbbc28e2..bc2ff92d 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 @@ -414,7 +414,11 @@ fun KanbanCreateTaskDialog( Spacer(modifier = Modifier.height(4.dp)) Row( horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .testTag("kanban_workspace_kinds"), ) { listOf( "scratch" to stringResource(R.string.kanban_workspace_scratch),