feat(#1220): complete Kanban management operations parity (#1234)

This commit is contained in:
m57 authored and GitHub committed 2026-09-23 12:25:37 +03:00
1 parent 33d87a11ec
commit 1270a08810
23 files changed
+1777 -51

No files matched your search

@@ -22,8 +22,14 @@ data class KanbanDetailEvent(
@Serializable
data class KanbanRun(
val id: Long,
val taskId: String? = null,
val profile: String? = null,
val stepKey: String? = null,
val status: String,
val claimLock: String? = null,
val claimExpires: Long? = null,
val maxRuntimeSeconds: Int? = null,
val lastHeartbeatAt: Long? = null,
val outcome: String? = null,
val summary: String? = null,
val error: String? = null,
@@ -59,3 +65,71 @@ data class KanbanAttachment(
data class AttachmentUploadResponse(
val attachment: KanbanAttachment? = null,
)
@Serializable
data class AttachmentListResponse(
val attachments: List<KanbanAttachment> = emptyList(),
)
@Serializable
data class DeleteAttachmentResponse(
val ok: Boolean,
val id: Long,
)
@Serializable
data class KanbanRunResponse(
val run: KanbanRun,
)
@Serializable
data class KanbanRunInspection(
val runId: Long,
val alive: Boolean,
val pid: Int? = null,
val reason: String? = null,
val cpuPercent: Double? = null,
val memoryRssBytes: Long? = null,
val memoryVmsBytes: Long? = null,
val numThreads: Int? = null,
val numFds: Int? = null,
val status: String? = null,
val createTime: Double? = null,
val cmdline: List<String>? = null,
val error: String? = null,
)
@Serializable
data class TerminateRunBody(
val reason: String? = null,
)
@Serializable
data class TerminateRunResponse(
val ok: Boolean,
val runId: Long,
val taskId: String,
)
@Serializable
data class KanbanActiveWorker(
val runId: Long,
val taskId: String,
val taskTitle: String,
val taskStatus: String,
val taskAssignee: String? = null,
val profile: String? = null,
val workerPid: Int,
val startedAt: Long? = null,
val claimLock: String? = null,
val claimExpires: Long? = null,
val lastHeartbeatAt: Long? = null,
val maxRuntimeSeconds: Int? = null,
)
@Serializable
data class ActiveWorkersResponse(
val workers: List<KanbanActiveWorker> = emptyList(),
val count: Int = workers.size,
val checkedAt: Long,
)
@@ -48,6 +48,18 @@ data class TaskLinks(
val children: List<String> = emptyList(),
)
@Serializable
data class TaskLinkBody(
val parentId: String,
val childId: String,
)
@Serializable
data class TaskLinkResponse(
val ok: Boolean,
val gated: Boolean = false,
)
@Serializable
data class ChildTaskResult(
val id: String,
@@ -75,6 +87,9 @@ data class KanbanTask(
val startedAt: Long? = null,
val workerPid: Int? = null,
val lastHeartbeatAt: Long? = null,
val currentRunId: Long? = null,
val workflowTemplateId: String? = null,
val currentStepKey: String? = null,
) {
val description: String? get() = body
val assignedTo: String? get() = assignee
@@ -112,6 +127,10 @@ data class KanbanTaskFull(
val startedAt: Long? = null,
val workerPid: Int? = null,
val lastHeartbeatAt: Long? = null,
val currentRunId: Long? = null,
val workflowTemplateId: String? = null,
val currentStepKey: String? = null,
val maxRuntimeSeconds: Int? = null,
val result: String? = null,
val createdBy: String? = null,
val modelOverride: String? = null,
@@ -245,6 +264,19 @@ data class ReclaimTaskResponse(
val taskId: String,
)
@Serializable
data class SpecifyTaskBody(
val author: String? = null,
)
@Serializable
data class SpecifyTaskResponse(
val ok: Boolean,
val taskId: String,
val reason: String? = null,
val newTitle: String? = null,
)
@Serializable
data class TaskEstimate(
val ok: Boolean,
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.model.ActiveWorkersResponse
import com.m57.hermescontrol.data.model.AttachmentListResponse
import com.m57.hermescontrol.data.model.AttachmentUploadResponse
import com.m57.hermescontrol.data.model.AutoDescribeResponse
import com.m57.hermescontrol.data.model.BoardExportResult
@@ -10,6 +12,7 @@ import com.m57.hermescontrol.data.model.CreateBoardBody
import com.m57.hermescontrol.data.model.CreateBoardResponse
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse
import com.m57.hermescontrol.data.model.DeleteAttachmentResponse
import com.m57.hermescontrol.data.model.DeleteBoardResponse
import com.m57.hermescontrol.data.model.DispatchResult
import com.m57.hermescontrol.data.model.ExportBoardBody
@@ -18,6 +21,8 @@ import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
import com.m57.hermescontrol.data.model.KanbanProjectsResponse
import com.m57.hermescontrol.data.model.KanbanRunInspection
import com.m57.hermescontrol.data.model.KanbanRunResponse
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.OrchestrationSettings
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
@@ -27,7 +32,13 @@ import com.m57.hermescontrol.data.model.ReclaimTaskBody
import com.m57.hermescontrol.data.model.ReclaimTaskResponse
import com.m57.hermescontrol.data.model.RenameBoardBody
import com.m57.hermescontrol.data.model.RenameBoardResponse
import com.m57.hermescontrol.data.model.SpecifyTaskBody
import com.m57.hermescontrol.data.model.SpecifyTaskResponse
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.TaskLinkBody
import com.m57.hermescontrol.data.model.TaskLinkResponse
import com.m57.hermescontrol.data.model.TerminateRunBody
import com.m57.hermescontrol.data.model.TerminateRunResponse
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.model.WorkerLog
@@ -57,6 +68,8 @@ interface KanbanApiService {
@Query("board") board: String? = null,
@Query("include_archived") includeArchived: Boolean = false,
@Query("tenant") tenant: String? = null,
@Query("workflow_template_id") workflowTemplateId: String? = null,
@Query("current_step_key") currentStepKey: String? = null,
): Response<KanbanBoardResponse>
@POST("api/plugins/kanban/boards")
@@ -96,6 +109,8 @@ interface KanbanApiService {
suspend fun getTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
@Query("run_state_type") runStateType: String? = null,
@Query("run_state_name") runStateName: String? = null,
): Response<KanbanTaskDetailResponse>
@POST("api/plugins/kanban/tasks")
@@ -170,6 +185,12 @@ interface KanbanApiService {
@Part file: MultipartBody.Part,
): Response<AttachmentUploadResponse>
@GET("api/plugins/kanban/tasks/{id}/attachments")
suspend fun listAttachments(
@Path("id") taskId: String,
@Query("board") board: String? = null,
): Response<AttachmentListResponse>
@Streaming
@GET("api/plugins/kanban/attachments/{id}")
suspend fun downloadAttachment(
@@ -177,6 +198,49 @@ interface KanbanApiService {
@Query("board") board: String? = null,
): Response<ResponseBody>
@DELETE("api/plugins/kanban/attachments/{id}")
suspend fun deleteAttachment(
@Path("id") attachmentId: Long,
@Query("board") board: String? = null,
): Response<DeleteAttachmentResponse>
@GET("api/plugins/kanban/workers/active")
suspend fun getActiveWorkers(
@Query("board") board: String? = null,
): Response<ActiveWorkersResponse>
@GET("api/plugins/kanban/runs/{id}")
suspend fun getRun(
@Path("id") runId: Long,
@Query("board") board: String? = null,
): Response<KanbanRunResponse>
@GET("api/plugins/kanban/runs/{id}/inspect")
suspend fun inspectRun(
@Path("id") runId: Long,
@Query("board") board: String? = null,
): Response<KanbanRunInspection>
@POST("api/plugins/kanban/runs/{id}/terminate")
suspend fun terminateRun(
@Path("id") runId: Long,
@Query("board") board: String? = null,
@Body body: TerminateRunBody = TerminateRunBody(),
): Response<TerminateRunResponse>
@POST("api/plugins/kanban/tasks/{id}/specify")
suspend fun specifyTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
@Body body: SpecifyTaskBody = SpecifyTaskBody(),
): Response<SpecifyTaskResponse>
@POST("api/plugins/kanban/links")
suspend fun createTaskLink(
@Query("board") board: String? = null,
@Body body: TaskLinkBody,
): Response<TaskLinkResponse>
@GET("api/plugins/kanban/profiles")
suspend fun getKanbanProfiles(): Response<KanbanProfilesResponse>
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.model.ActiveWorkersResponse
import com.m57.hermescontrol.data.model.AttachmentListResponse
import com.m57.hermescontrol.data.model.AttachmentUploadResponse
import com.m57.hermescontrol.data.model.AutoDescribeResponse
import com.m57.hermescontrol.data.model.BoardExportResult
@@ -10,6 +12,7 @@ import com.m57.hermescontrol.data.model.CreateBoardBody
import com.m57.hermescontrol.data.model.CreateBoardResponse
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse
import com.m57.hermescontrol.data.model.DeleteAttachmentResponse
import com.m57.hermescontrol.data.model.DeleteBoardResponse
import com.m57.hermescontrol.data.model.DispatchResult
import com.m57.hermescontrol.data.model.ExportBoardBody
@@ -18,6 +21,8 @@ import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
import com.m57.hermescontrol.data.model.KanbanProjectsResponse
import com.m57.hermescontrol.data.model.KanbanRunInspection
import com.m57.hermescontrol.data.model.KanbanRunResponse
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.OrchestrationSettings
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
@@ -27,7 +32,13 @@ import com.m57.hermescontrol.data.model.ReclaimTaskBody
import com.m57.hermescontrol.data.model.ReclaimTaskResponse
import com.m57.hermescontrol.data.model.RenameBoardBody
import com.m57.hermescontrol.data.model.RenameBoardResponse
import com.m57.hermescontrol.data.model.SpecifyTaskBody
import com.m57.hermescontrol.data.model.SpecifyTaskResponse
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.TaskLinkBody
import com.m57.hermescontrol.data.model.TaskLinkResponse
import com.m57.hermescontrol.data.model.TerminateRunBody
import com.m57.hermescontrol.data.model.TerminateRunResponse
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.model.WorkerLog
@@ -48,6 +59,8 @@ interface KanbanRepository {
board: String,
includeArchived: Boolean = false,
tenant: String? = null,
workflowTemplateId: String? = null,
currentStepKey: String? = null,
): NetworkResult<KanbanBoardResponse>
suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse>
@@ -72,6 +85,8 @@ interface KanbanRepository {
suspend fun getTask(
taskId: String,
board: String?,
runStateType: String? = null,
runStateName: String? = null,
): NetworkResult<KanbanTaskDetailResponse>
suspend fun createTask(
@@ -136,11 +151,49 @@ interface KanbanRepository {
file: MultipartBody.Part,
): NetworkResult<AttachmentUploadResponse>
suspend fun listAttachments(
taskId: String,
board: String,
): NetworkResult<AttachmentListResponse>
suspend fun deleteAttachment(
attachmentId: Long,
board: String,
): NetworkResult<DeleteAttachmentResponse>
suspend fun downloadAttachment(
attachmentId: Long,
board: String?,
): NetworkResult<ResponseBody>
suspend fun getActiveWorkers(board: String): NetworkResult<ActiveWorkersResponse>
suspend fun getRun(
runId: Long,
board: String,
): NetworkResult<KanbanRunResponse>
suspend fun inspectRun(
runId: Long,
board: String,
): NetworkResult<KanbanRunInspection>
suspend fun terminateRun(
runId: Long,
board: String,
): NetworkResult<TerminateRunResponse>
suspend fun specifyTask(
taskId: String,
board: String,
): NetworkResult<SpecifyTaskResponse>
suspend fun createTaskLink(
board: String,
parentId: String,
childId: String,
): NetworkResult<TaskLinkResponse>
suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse>
suspend fun updateProfileDescription(
@@ -173,9 +226,11 @@ class KanbanRepositoryImpl(
board: String,
includeArchived: Boolean,
tenant: String?,
workflowTemplateId: String?,
currentStepKey: String?,
): NetworkResult<KanbanBoardResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getBoard(board = board, includeArchived = includeArchived, tenant = tenant) }
safeApiCall { apiProvider().getBoard(board, includeArchived, tenant, workflowTemplateId, currentStepKey) }
}
override suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse> =
@@ -215,9 +270,11 @@ class KanbanRepositoryImpl(
override suspend fun getTask(
taskId: String,
board: String?,
runStateType: String?,
runStateName: String?,
): NetworkResult<KanbanTaskDetailResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getTask(taskId = taskId, board = board) }
safeApiCall { apiProvider().getTask(taskId, board, runStateType, runStateName) }
}
override suspend fun createTask(
@@ -364,6 +421,18 @@ class KanbanRepositoryImpl(
safeApiCall { apiProvider().uploadAttachment(taskId = taskId, board = board, file = file) }
}
override suspend fun listAttachments(
taskId: String,
board: String,
): NetworkResult<AttachmentListResponse> =
withContext(ioDispatcher) { safeApiCall { apiProvider().listAttachments(taskId, board) } }
override suspend fun deleteAttachment(
attachmentId: Long,
board: String,
): NetworkResult<DeleteAttachmentResponse> =
withContext(ioDispatcher) { safeApiCall { apiProvider().deleteAttachment(attachmentId, board) } }
override suspend fun downloadAttachment(
attachmentId: Long,
board: String?,
@@ -372,6 +441,48 @@ class KanbanRepositoryImpl(
safeApiCall { apiProvider().downloadAttachment(attachmentId = attachmentId, board = board) }
}
override suspend fun getActiveWorkers(board: String): NetworkResult<ActiveWorkersResponse> =
withContext(ioDispatcher) { safeApiCall { apiProvider().getActiveWorkers(board) } }
override suspend fun getRun(
runId: Long,
board: String,
): NetworkResult<KanbanRunResponse> =
withContext(ioDispatcher) { safeApiCall { apiProvider().getRun(runId, board) } }
override suspend fun inspectRun(
runId: Long,
board: String,
): NetworkResult<KanbanRunInspection> =
withContext(ioDispatcher) { safeApiCall { apiProvider().inspectRun(runId, board) } }
override suspend fun terminateRun(
runId: Long,
board: String,
): NetworkResult<TerminateRunResponse> =
withContext(ioDispatcher) {
val result = safeApiCall { apiProvider().terminateRun(runId, board, TerminateRunBody(reason = "mobile")) }
if (result is NetworkResult.Success && result.data.ok) nudger.scheduleNudge(board)
result
}
override suspend fun specifyTask(
taskId: String,
board: String,
): NetworkResult<SpecifyTaskResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().specifyTask(taskId, board, SpecifyTaskBody(author = "mobile")) }
}
override suspend fun createTaskLink(
board: String,
parentId: String,
childId: String,
): NetworkResult<TaskLinkResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().createTaskLink(board, TaskLinkBody(parentId, childId)) }
}
override suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getKanbanProfiles() }
@@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -119,10 +120,22 @@ fun KanbanScreen(
var selectedAssignee by remember { mutableStateOf<String?>(null) }
var selectedTenant by remember { mutableStateOf<String?>(null) }
var showFilterSheet by remember { mutableStateOf(false) }
var showWorkersDialog by remember { mutableStateOf(false) }
val filteredTasks =
remember(query, state.tasks, selectedAssignee, selectedTenant) {
state.tasks.filter { task ->
remember(
query,
state.tasks,
selectedAssignee,
selectedTenant,
state.selectedWorkflowTemplateId,
state.selectedCurrentStepKey,
) {
filterKanbanTasks(
state.tasks,
state.selectedWorkflowTemplateId,
state.selectedCurrentStepKey,
).filter { task ->
val matchesQuery =
query.isBlank() ||
task.title.contains(query, ignoreCase = true) ||
@@ -331,6 +344,14 @@ fun KanbanScreen(
},
)
if (state.selectedBoard != null) {
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_active_workers)) },
onClick = {
showBoardMenu = false
showWorkersDialog = true
viewModel.loadActiveWorkers()
},
)
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_board_settings)) },
onClick = {
@@ -917,10 +938,23 @@ fun KanbanScreen(
tenants = tenants,
selectedAssignee = selectedAssignee,
selectedTenant = selectedTenant,
workflowTemplateIds =
state.tasks
.mapNotNull { it.workflowTemplateId }
.distinct()
.sorted(),
currentStepKeys =
state.tasks
.mapNotNull { it.currentStepKey }
.distinct()
.sorted(),
selectedWorkflowTemplateId = state.selectedWorkflowTemplateId,
selectedCurrentStepKey = state.selectedCurrentStepKey,
includeArchived = state.includeArchived,
groupRunning = state.groupRunning,
onSelectAssignee = { selectedAssignee = it },
onSelectTenant = { selectedTenant = it },
onSelectWorkflowFilters = viewModel::setWorkflowFilters,
onToggleIncludeArchived = viewModel::setIncludeArchived,
onToggleGroupRunning = viewModel::setGroupRunning,
onClearFilters = {
@@ -928,6 +962,7 @@ fun KanbanScreen(
selectedTenant = null
viewModel.setIncludeArchived(false)
viewModel.setGroupRunning(false)
viewModel.setWorkflowFilters(null, null)
},
onDismiss = { showFilterSheet = false },
)
@@ -1291,6 +1326,46 @@ fun KanbanScreen(
)
}
if (showWorkersDialog) {
AlertDialog(
onDismissRequest = { showWorkersDialog = false },
title = { Text(stringResource(R.string.kanban_active_workers)) },
text = {
if (state.isLoadingWorkers) {
CircularProgressIndicator()
} else if (state.activeWorkers.isEmpty()) {
Text(stringResource(R.string.kanban_no_active_workers))
} else {
LazyColumn(modifier = Modifier.heightIn(max = 320.dp)) {
items(state.activeWorkers, key = { it.runId }) { worker ->
Text(
"${worker.taskTitle} · ${worker.profile ?: worker.taskAssignee.orEmpty()}",
modifier =
Modifier
.fillMaxWidth()
.clickable {
val board = state.selectedBoard ?: return@clickable
showWorkersDialog = false
NavigationController.navigateTo(
KanbanTaskDetailKey(
boardSlug = board.id,
taskId = worker.taskId,
),
)
}.padding(vertical = 8.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = { showWorkersDialog = false }) {
Text(stringResource(R.string.action_close))
}
},
)
}
if (showDeleteBoardDialog && state.selectedBoard != null) {
AlertDialog(
onDismissRequest = { showDeleteBoardDialog = false },
@@ -31,6 +31,7 @@ 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.Download
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.RestartAlt
@@ -62,9 +63,11 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
@@ -75,12 +78,14 @@ 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.KanbanAttachment
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.data.remote.NetworkResult
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.common.ErrorState
@@ -89,6 +94,13 @@ 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
import com.m57.hermescontrol.util.StreamingUriRequestBody
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -110,6 +122,7 @@ fun KanbanTaskScreen(
stringResource(R.string.kanban_tab_files),
)
var showDeleteDialog by remember { mutableStateOf(false) }
var runToTerminate by remember { mutableStateOf<Long?>(null) }
LaunchedEffect(boardSlug, taskId) {
viewModel.loadTask(boardSlug, taskId)
@@ -208,6 +221,10 @@ fun KanbanTaskScreen(
onReclaim = {
viewModel.reclaim(boardSlug, taskId)
},
isSpecifying = state.isSpecifying,
isLinking = state.isLinking,
onSpecify = { viewModel.specifyTask(boardSlug, taskId) },
onAddParent = { viewModel.createParentLink(boardSlug, taskId, it) },
)
}
@@ -229,6 +246,10 @@ fun KanbanTaskScreen(
TaskRunsTab(
runs = detail.runs,
workerLog = state.workerLog,
runStateType = state.runStateType,
runStateName = state.runStateName,
onFilter = { type, name -> viewModel.setRunFilter(boardSlug, taskId, type, name) },
onInspect = { viewModel.inspectRun(boardSlug, taskId, it) },
)
}
@@ -242,9 +263,10 @@ fun KanbanTaskScreen(
TaskFilesTab(
attachments = detail.attachments ?: emptyList(),
isUploading = state.isUploadingAttachment,
onUpload = { filename, mimeType, bytes ->
viewModel.uploadAttachment(boardSlug, taskId, filename, mimeType, bytes)
},
isDeleting = state.isDeletingAttachment,
board = boardSlug,
taskId = taskId,
viewModel = viewModel,
)
}
}
@@ -276,6 +298,68 @@ fun KanbanTaskScreen(
},
)
}
val selectedRun = state.selectedRun
if (selectedRun != null) {
AlertDialog(
onDismissRequest = viewModel::clearRunInspection,
title = { Text(stringResource(R.string.kanban_run_details)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
MetaRow(stringResource(R.string.kanban_run_number, selectedRun.id), selectedRun.status)
selectedRun.profile?.let { MetaRow(stringResource(R.string.kanban_assignee), it) }
selectedRun.stepKey?.let { MetaRow("Step", it) }
selectedRun.workerPid?.let { MetaRow("PID", it.toString()) }
selectedRun.summary?.let { Text(it) }
selectedRun.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
state.runInspection?.let { inspection ->
Text(
if (inspection.alive) {
inspection.status ?: selectedRun.status
} else {
inspection.reason ?: stringResource(R.string.kanban_run_inspection_unavailable)
},
)
inspection.cpuPercent?.let { MetaRow("CPU", "$it%") }
inspection.memoryRssBytes?.let { MetaRow("Memory", "${it / 1024} KiB") }
inspection.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
}
},
confirmButton = {
if (selectedRun.endedAt == null && selectedRun.status == "running" &&
state.detail?.task?.status == "running"
) {
TextButton(onClick = { runToTerminate = selectedRun.id }, enabled = !state.isTerminatingRun) {
Text(stringResource(R.string.kanban_terminate_run))
}
}
},
dismissButton = {
TextButton(onClick = viewModel::clearRunInspection) { Text(stringResource(R.string.action_cancel)) }
},
)
}
if (runToTerminate != null) {
AlertDialog(
onDismissRequest = { runToTerminate = null },
title = { Text(stringResource(R.string.kanban_terminate_run)) },
text = { Text(stringResource(R.string.kanban_terminate_run_confirm)) },
confirmButton = {
Button(
onClick = {
runToTerminate?.let { viewModel.terminateRun(boardSlug, taskId, it) }
runToTerminate = null
},
enabled = !state.isTerminatingRun,
) { Text(stringResource(R.string.kanban_terminate_run)) }
},
dismissButton = {
TextButton(onClick = { runToTerminate = null }) { Text(stringResource(R.string.action_cancel)) }
},
)
}
}
@Composable
@@ -293,11 +377,16 @@ private fun TaskOverviewTab(
onNavigateToTask: (String) -> Unit,
onSaveDescription: (String) -> Unit,
onReclaim: () -> Unit,
isSpecifying: Boolean,
isLinking: Boolean,
onSpecify: () -> Unit,
onAddParent: (String) -> 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) }
var parentDraft by remember { mutableStateOf("") }
val statusColors = LocalHermesStatusColors.current
val currentModelOverride =
@@ -430,6 +519,8 @@ private fun TaskOverviewTab(
task.tenant?.let { MetaRow(label = "Tenant", value = it) }
task.workspaceKind?.let { MetaRow(label = "Workspace", value = "$it ${task.workspacePath ?: ""}") }
task.workerPid?.let { MetaRow(label = "Worker PID", value = it.toString()) }
task.workflowTemplateId?.let { MetaRow(label = "Workflow", value = it) }
task.currentStepKey?.let { MetaRow(label = "Step", value = it) }
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
@@ -446,7 +537,7 @@ private fun TaskOverviewTab(
}
// Dependencies Section
if (links.parents.isNotEmpty() || links.children.isNotEmpty()) {
run {
Card(
shape = RoundedCornerShape(8.dp),
modifier =
@@ -461,6 +552,22 @@ private fun TaskOverviewTab(
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = parentDraft,
onValueChange = { parentDraft = it },
label = { Text(stringResource(R.string.kanban_parent_id)) },
modifier = Modifier.weight(1f),
singleLine = true,
)
TextButton(
onClick = {
onAddParent(parentDraft)
parentDraft = ""
},
enabled = parentDraft.isNotBlank() && !isLinking,
) { Text(stringResource(R.string.kanban_add_parent)) }
}
if (links.parents.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
@@ -506,6 +613,13 @@ private fun TaskOverviewTab(
}
// Effort Estimation Section
if (task.status == "triage") {
OutlinedButton(
onClick = onSpecify,
enabled = !isSpecifying,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
) { Text(stringResource(R.string.kanban_specify_task)) }
}
Card(
shape = RoundedCornerShape(8.dp),
modifier =
@@ -778,8 +892,15 @@ private fun formatRunDuration(durationSeconds: Long): String =
private fun TaskRunsTab(
runs: List<com.m57.hermescontrol.data.model.KanbanRun>,
workerLog: com.m57.hermescontrol.data.model.WorkerLog?,
runStateType: String?,
runStateName: String?,
onFilter: (String?, String?) -> Unit,
onInspect: (Long) -> Unit,
modifier: Modifier = Modifier,
) {
var filterType by remember(runStateType) { mutableStateOf(runStateType ?: "status") }
var filterName by remember(runStateName) { mutableStateOf(runStateName ?: "") }
var filterMenuExpanded by remember { mutableStateOf(false) }
LazyColumn(
modifier = modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
@@ -790,6 +911,34 @@ private fun TaskRunsTab(
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Box {
TextButton(onClick = { filterMenuExpanded = true }) {
Text(if (filterType == "status") "Status" else "Outcome")
}
DropdownMenu(expanded = filterMenuExpanded, onDismissRequest = { filterMenuExpanded = false }) {
listOf("status", "outcome").forEach { type ->
DropdownMenuItem(text = { Text(type) }, onClick = {
filterType = type
filterMenuExpanded = false
})
}
}
}
OutlinedTextField(
value = filterName,
onValueChange = { filterName = it },
modifier = Modifier.weight(1f),
label = { Text(stringResource(R.string.kanban_run_state_filter)) },
singleLine = true,
)
TextButton(onClick = { onFilter(filterType, filterName.trim()) }, enabled = filterName.isNotBlank()) {
Text(stringResource(R.string.kanban_filter))
}
}
if (runStateName != null) {
TextButton(onClick = { onFilter(null, null) }) { Text(stringResource(R.string.kanban_all_runs)) }
}
}
if (runs.isEmpty()) {
@@ -858,6 +1007,9 @@ private fun TaskRunsTab(
style = MaterialTheme.typography.bodySmall,
)
}
OutlinedButton(onClick = { onInspect(run.id) }) {
Text(stringResource(R.string.kanban_inspect_run))
}
}
}
}
@@ -1058,31 +1210,107 @@ private fun MetaRow(
private fun TaskFilesTab(
attachments: List<com.m57.hermescontrol.data.model.KanbanAttachment>,
isUploading: Boolean,
onUpload: (filename: String, mimeType: String, bytes: ByteArray) -> Unit,
isDeleting: Boolean,
board: String,
taskId: String,
viewModel: KanbanTaskViewModel,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent(),
) { uri: Uri? ->
if (uri != null) {
var filename = "attachment"
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) ?: "attachment"
val configuration = LocalConfiguration.current
val resources = remember(context, configuration) { context.createConfigurationContext(configuration).resources }
val scope = rememberCoroutineScope()
var attachmentToDelete by remember { mutableStateOf<KanbanAttachment?>(null) }
var attachmentToDownload by remember { mutableStateOf<KanbanAttachment?>(null) }
val downloadLauncher =
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri ->
val selected = attachmentToDownload
attachmentToDownload = null
if (uri != null && selected != null) {
scope.launch {
when (val result = viewModel.downloadAttachment(board, selected.id)) {
is NetworkResult.Success -> {
try {
withContext(Dispatchers.IO) {
result.data.use { body ->
context.contentResolver.openOutputStream(uri)?.use { output ->
body.byteStream().use { input -> input.copyTo(output) }
} ?: error("Could not open the selected destination")
}
}
viewModel.showToast(resources.getString(R.string.kanban_attachment_saved))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
viewModel.showToast(
resources.getString(R.string.kanban_attachment_error, e.message ?: "Unknown error"),
)
}
}
is NetworkResult.Failure -> {
viewModel.showToast(result.error.message)
}
}
}
val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
val bytes =
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: byteArrayOf()
if (bytes.isNotEmpty()) {
onUpload(filename, mimeType, bytes)
}
}
}
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri: Uri? ->
if (uri != null) {
scope.launch {
try {
val part =
withContext(Dispatchers.IO) {
var filename = "attachment"
var contentLength = -1L
context.contentResolver
.query(
uri,
arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE),
null,
null,
null,
)?.use { c ->
if (c.moveToFirst()) {
c.getColumnIndex(OpenableColumns.DISPLAY_NAME).takeIf { it >= 0 }?.let {
filename = c.getString(it) ?: filename
}
c
.getColumnIndex(
OpenableColumns.SIZE,
).takeIf { it >= 0 && !c.isNull(it) }
?.let {
contentLength = c.getLong(it)
}
}
}
val mime = context.contentResolver.getType(uri) ?: "application/octet-stream"
val body =
StreamingUriRequestBody(
context.contentResolver,
uri,
mime.toMediaTypeOrNull(),
contentLength,
)
MultipartBody.Part.createFormData("file", filename, body)
}
viewModel.uploadAttachment(board, taskId, part)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
viewModel.showToast(
resources.getString(
R.string.kanban_attachment_error,
e.message ?: "Unknown error",
),
)
}
}
}
}
LaunchedEffect(board, taskId) { viewModel.refreshAttachments(board, taskId) }
Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
Row(
@@ -1096,7 +1324,7 @@ private fun TaskFilesTab(
fontWeight = FontWeight.Bold,
)
Button(
onClick = { launcher.launch("*/*") },
onClick = { launcher.launch(arrayOf("*/*")) },
enabled = !isUploading,
) {
if (isUploading) {
@@ -1136,22 +1364,55 @@ private fun TaskFilesTab(
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = att.filename.ifBlank { "File #${att.id}" },
att.filename.ifBlank {
"File #${att.id}"
},
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
att.size?.let {
Text(
text = "${it / 1024} KB",
"${it / 1024} KB",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
IconButton(onClick = {
attachmentToDownload = att
downloadLauncher.launch(att.filename)
}) {
Icon(
Icons.Filled.Download,
contentDescription = stringResource(R.string.kanban_download_attachment),
)
}
IconButton(onClick = { attachmentToDelete = att }, enabled = !isDeleting) {
Icon(
Icons.Filled.Delete,
contentDescription = stringResource(R.string.kanban_delete_attachment),
)
}
}
}
}
}
}
}
attachmentToDelete?.let { attachment ->
AlertDialog(
onDismissRequest = { attachmentToDelete = null },
title = { Text(stringResource(R.string.kanban_delete_attachment)) },
text = { Text(stringResource(R.string.kanban_delete_attachment_confirm)) },
confirmButton = {
TextButton(onClick = {
attachmentToDelete = null
viewModel.deleteAttachment(board, taskId, attachment.id)
}) { Text(stringResource(R.string.kanban_delete_attachment)) }
},
dismissButton = {
TextButton(onClick = { attachmentToDelete = null }) { Text(stringResource(R.string.action_cancel)) }
},
)
}
}
@@ -4,6 +4,8 @@ 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.KanbanRun
import com.m57.hermescontrol.data.model.KanbanRunInspection
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
@@ -25,9 +27,8 @@ 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
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.ResponseBody
data class KanbanTaskUiState(
val isLoading: Boolean = false,
@@ -47,6 +48,15 @@ data class KanbanTaskUiState(
val isReassigning: Boolean = false,
val isUpdatingModel: Boolean = false,
val isUploadingAttachment: Boolean = false,
val isDeletingAttachment: Boolean = false,
val isSpecifying: Boolean = false,
val isLinking: Boolean = false,
val isInspectingRun: Boolean = false,
val isTerminatingRun: Boolean = false,
val selectedRun: KanbanRun? = null,
val runInspection: KanbanRunInspection? = null,
val runStateType: String? = null,
val runStateName: String? = null,
)
class KanbanTaskViewModel(
@@ -61,6 +71,7 @@ class KanbanTaskViewModel(
private var eventsClient: KanbanEventsClient? = null
private var eventsBoard: String? = null
private var activeTaskId: String? = null
private var activeBoard: String? = null
private var currentLoadGen: Int = 0
private var reloadJob: Job? = null
private var logPollJob: Job? = null
@@ -74,7 +85,18 @@ class KanbanTaskViewModel(
reloadJob?.cancel()
stopLogPolling()
detailPollJob?.cancel()
if (activeBoard != board || activeTaskId != taskId) {
_uiState.update {
it.copy(
runStateType = null,
runStateName = null,
selectedRun = null,
runInspection = null,
)
}
}
activeTaskId = taskId
activeBoard = board
_uiState.update {
it.copy(
isLoading = true,
@@ -90,7 +112,15 @@ class KanbanTaskViewModel(
startDetailPolling(board, taskId)
viewModelScope.launch {
when (val result = repository.getTask(taskId = taskId, board = board)) {
when (
val result =
repository.getTask(
taskId,
board,
_uiState.value.runStateType,
_uiState.value.runStateName,
)
) {
is NetworkResult.Success -> {
if (gen != currentLoadGen) return@launch
val taskData = result.data
@@ -254,11 +284,13 @@ class KanbanTaskViewModel(
taskId: String,
) {
val gen = currentLoadGen
val result = repository.getTask(taskId = taskId, board = board)
if (gen != currentLoadGen || taskId != activeTaskId) return
val result = repository.getTask(taskId, board, _uiState.value.runStateType, _uiState.value.runStateName)
if (gen != currentLoadGen || taskId != activeTaskId || board != activeBoard) return
if (result is NetworkResult.Success) {
val taskData = result.data
_uiState.update { it.copy(detail = taskData) }
_uiState.update { state ->
state.copy(detail = taskData.copy(attachments = taskData.attachments ?: state.detail?.attachments))
}
if (taskData.task.status.equals("running", ignoreCase = true)) {
if (logPollJob == null) startLogPolling(board, taskId)
} else {
@@ -521,18 +553,6 @@ class KanbanTaskViewModel(
}
}
fun uploadAttachment(
board: String,
taskId: String,
filename: String,
mimeType: String,
bytes: ByteArray,
) {
val requestBody = bytes.toRequestBody(mimeType.toMediaTypeOrNull())
val part = MultipartBody.Part.createFormData("file", filename, requestBody)
uploadAttachment(board, taskId, part)
}
fun uploadAttachment(
board: String,
taskId: String,
@@ -548,7 +568,10 @@ class KanbanTaskViewModel(
toastMessage = "Attachment uploaded",
)
}
loadTask(board, taskId)
if (activeTaskId == taskId && activeBoard == board) {
loadTaskSilently(board, taskId)
refreshAttachments(board, taskId)
}
}
is NetworkResult.Failure -> {
@@ -563,6 +586,217 @@ class KanbanTaskViewModel(
}
}
fun refreshAttachments(
board: String,
taskId: String,
) {
viewModelScope.launch {
when (val result = repository.listAttachments(taskId, board)) {
is NetworkResult.Success -> {
_uiState.update { state ->
if (activeTaskId != taskId || activeBoard != board) {
state
} else {
state.copy(detail = state.detail?.copy(attachments = result.data.attachments))
}
}
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = result.error.message) }
}
}
}
}
fun deleteAttachment(
board: String,
taskId: String,
attachmentId: Long,
) {
if (_uiState.value.isDeletingAttachment) return
_uiState.update { it.copy(isDeletingAttachment = true) }
viewModelScope.launch {
when (val result = repository.deleteAttachment(attachmentId, board)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isDeletingAttachment = false,
toastMessage = if (result.data.ok) "Attachment deleted" else "Attachment was not deleted",
)
}
if (result.data.ok) refreshAttachments(board, taskId)
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(isDeletingAttachment = false, toastMessage = result.error.message)
}
}
}
}
}
suspend fun downloadAttachment(
board: String,
attachmentId: Long,
): NetworkResult<ResponseBody> = repository.downloadAttachment(attachmentId, board)
fun showToast(message: String) {
_uiState.update { it.copy(toastMessage = message) }
}
fun inspectRun(
board: String,
taskId: String,
runId: Long,
) {
if (_uiState.value.isInspectingRun) return
_uiState.update { it.copy(isInspectingRun = true, selectedRun = null, runInspection = null) }
viewModelScope.launch {
val run = repository.getRun(runId, board)
val inspection = if (run is NetworkResult.Success) repository.inspectRun(runId, board) else null
if (activeTaskId != null && (activeTaskId != taskId || activeBoard != board)) return@launch
_uiState.update {
it.copy(
isInspectingRun = false,
selectedRun = (run as? NetworkResult.Success)?.data?.run,
runInspection = (inspection as? NetworkResult.Success)?.data,
toastMessage =
(run as? NetworkResult.Failure)?.error?.message
?: (inspection as? NetworkResult.Failure)?.error?.message,
)
}
}
}
fun clearRunInspection() {
_uiState.update { it.copy(selectedRun = null, runInspection = null) }
}
fun terminateRun(
board: String,
taskId: String,
runId: Long,
) {
if (_uiState.value.isTerminatingRun) return
_uiState.update { it.copy(isTerminatingRun = true) }
viewModelScope.launch {
when (val result = repository.terminateRun(runId, board)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isTerminatingRun = false,
toastMessage = if (result.data.ok) "Run terminated" else "Run was not terminated",
)
}
if (result.data.ok && result.data.taskId == taskId &&
activeTaskId == taskId && activeBoard == board
) {
val latestRun = repository.getRun(runId, board)
_uiState.update { it.copy(selectedRun = (latestRun as? NetworkResult.Success)?.data?.run) }
loadTaskSilently(board, taskId)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(isTerminatingRun = false, toastMessage = result.error.message)
}
}
}
}
}
fun specifyTask(
board: String,
taskId: String,
) {
if (_uiState.value.isSpecifying) return
_uiState.update { it.copy(isSpecifying = true) }
viewModelScope.launch {
when (val result = repository.specifyTask(taskId, board)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isSpecifying = false,
toastMessage =
if (result.data.ok) {
"Task specified"
} else {
result.data.reason
?: "Specification failed"
},
)
}
if (result.data.ok && activeTaskId == taskId &&
activeBoard == board
) {
loadTaskSilently(board, taskId)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(isSpecifying = false, toastMessage = result.error.message)
}
}
}
}
}
fun createParentLink(
board: String,
childId: String,
parentId: String,
) {
if (parentId.isBlank() || _uiState.value.isLinking) return
_uiState.update { it.copy(isLinking = true) }
viewModelScope.launch {
when (val result = repository.createTaskLink(board, parentId.trim(), childId)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isLinking = false,
toastMessage =
when {
!result.data.ok -> "Parent link was not created"
result.data.gated -> "Linked; task gated by parent"
else -> "Parent linked"
},
)
}
if (result.data.ok) {
if (activeTaskId == childId && activeBoard == board) {
loadTaskSilently(board, childId)
} else if (activeTaskId == null) {
loadTask(board, childId)
}
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(isLinking = false, toastMessage = result.error.message)
}
}
}
}
}
fun setRunFilter(
board: String,
taskId: String,
type: String?,
name: String?,
) {
if ((type == null) != (name == null) || type !in setOf(null, "status", "outcome")) return
_uiState.update { it.copy(runStateType = type, runStateName = name) }
if (activeBoard == board && activeTaskId == taskId) {
viewModelScope.launch { loadTaskSilently(board, taskId) }
}
}
public override fun onCleared() {
stopLogPolling()
detailPollJob?.cancel()
@@ -0,0 +1,30 @@
package com.m57.hermescontrol.ui.kanban
/** The dashboard currently sends dependency refusals as a 409 string detail, not JSON fields. */
data class KanbanTransitionRefusal(
val blockingParentIds: List<String>,
val blockingParentStatuses: List<String>,
)
fun kanbanTransitionRefusal(message: String): KanbanTransitionRefusal? {
val (parentList, pattern) =
when {
message.contains("blocked by parent(s) not done") -> {
message.substringAfter("blocked by parent(s) not done") to Regex("\\(([^(),\\s]+), status=([^()]*)\\)")
}
message.contains("unsatisfied parent dependencies:") -> {
message.substringAfter("unsatisfied parent dependencies:").substringBefore(';') to
Regex("([^,;()\\s]+) \\(([^()]*)\\)")
}
else -> {
return null
}
}
val blockers =
pattern.findAll(parentList).map { match -> match.groupValues[1] to match.groupValues[2] }.toList()
return blockers.takeIf { it.isNotEmpty() }?.let {
KanbanTransitionRefusal(it.map { pair -> pair.first }, it.map { pair -> pair.second })
}
}
@@ -12,6 +12,7 @@ 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.KanbanActiveWorker
import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanColumn
@@ -26,6 +27,7 @@ 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.NetworkError
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.repository.KanbanRepository
@@ -64,8 +66,22 @@ data class KanbanUiState(
val columnCollapseOverrides: Map<String, Boolean> = emptyMap(),
val errorMessage: String? = null,
val toastMessage: String? = null,
val selectedWorkflowTemplateId: String? = null,
val selectedCurrentStepKey: String? = null,
val activeWorkers: List<KanbanActiveWorker> = emptyList(),
val isLoadingWorkers: Boolean = false,
)
fun filterKanbanTasks(
tasks: List<KanbanTask>,
workflowTemplateId: String?,
currentStepKey: String?,
): List<KanbanTask> =
tasks.filter { task ->
(workflowTemplateId == null || task.workflowTemplateId.equals(workflowTemplateId, ignoreCase = true)) &&
(currentStepKey == null || task.currentStepKey.equals(currentStepKey, ignoreCase = true))
}
/**
* Task actions mirroring the desktop kanban's transition-gated buttons.
* Only moves the backend PATCH route accepts are offered: never `running`
@@ -250,7 +266,15 @@ class KanbanViewModel(
preferences.setSelectedBoard(endpoint, board.id)
val gen = ++currentLoadGen
_uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) }
_uiState.update {
it.copy(
selectedBoard = board,
isLoading = true,
errorMessage = null,
activeWorkers = emptyList(),
isLoadingWorkers = false,
)
}
viewModelScope.launch {
loadBoardIntoState(board, gen)
connectEvents(board)
@@ -272,6 +296,54 @@ class KanbanViewModel(
_uiState.update { it.copy(groupRunning = group) }
}
fun setWorkflowFilters(
workflowTemplateId: String?,
currentStepKey: String?,
) {
_uiState.update {
it.copy(
selectedWorkflowTemplateId = workflowTemplateId,
selectedCurrentStepKey = currentStepKey,
)
}
_uiState.value.selectedBoard?.let { board ->
val gen = ++currentLoadGen
viewModelScope.launch { loadBoardIntoState(board, gen) }
}
}
fun loadActiveWorkers() {
val board = _uiState.value.selectedBoard ?: return
_uiState.update { it.copy(isLoadingWorkers = true) }
viewModelScope.launch {
when (val result = repository.getActiveWorkers(board.id)) {
is NetworkResult.Success -> {
_uiState.update {
if (it.selectedBoard?.id == board.id) {
it.copy(activeWorkers = result.data.workers, isLoadingWorkers = false)
} else {
it
}
}
}
is NetworkResult.Failure -> {
_uiState.update {
if (it.selectedBoard?.id == board.id) {
it.copy(
isLoadingWorkers = false,
activeWorkers = emptyList(),
toastMessage = "Failed to load active workers: ${result.error.message}",
)
} else {
it
}
}
}
}
}
}
fun toggleColumnCollapse(
columnName: String,
boardHasWork: Boolean,
@@ -860,10 +932,22 @@ class KanbanViewModel(
operatingTaskIds = state.operatingTaskIds - task.id,
)
}
reloadBoardSilently()
}
is NetworkResult.Failure -> {
revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}")
val refusal =
(result.error as? NetworkError.Http)
?.takeIf { it.code == 409 }
?.let { kanbanTransitionRefusal(it.message) }
val message =
if (refusal != null) {
"Move blocked by parents ${refusal.blockingParentIds.joinToString()} " +
"(${refusal.blockingParentStatuses.joinToString()}): ${result.error.message}"
} else {
"Move failed: ${result.error.message}"
}
revertTaskMove(task.id, originalStatus, message)
}
}
}
@@ -945,6 +1029,8 @@ class KanbanViewModel(
repository.getBoard(
board = board.id,
includeArchived = _uiState.value.includeArchived,
workflowTemplateId = _uiState.value.selectedWorkflowTemplateId,
currentStepKey = _uiState.value.selectedCurrentStepKey,
)
if (gen != currentLoadGen) return@launch
if (result is NetworkResult.Success) {
@@ -1010,6 +1096,8 @@ class KanbanViewModel(
repository.getBoard(
board = board.id,
includeArchived = _uiState.value.includeArchived,
workflowTemplateId = _uiState.value.selectedWorkflowTemplateId,
currentStepKey = _uiState.value.selectedCurrentStepKey,
)
applyBoardResult(board, result, gen)
}
@@ -34,10 +34,15 @@ fun KanbanFilterSheet(
tenants: List<String>,
selectedAssignee: String?,
selectedTenant: String?,
workflowTemplateIds: List<String>,
currentStepKeys: List<String>,
selectedWorkflowTemplateId: String?,
selectedCurrentStepKey: String?,
includeArchived: Boolean,
groupRunning: Boolean,
onSelectAssignee: (String?) -> Unit,
onSelectTenant: (String?) -> Unit,
onSelectWorkflowFilters: (String?, String?) -> Unit,
onToggleIncludeArchived: (Boolean) -> Unit,
onToggleGroupRunning: (Boolean) -> Unit,
onClearFilters: () -> Unit,
@@ -105,6 +110,29 @@ fun KanbanFilterSheet(
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
"Workflow template",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
)
FilterChoiceRow("All templates", selectedWorkflowTemplateId == null) {
onSelectWorkflowFilters(null, selectedCurrentStepKey)
}
workflowTemplateIds.forEach { value ->
FilterChoiceRow(value, selectedWorkflowTemplateId == value) {
onSelectWorkflowFilters(value, selectedCurrentStepKey)
}
}
Text("Current step", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary)
FilterChoiceRow("All steps", selectedCurrentStepKey == null) {
onSelectWorkflowFilters(selectedWorkflowTemplateId, null)
}
currentStepKeys.forEach { value ->
FilterChoiceRow(value, selectedCurrentStepKey == value) {
onSelectWorkflowFilters(selectedWorkflowTemplateId, value)
}
}
// Assignee Filter
Text(
text = stringResource(R.string.kanban_assignee),
@@ -192,3 +220,18 @@ fun KanbanFilterSheet(
}
}
}
@Composable
private fun FilterChoiceRow(
label: String,
selected: Boolean,
onClick: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 4.dp),
) {
RadioButton(selected = selected, onClick = onClick)
Text(label, style = MaterialTheme.typography.bodyMedium)
}
}