Merge pull request #1112 from Hy4ri/feat/kanban-parity-foundation

feat(kanban): desktop parity foundation and task inspection
This commit is contained in:
M57 authored and GitHub committed 2026-09-15 00:22:59 +03:00
commit 2d71135a85
32 files changed
+5989 -286

No files matched your search

@@ -174,6 +174,15 @@ private fun appEntryProvider(
onBack = { NavigationController.goBack() },
)
}
// ── Kanban Task Detail ─────────────────────────────────────────────
entry<KanbanTaskDetailKey> { key ->
com.m57.hermescontrol.ui.kanban.KanbanTaskScreen(
boardSlug = key.boardSlug,
taskId = key.taskId,
onBack = { NavigationController.goBack() },
)
}
}
@Composable
@@ -65,6 +65,11 @@ import kotlinx.serialization.Serializable
@Serializable data object KanbanScreen : NavKey
@Serializable data class KanbanTaskDetailKey(
val boardSlug: String,
val taskId: String,
) : NavKey
@Serializable data object ProcessesScreen : NavKey
@Serializable data object ProvidersScreen : NavKey
@@ -0,0 +1,65 @@
package com.m57.hermescontrol.data.local
/**
* Local preferences for Kanban board viewing:
* selected board slug, view mode (list vs board), archive visibility, and group running.
*/
interface KanbanPreferencesStore {
fun getSelectedBoard(endpoint: String): String?
fun setSelectedBoard(
endpoint: String,
slug: String,
)
fun getViewMode(): String
fun setViewMode(mode: String)
fun getIncludeArchived(): Boolean
fun setIncludeArchived(include: Boolean)
fun getGroupRunning(): Boolean
fun setGroupRunning(group: Boolean)
companion object {
const val MODE_LIST = "list"
const val MODE_BOARD = "board"
}
}
class InMemoryKanbanPreferencesStore : KanbanPreferencesStore {
private val selectedBoards = mutableMapOf<String, String>()
private var viewMode: String = KanbanPreferencesStore.MODE_LIST
private var includeArchived: Boolean = false
private var groupRunning: Boolean = false
override fun getSelectedBoard(endpoint: String): String? = selectedBoards[endpoint]
override fun setSelectedBoard(
endpoint: String,
slug: String,
) {
selectedBoards[endpoint] = slug
}
override fun getViewMode(): String = viewMode
override fun setViewMode(mode: String) {
viewMode = mode
}
override fun getIncludeArchived(): Boolean = includeArchived
override fun setIncludeArchived(include: Boolean) {
includeArchived = include
}
override fun getGroupRunning(): Boolean = groupRunning
override fun setGroupRunning(group: Boolean) {
groupRunning = group
}
}
@@ -1,44 +0,0 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class KanbanBoard(
@SerialName("slug") val id: String,
val name: String,
val description: String? = null,
)
@Serializable
data class KanbanTask(
val id: String,
val title: String,
@SerialName("body") val description: String? = null,
val status: String,
@SerialName("assignee") val assignedTo: String? = null,
)
@Serializable
data class CreateTaskBody(
val title: String,
val body: String? = null,
)
@Serializable
data class KanbanColumn(
val name: String,
val tasks: List<KanbanTask>,
)
@Serializable
data class KanbanBoardResponse(
val columns: List<KanbanColumn>,
val assignees: List<String>? = null,
val tenants: List<String>? = null,
)
@Serializable
data class KanbanBoardsResponse(
val boards: List<KanbanBoard>,
val current: String? = null,
)
@@ -0,0 +1,61 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class KanbanComment(
val id: Long,
val author: String,
val body: String,
val createdAt: Long,
)
@Serializable
data class KanbanDetailEvent(
val id: Long,
val kind: String,
val payload: JsonElement? = null,
val createdAt: Long,
)
@Serializable
data class KanbanRun(
val id: Long,
val profile: String? = null,
val status: String,
val outcome: String? = null,
val summary: String? = null,
val error: String? = null,
val metadata: JsonElement? = null,
val workerPid: Int? = null,
val startedAt: Long? = null,
val endedAt: Long? = null,
)
@Serializable
data class WorkerLog(
val taskId: String,
val path: String? = null,
val exists: Boolean = false,
val sizeBytes: Long = 0L,
val content: String = "",
val truncated: Boolean = false,
)
@Serializable
data class KanbanAttachment(
val id: Long,
val taskId: String? = null,
val filename: String,
val contentType: String? = null,
val size: Long? = null,
val uploadedBy: String? = null,
val storedPath: String? = null,
val createdAt: Long? = null,
)
@Serializable
data class AttachmentUploadResponse(
val attachment: KanbanAttachment? = null,
)
@@ -0,0 +1,94 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class KanbanBoard(
@SerialName("slug") val id: String,
val name: String? = null,
val description: String? = null,
val isCurrent: Boolean = false,
val total: Int? = null,
val counts: Map<String, Int> = emptyMap(),
val defaultWorkdir: String? = null,
val defaultWorkspaceKind: String? = null,
val projectId: String? = null,
val projectName: String? = null,
val createdAt: Long? = null,
val archived: Boolean = false,
) {
val slug: String get() = id
val displayName: String get() = name?.takeIf { it.isNotBlank() } ?: id
}
@Serializable
data class KanbanColumn(
val name: String,
val tasks: List<KanbanTask> = emptyList(),
)
@Serializable
data class KanbanBoardResponse(
val columns: List<KanbanColumn> = emptyList(),
val assignees: List<String>? = null,
val tenants: List<String>? = null,
val latestEventId: Long = 0L,
val now: Long = 0L,
)
@Serializable
data class KanbanBoardsResponse(
val boards: List<KanbanBoard> = emptyList(),
val current: String? = null,
)
@Serializable
data class KanbanProject(
val id: String,
val slug: String,
val name: String,
val primaryPath: String? = null,
val icon: String? = null,
val color: String? = null,
)
@Serializable
data class KanbanProjectsResponse(
val projects: List<KanbanProject> = emptyList(),
)
@Serializable
data class CreateBoardBody(
val slug: String,
val name: String? = null,
val description: String? = null,
val projectId: String? = null,
val switch: Boolean = false,
)
@Serializable
data class CreateBoardResponse(
val board: KanbanBoard? = null,
val current: String? = null,
)
@Serializable
data class RenameBoardBody(
val name: String? = null,
val description: String? = null,
val defaultWorkdir: String? = null,
val projectId: String? = null,
)
@Serializable
data class RenameBoardResponse(
val board: KanbanBoard? = null,
)
@Serializable
data class DeleteBoardResponse(
val result: JsonElement? = null,
val current: String? = null,
)
@@ -0,0 +1,70 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class KanbanProfile(
val name: String,
val isDefault: Boolean = false,
val description: String = "",
val descriptionAuto: Boolean = false,
val model: String? = null,
val provider: String? = null,
val skillCount: Int = 0,
)
@Serializable
data class KanbanProfilesResponse(
val profiles: List<KanbanProfile> = emptyList(),
)
@Serializable
data class OrchestrationSettings(
val orchestratorProfile: String = "",
val defaultAssignee: String = "",
val autoDecompose: Boolean = true,
val autoPromoteChildren: Boolean = true,
val resolvedOrchestratorProfile: String = "",
val resolvedDefaultAssignee: String = "",
val activeProfile: String = "default",
)
@Serializable
data class OrchestrationSettingsUpdate(
val orchestratorProfile: String? = null,
val defaultAssignee: String? = null,
val autoDecompose: Boolean? = null,
val autoPromoteChildren: Boolean? = null,
)
@Serializable
data class AutoDescribeResponse(
val ok: Boolean = false,
val profile: String? = null,
val reason: String? = null,
val description: String? = null,
)
@Serializable
data class BoardExportResult(
val board: String,
val archive: String,
val size: Long = 0L,
)
@Serializable
data class BoardImportResult(
val board: String,
val name: String = "",
val renamed: Boolean = false,
val requestedBoard: String = "",
val counts: Map<String, Int> = emptyMap(),
val warnings: List<String> = emptyList(),
val current: String? = null,
)
@Serializable
data class DispatchResult(
val spawned: List<JsonElement> = emptyList(),
)
@@ -0,0 +1,255 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class TaskLinkCounts(
val parents: Int = 0,
val children: Int = 0,
)
@Serializable
data class TaskProgress(
val done: Int = 0,
val total: Int = 0,
)
@Serializable
data class TaskWarnings(
val count: Int = 0,
val highestSeverity: String? = null,
)
@Serializable
data class TaskDiagnosticAction(
val kind: String,
val label: String,
val payload: Map<String, JsonElement> = emptyMap(),
val suggested: Boolean = false,
)
@Serializable
data class TaskDiagnostic(
val kind: String,
val severity: String,
val title: String,
val detail: String,
val actions: List<TaskDiagnosticAction> = emptyList(),
val count: Int = 1,
val lastSeenAt: Long = 0L,
val data: Map<String, JsonElement> = emptyMap(),
)
@Serializable
data class TaskLinks(
val parents: List<String> = emptyList(),
val children: List<String> = emptyList(),
)
@Serializable
data class ChildTaskResult(
val id: String,
val title: String,
val status: String,
val latestSummary: String? = null,
val result: String? = null,
)
@Serializable
data class KanbanTask(
val id: String,
val title: String,
val body: String? = null,
val status: String,
val assignee: String? = null,
val priority: Int? = null,
val tenant: String? = null,
val createdAt: Long? = null,
val latestSummary: String? = null,
val commentCount: Int = 0,
val linkCounts: TaskLinkCounts? = null,
val progress: TaskProgress? = null,
val warnings: TaskWarnings? = null,
val startedAt: Long? = null,
val workerPid: Int? = null,
val lastHeartbeatAt: Long? = null,
) {
val description: String? get() = body
val assignedTo: String? get() = assignee
constructor(
id: String,
title: String,
description: String? = null,
status: String,
assignedTo: String? = null,
) : this(
id = id,
title = title,
body = description,
status = status,
assignee = assignedTo,
)
}
@Serializable
data class KanbanTaskFull(
val id: String,
val title: String,
val body: String? = null,
val status: String,
val assignee: String? = null,
val priority: Int? = null,
val tenant: String? = null,
val createdAt: Long? = null,
val latestSummary: String? = null,
val commentCount: Int = 0,
val linkCounts: TaskLinkCounts? = null,
val progress: TaskProgress? = null,
val warnings: TaskWarnings? = null,
val startedAt: Long? = null,
val workerPid: Int? = null,
val lastHeartbeatAt: Long? = null,
val result: String? = null,
val createdBy: String? = null,
val modelOverride: String? = null,
val providerOverride: String? = null,
val reasoningEffort: String? = null,
val completedAt: Long? = null,
val lastFailureError: String? = null,
val workspaceKind: String? = null,
val workspacePath: String? = null,
val branchName: String? = null,
val consecutiveFailures: Int = 0,
val diagnostics: List<TaskDiagnostic> = emptyList(),
) {
val description: String? get() = body
val assignedTo: String? get() = assignee
}
@Serializable
data class KanbanTaskDetailResponse(
val task: KanbanTaskFull,
val comments: List<KanbanComment> = emptyList(),
val events: List<KanbanDetailEvent> = emptyList(),
val attachments: List<KanbanAttachment>? = null,
val links: TaskLinks = TaskLinks(),
val childResults: List<ChildTaskResult> = emptyList(),
val runs: List<KanbanRun> = emptyList(),
)
@Serializable
data class CreateTaskBody(
val title: String,
val body: String? = null,
val assignee: String? = null,
val tenant: String? = null,
val priority: Int = 0,
val workspaceKind: String? = null,
val workspacePath: String? = null,
val parents: List<String> = emptyList(),
val triage: Boolean = false,
val maxRuntimeSeconds: Int? = null,
val skills: List<String>? = 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,
)
@Serializable
data class CreateTaskResponse(
val task: KanbanTask? = null,
val warning: String? = null,
)
@Serializable
data class UpdateTaskBody(
val status: String? = null,
val assignee: String? = null,
val priority: Int? = null,
val title: String? = null,
val body: String? = null,
val result: String? = null,
val blockReason: String? = null,
val summary: String? = null,
val metadata: Map<String, JsonElement>? = null,
val modelOverride: String? = null,
val providerOverride: String? = null,
val clearModelOverride: Boolean = false,
val reasoningEffort: String? = null,
val clearReasoningEffort: Boolean = false,
)
@Serializable
data class UpdateTaskResponse(
val task: KanbanTask? = null,
)
@Serializable
data class BulkTasksBody(
val ids: List<String>,
val status: String? = null,
val assignee: String? = null,
val priority: Int? = null,
val archive: Boolean = false,
val result: String? = null,
val summary: String? = null,
val metadata: Map<String, JsonElement>? = 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,
)
@Serializable
data class BulkTaskResult(
val id: String,
val ok: Boolean,
val error: String? = null,
)
@Serializable
data class BulkTasksResponse(
val results: List<BulkTaskResult> = emptyList(),
)
@Serializable
data class ReassignTaskBody(
val profile: String? = null,
val reclaimFirst: Boolean = true,
val reason: String? = null,
)
@Serializable
data class ReassignTaskResponse(
val ok: Boolean = true,
val taskId: String,
val assignee: String? = null,
)
@Serializable
data class ReclaimTaskBody(
val reason: String? = null,
)
@Serializable
data class ReclaimTaskResponse(
val ok: Boolean = true,
val taskId: String,
)
@Serializable
data class TaskEstimate(
val ok: Boolean,
val reason: String? = null,
val estTokens: Int? = null,
val complexity: String? = null,
val rationale: String? = null,
val model: String? = null,
)
@@ -30,6 +30,10 @@ object ApiClient {
}
}
/** The current [KanbanApiService] instance. Reuses [hermesApi]. */
val kanbanApi: KanbanApiService
get() = hermesApi
/** Force-rebuild the Retrofit client (e.g. after settings change). */
fun rebuild() {
synchronized(this) {
@@ -79,7 +83,15 @@ object ApiClient {
// ── Internal ─────────────────────────────────────────────────────────
private fun buildService(): HermesApiService {
private fun buildService(): HermesApiService =
buildRetrofit().create(HermesApiService::class.java).also {
service = it
}
private fun buildRetrofit(): Retrofit {
val cached = retrofit
if (cached != null) return cached
val logging =
HttpLoggingInterceptor().apply {
level =
@@ -129,15 +141,12 @@ object ApiClient {
.authenticator(TokenRefreshAuthenticator)
.build()
val rf =
Retrofit
.Builder()
.baseUrl(AuthManager.endpointForBuild().baseUrl)
.client(okHttp)
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
.build()
.also { retrofit = it }
return rf.create(HermesApiService::class.java)
return Retrofit
.Builder()
.baseUrl(AuthManager.endpointForBuild().baseUrl)
.client(okHttp)
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
.build()
.also { retrofit = it }
}
}
@@ -154,7 +154,7 @@ import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming
interface HermesApiService {
interface HermesApiService : KanbanApiService {
@GET("api/skills/content")
suspend fun getSkillContent(
@Query("name") name: String,
@@ -0,0 +1,195 @@
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.BulkTasksBody
import com.m57.hermescontrol.data.model.BulkTasksResponse
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.DeleteBoardResponse
import com.m57.hermescontrol.data.model.DispatchResult
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.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.OrchestrationSettings
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
import com.m57.hermescontrol.data.model.ReassignTaskBody
import com.m57.hermescontrol.data.model.ReassignTaskResponse
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.TaskEstimate
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.model.WorkerLog
import okhttp3.MultipartBody
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming
interface KanbanApiService {
@GET("api/plugins/kanban/boards")
suspend fun getBoards(
@Query("include_archived") includeArchived: Boolean = false,
): Response<KanbanBoardsResponse>
@GET("api/plugins/kanban/board")
suspend fun getBoard(
@Query("board") board: String? = null,
@Query("include_archived") includeArchived: Boolean = false,
@Query("tenant") tenant: String? = null,
): Response<KanbanBoardResponse>
@POST("api/plugins/kanban/boards")
suspend fun createBoard(
@Body body: CreateBoardBody,
): Response<CreateBoardResponse>
@PATCH("api/plugins/kanban/boards/{slug}")
suspend fun updateBoard(
@Path("slug") slug: String,
@Body body: RenameBoardBody,
): Response<RenameBoardResponse>
@DELETE("api/plugins/kanban/boards/{slug}")
suspend fun deleteBoard(
@Path("slug") slug: String,
@Query("delete") delete: Boolean = false,
): Response<DeleteBoardResponse>
@POST("api/plugins/kanban/boards/{slug}/switch")
suspend fun switchBoard(
@Path("slug") slug: String,
): Response<Unit>
@GET("api/plugins/kanban/tasks/{id}")
suspend fun getTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
): Response<KanbanTaskDetailResponse>
@POST("api/plugins/kanban/tasks")
suspend fun createTask(
@Query("board") board: String? = null,
@Body body: CreateTaskBody,
): Response<CreateTaskResponse>
@PATCH("api/plugins/kanban/tasks/{id}")
suspend fun updateTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
@Body body: UpdateTaskBody,
): Response<UpdateTaskResponse>
@DELETE("api/plugins/kanban/tasks/{id}")
suspend fun deleteTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
): Response<Unit>
@POST("api/plugins/kanban/tasks/bulk")
suspend fun bulkTasks(
@Query("board") board: String? = null,
@Body body: BulkTasksBody,
): Response<BulkTasksResponse>
@POST("api/plugins/kanban/tasks/{id}/comments")
suspend fun addComment(
@Path("id") taskId: String,
@Query("board") board: String? = null,
@Body body: Map<String, String>,
): Response<Unit>
@POST("api/plugins/kanban/tasks/{id}/reassign")
suspend fun reassignTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
@Body body: ReassignTaskBody,
): Response<ReassignTaskResponse>
@POST("api/plugins/kanban/tasks/{id}/reclaim")
suspend fun reclaimTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
@Body body: ReclaimTaskBody = ReclaimTaskBody(),
): Response<ReclaimTaskResponse>
@GET("api/plugins/kanban/tasks/{id}/log")
suspend fun getTaskLog(
@Path("id") taskId: String,
@Query("tail") tail: Int = 16384,
@Query("board") board: String? = null,
): Response<WorkerLog>
@POST("api/plugins/kanban/tasks/{id}/estimate")
suspend fun estimateTask(
@Path("id") taskId: String,
@Query("board") board: String? = null,
): Response<TaskEstimate>
@POST("api/plugins/kanban/estimate")
suspend fun estimateNew(
@Body body: Map<String, String>,
): Response<TaskEstimate>
@Multipart
@POST("api/plugins/kanban/tasks/{id}/attachments")
suspend fun uploadAttachment(
@Path("id") taskId: String,
@Query("board") board: String? = null,
@Part file: MultipartBody.Part,
): Response<AttachmentUploadResponse>
@Streaming
@GET("api/plugins/kanban/attachments/{id}")
suspend fun downloadAttachment(
@Path("id") attachmentId: Long,
@Query("board") board: String? = null,
): Response<ResponseBody>
@GET("api/plugins/kanban/profiles")
suspend fun getKanbanProfiles(): Response<KanbanProfilesResponse>
@PATCH("api/plugins/kanban/profiles/{name}")
suspend fun updateProfileDescription(
@Path("name") name: String,
@Body body: Map<String, String>,
): Response<Unit>
@POST("api/plugins/kanban/profiles/{name}/describe-auto")
suspend fun autoDescribeProfile(
@Path("name") name: String,
@Body body: Map<String, Boolean> = mapOf("overwrite" to true),
): Response<AutoDescribeResponse>
@GET("api/plugins/kanban/orchestration")
suspend fun getOrchestration(): Response<OrchestrationSettings>
@PUT("api/plugins/kanban/orchestration")
suspend fun updateOrchestration(
@Body body: OrchestrationSettingsUpdate,
): Response<OrchestrationSettings>
@GET("api/plugins/kanban/projects")
suspend fun getProjects(): Response<KanbanProjectsResponse>
@POST("api/plugins/kanban/dispatch")
suspend fun nudgeDispatcher(
@Query("board") board: String? = null,
): Response<DispatchResult>
}
@@ -0,0 +1,366 @@
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.BulkTasksBody
import com.m57.hermescontrol.data.model.BulkTasksResponse
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.DeleteBoardResponse
import com.m57.hermescontrol.data.model.DispatchResult
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.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.OrchestrationSettings
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
import com.m57.hermescontrol.data.model.ReassignTaskBody
import com.m57.hermescontrol.data.model.ReassignTaskResponse
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.TaskEstimate
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.model.WorkerLog
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.KanbanApiService
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MultipartBody
import okhttp3.ResponseBody
interface KanbanRepository {
suspend fun getBoards(includeArchived: Boolean = false): NetworkResult<KanbanBoardsResponse>
suspend fun getBoard(
board: String,
includeArchived: Boolean = false,
tenant: String? = null,
): NetworkResult<KanbanBoardResponse>
suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse>
suspend fun updateBoard(
slug: String,
body: RenameBoardBody,
): NetworkResult<RenameBoardResponse>
suspend fun deleteBoard(
slug: String,
delete: Boolean = false,
): NetworkResult<DeleteBoardResponse>
suspend fun getTask(
taskId: String,
board: String?,
): NetworkResult<KanbanTaskDetailResponse>
suspend fun createTask(
board: String?,
body: CreateTaskBody,
): NetworkResult<CreateTaskResponse>
suspend fun updateTask(
taskId: String,
board: String?,
body: UpdateTaskBody,
): NetworkResult<UpdateTaskResponse>
suspend fun deleteTask(
taskId: String,
board: String?,
): NetworkResult<Unit>
suspend fun bulkTasks(
board: String?,
body: BulkTasksBody,
): NetworkResult<BulkTasksResponse>
suspend fun addComment(
taskId: String,
board: String?,
body: String,
): NetworkResult<Unit>
suspend fun reassignTask(
taskId: String,
board: String?,
profile: String?,
reclaimFirst: Boolean = true,
): NetworkResult<ReassignTaskResponse>
suspend fun reclaimTask(
taskId: String,
board: String?,
reason: String? = null,
): NetworkResult<ReclaimTaskResponse>
suspend fun getTaskLog(
taskId: String,
board: String?,
tail: Int = 16384,
): NetworkResult<WorkerLog>
suspend fun estimateTask(
taskId: String,
board: String?,
): NetworkResult<TaskEstimate>
suspend fun estimateNew(
title: String,
body: String?,
): NetworkResult<TaskEstimate>
suspend fun uploadAttachment(
taskId: String,
board: String?,
file: MultipartBody.Part,
): NetworkResult<AttachmentUploadResponse>
suspend fun downloadAttachment(
attachmentId: Long,
board: String?,
): NetworkResult<ResponseBody>
suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse>
suspend fun updateProfileDescription(
name: String,
description: String,
): NetworkResult<Unit>
suspend fun autoDescribeProfile(name: String): NetworkResult<AutoDescribeResponse>
suspend fun getOrchestration(): NetworkResult<OrchestrationSettings>
suspend fun updateOrchestration(body: OrchestrationSettingsUpdate): NetworkResult<OrchestrationSettings>
suspend fun getProjects(): NetworkResult<KanbanProjectsResponse>
suspend fun nudgeDispatcher(board: String?): NetworkResult<DispatchResult>
}
class KanbanRepositoryImpl(
private val apiProvider: () -> KanbanApiService = { ApiClient.kanbanApi },
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
) : KanbanRepository {
override suspend fun getBoards(includeArchived: Boolean): NetworkResult<KanbanBoardsResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getBoards(includeArchived) }
}
override suspend fun getBoard(
board: String,
includeArchived: Boolean,
tenant: String?,
): NetworkResult<KanbanBoardResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getBoard(board = board, includeArchived = includeArchived, tenant = tenant) }
}
override suspend fun createBoard(body: CreateBoardBody): NetworkResult<CreateBoardResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().createBoard(body) }
}
override suspend fun updateBoard(
slug: String,
body: RenameBoardBody,
): NetworkResult<RenameBoardResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().updateBoard(slug, body) }
}
override suspend fun deleteBoard(
slug: String,
delete: Boolean,
): NetworkResult<DeleteBoardResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().deleteBoard(slug, delete) }
}
override suspend fun getTask(
taskId: String,
board: String?,
): NetworkResult<KanbanTaskDetailResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getTask(taskId = taskId, board = board) }
}
override suspend fun createTask(
board: String?,
body: CreateTaskBody,
): NetworkResult<CreateTaskResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().createTask(board = board, body = body) }
}
override suspend fun updateTask(
taskId: String,
board: String?,
body: UpdateTaskBody,
): NetworkResult<UpdateTaskResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().updateTask(taskId = taskId, board = board, body = body) }
}
override suspend fun deleteTask(
taskId: String,
board: String?,
): NetworkResult<Unit> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().deleteTask(taskId = taskId, board = board) }
}
override suspend fun bulkTasks(
board: String?,
body: BulkTasksBody,
): NetworkResult<BulkTasksResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().bulkTasks(board = board, body = body) }
}
override suspend fun addComment(
taskId: String,
board: String?,
body: String,
): NetworkResult<Unit> =
withContext(ioDispatcher) {
safeApiCall {
apiProvider().addComment(
taskId = taskId,
board = board,
body = mapOf("body" to body, "author" to "mobile"),
)
}
}
override suspend fun reassignTask(
taskId: String,
board: String?,
profile: String?,
reclaimFirst: Boolean,
): NetworkResult<ReassignTaskResponse> =
withContext(ioDispatcher) {
safeApiCall {
apiProvider().reassignTask(
taskId = taskId,
board = board,
body = ReassignTaskBody(profile = profile, reclaimFirst = reclaimFirst),
)
}
}
override suspend fun reclaimTask(
taskId: String,
board: String?,
reason: String?,
): NetworkResult<ReclaimTaskResponse> =
withContext(ioDispatcher) {
safeApiCall {
apiProvider().reclaimTask(
taskId = taskId,
board = board,
body = ReclaimTaskBody(reason = reason),
)
}
}
override suspend fun getTaskLog(
taskId: String,
board: String?,
tail: Int,
): NetworkResult<WorkerLog> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getTaskLog(taskId = taskId, tail = tail, board = board) }
}
override suspend fun estimateTask(
taskId: String,
board: String?,
): NetworkResult<TaskEstimate> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().estimateTask(taskId = taskId, board = board) }
}
override suspend fun estimateNew(
title: String,
body: String?,
): NetworkResult<TaskEstimate> =
withContext(ioDispatcher) {
val payload =
buildMap {
put("title", title)
if (!body.isNullOrBlank()) put("body", body)
}
safeApiCall { apiProvider().estimateNew(payload) }
}
override suspend fun uploadAttachment(
taskId: String,
board: String?,
file: MultipartBody.Part,
): NetworkResult<AttachmentUploadResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().uploadAttachment(taskId = taskId, board = board, file = file) }
}
override suspend fun downloadAttachment(
attachmentId: Long,
board: String?,
): NetworkResult<ResponseBody> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().downloadAttachment(attachmentId = attachmentId, board = board) }
}
override suspend fun getProfiles(): NetworkResult<KanbanProfilesResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getKanbanProfiles() }
}
override suspend fun updateProfileDescription(
name: String,
description: String,
): NetworkResult<Unit> =
withContext(ioDispatcher) {
safeApiCall {
apiProvider().updateProfileDescription(
name = name,
body = mapOf("description" to description),
)
}
}
override suspend fun autoDescribeProfile(name: String): NetworkResult<AutoDescribeResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().autoDescribeProfile(name) }
}
override suspend fun getOrchestration(): NetworkResult<OrchestrationSettings> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getOrchestration() }
}
override suspend fun updateOrchestration(body: OrchestrationSettingsUpdate): NetworkResult<OrchestrationSettings> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().updateOrchestration(body) }
}
override suspend fun getProjects(): NetworkResult<KanbanProjectsResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().getProjects() }
}
override suspend fun nudgeDispatcher(board: String?): NetworkResult<DispatchResult> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().nudgeDispatcher(board) }
}
}
@@ -0,0 +1,125 @@
package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.ws.KanbanEventsClient
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
import kotlinx.coroutines.CoroutineScope
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.launch
sealed interface KanbanSyncStatus {
data object Live : KanbanSyncStatus
data class Polling(
val lastSyncEpoch: Long = System.currentTimeMillis(),
) : KanbanSyncStatus
data object AuthFailed : KanbanSyncStatus
data object Disconnected : KanbanSyncStatus
}
class KanbanSyncCoordinator(
private val repository: KanbanRepository,
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
private val reloadDebounceMs: Long = 250L,
private val pollIntervalMs: Long = 60_000L,
private val nudgeDebounceMs: Long = 400L,
) {
private var eventsClient: KanbanEventsClient? = null
private var activeBoard: String? = null
private var reloadJob: Job? = null
private var pollJob: Job? = null
private var nudgeJob: Job? = null
private val _syncStatus = MutableStateFlow<KanbanSyncStatus>(KanbanSyncStatus.Disconnected)
val syncStatus: StateFlow<KanbanSyncStatus> = _syncStatus.asStateFlow()
fun connect(
scope: CoroutineScope,
board: String,
onReload: () -> Unit,
) {
if (activeBoard == board && _syncStatus.value == KanbanSyncStatus.Live) return
activeBoard = board
val client = eventsClient ?: eventsClientProvider().also { eventsClient = it }
client.connect(
scope = scope,
board = board,
onEvents = {
reloadJob?.cancel()
reloadJob =
scope.launch {
delay(reloadDebounceMs)
onReload()
}
},
onStatus = { status ->
when (status) {
KanbanLiveStatus.CONNECTED -> {
_syncStatus.value = KanbanSyncStatus.Live
}
KanbanLiveStatus.DISCONNECTED -> {
_syncStatus.value = KanbanSyncStatus.Polling()
}
KanbanLiveStatus.AUTH_FAILED -> {
_syncStatus.value = KanbanSyncStatus.AuthFailed
}
}
},
)
}
fun startVisiblePolling(
scope: CoroutineScope,
board: String,
onPoll: () -> Unit,
) {
pollJob?.cancel()
pollJob =
scope.launch {
while (true) {
delay(pollIntervalMs)
if (_syncStatus.value !is KanbanSyncStatus.Live) {
onPoll()
_syncStatus.value = KanbanSyncStatus.Polling(System.currentTimeMillis())
}
}
}
}
fun stopVisiblePolling() {
pollJob?.cancel()
pollJob = null
}
fun scheduleNudge(
scope: CoroutineScope,
board: String,
) {
nudgeJob?.cancel()
nudgeJob =
scope.launch {
delay(nudgeDebounceMs)
repository.nudgeDispatcher(board)
}
}
fun disconnect() {
activeBoard = null
reloadJob?.cancel()
reloadJob = null
pollJob?.cancel()
pollJob = null
nudgeJob?.cancel()
nudgeJob = null
eventsClient?.disconnect()
_syncStatus.value = KanbanSyncStatus.Disconnected
}
}
@@ -8,11 +8,19 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
@@ -31,11 +39,37 @@ enum class KanbanLiveStatus {
AUTH_FAILED,
}
@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
object StringOrLongSerializer : KSerializer<String?> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("StringOrLong", PrimitiveKind.STRING)
override fun serialize(
encoder: Encoder,
value: String?,
) {
if (value == null) {
encoder.encodeNull()
} else {
encoder.encodeString(value)
}
}
override fun deserialize(decoder: Decoder): String? {
val jsonDecoder = decoder as? JsonDecoder ?: return decoder.decodeString()
val element = jsonDecoder.decodeJsonElement()
if (element is JsonPrimitive) {
return element.content
}
return null
}
}
/** One row of the backend's ``task_events`` table (``/api/plugins/kanban/events``). */
@Serializable
data class KanbanEvent(
val id: Long,
@SerialName("task_id") val taskId: String? = null,
@Serializable(with = StringOrLongSerializer::class)
@SerialName("run_id") val runId: String? = null,
val kind: String,
/** Partial update (e.g. ``{"status": "done"}``) — never applied directly. */
@@ -17,13 +17,21 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
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.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Person
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.Icon
import androidx.compose.material3.IconButton
@@ -32,6 +40,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryScrollableTabRow
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
@@ -46,6 +55,8 @@ import androidx.compose.ui.text.style.TextOverflow
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.KanbanTask
import com.m57.hermescontrol.ui.common.ErrorState
@@ -54,6 +65,9 @@ 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.KanbanCreateTaskDialog
import com.m57.hermescontrol.ui.kanban.components.KanbanFilterSheet
import com.m57.hermescontrol.ui.kanban.components.KanbanTaskCard
private const val DEFAULT_COLUMN = "todo"
@@ -67,14 +81,26 @@ fun KanbanScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
var query by remember { mutableStateOf("") }
var selectedAssignee by remember { mutableStateOf<String?>(null) }
var selectedTenant by remember { mutableStateOf<String?>(null) }
var includeArchived by remember { mutableStateOf(false) }
var groupRunning by remember { mutableStateOf(false) }
var showFilterSheet by remember { mutableStateOf(false) }
val filteredTasks =
remember(query, state.tasks) {
remember(query, state.tasks, selectedAssignee, selectedTenant) {
state.tasks.filter { task ->
task.title.contains(query, ignoreCase = true) ||
task.description?.contains(query, ignoreCase = true) == true ||
task.status.contains(query, ignoreCase = true) ||
task.assignedTo?.contains(query, ignoreCase = true) == true
val matchesQuery =
query.isBlank() ||
task.title.contains(query, ignoreCase = true) ||
task.description?.contains(query, ignoreCase = true) == true ||
task.status.contains(query, ignoreCase = true) ||
task.assignedTo?.contains(query, ignoreCase = true) == true ||
task.id.contains(query, ignoreCase = true)
val matchesAssignee =
selectedAssignee == null || task.assignee.equals(selectedAssignee, ignoreCase = true)
val matchesTenant = selectedTenant == null || task.tenant.equals(selectedTenant, ignoreCase = true)
matchesQuery && matchesAssignee && matchesTenant
}
}
@@ -87,6 +113,17 @@ fun KanbanScreen(
var confirmTarget by remember { mutableStateOf<Pair<KanbanTask, KanbanTaskAction>?>(null) }
var summaryTarget by remember { mutableStateOf<Pair<KanbanTask, KanbanTaskAction>?>(null) }
// Multi-select & Bulk operations state
var isMultiSelectMode by remember { mutableStateOf(false) }
var selectedTaskIds by remember { mutableStateOf(setOf<String>()) }
var showBulkMoveDialog 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 showDeleteBoardDialog by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
viewModel.loadBoards()
}
@@ -96,6 +133,60 @@ fun KanbanScreen(
HermesScaffold(
title = { Text(stringResource(R.string.kanban_board_title)) },
navigationIcon = onOpenDrawer?.let { NavIcon.Menu(it) },
actions = {
IconButton(onClick = { showFilterSheet = true }) {
Icon(
imageVector = Icons.Default.FilterList,
contentDescription = "Filter",
)
}
Box {
IconButton(onClick = { showBoardMenu = true }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Board options",
)
}
DropdownMenu(
expanded = showBoardMenu,
onDismissRequest = { showBoardMenu = false },
) {
DropdownMenuItem(
text = { Text("New Board") },
onClick = {
showBoardMenu = false
showCreateBoardDialog = true
},
)
if (state.selectedBoard != null) {
DropdownMenuItem(
text = { Text("Rename Board") },
onClick = {
showBoardMenu = false
showRenameBoardDialog = true
},
)
if (state.boards.size > 1) {
DropdownMenuItem(
text = { Text("Delete Board") },
onClick = {
showBoardMenu = false
showDeleteBoardDialog = true
},
)
}
}
DropdownMenuItem(
text = { Text(if (isMultiSelectMode) "Exit Multi-Select" else "Select Multiple") },
onClick = {
showBoardMenu = false
isMultiSelectMode = !isMultiSelectMode
if (!isMultiSelectMode) selectedTaskIds = emptySet()
},
)
}
}
},
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadBoards() },
) { paddingValues ->
@@ -142,7 +233,7 @@ fun KanbanScreen(
Tab(
selected = board == state.selectedBoard,
onClick = { viewModel.selectBoard(board) },
text = { Text(board.name) },
text = { Text(board.displayName) },
)
}
}
@@ -167,6 +258,14 @@ fun KanbanScreen(
) {
LiveStatusPill(isLive = state.isLive)
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = { showFilterSheet = true }) {
Icon(
imageVector = Icons.Filled.FilterList,
contentDescription = "Filters",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.width(4.dp))
IconButton(onClick = { showAddTaskDialog = true }) {
Icon(
imageVector = Icons.Filled.Add,
@@ -228,9 +327,41 @@ fun KanbanScreen(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(colTasks, key = { it.id }) { task ->
TaskCard(
KanbanTaskCard(
task = task,
onTaskClick = { taskForActions = it },
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
}
},
)
}
}
@@ -243,10 +374,16 @@ fun KanbanScreen(
}
if (showAddTaskDialog) {
AddTaskDialog(
KanbanCreateTaskDialog(
columns = state.columns,
defaultColumn = state.columns.firstOrNull()?.name ?: DEFAULT_COLUMN,
profiles = state.profiles,
existingTasks = state.tasks,
isCreating = state.isCreatingTask,
onEstimate = viewModel::estimateNewTask,
onDismiss = { showAddTaskDialog = false },
onConfirm = { title, desc ->
viewModel.createTask(title, desc, state.columns.firstOrNull()?.name ?: DEFAULT_COLUMN)
onConfirm = { body, targetStatus ->
viewModel.createTask(body, targetStatus)
showAddTaskDialog = false
},
)
@@ -288,48 +425,281 @@ fun KanbanScreen(
summaryTarget?.let { (task, action) ->
CompleteTaskDialog(
onConfirm = { summary -> viewModel.moveTask(task, action, summary) },
onConfirm = { summary ->
summaryTarget = null
viewModel.moveTask(task, action, summary)
},
onDismiss = { summaryTarget = null },
)
}
}
}
}
}
}
@Composable
fun TaskCard(
task: KanbanTask,
onTaskClick: (KanbanTask) -> Unit,
) {
Card(onClick = { onTaskClick(task) }, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
Text(text = task.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(4.dp))
task.description?.let {
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
task.assignedTo?.let { assignee ->
Spacer(modifier = Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Person,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = assignee,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (showFilterSheet) {
val assignees =
remember(state.tasks) {
state.tasks
.mapNotNull { it.assignee }
.distinct()
.sorted()
}
val tenants =
remember(state.tasks) {
state.tasks
.mapNotNull { it.tenant }
.distinct()
.sorted()
}
KanbanFilterSheet(
assignees = assignees,
tenants = tenants,
selectedAssignee = selectedAssignee,
selectedTenant = selectedTenant,
includeArchived = includeArchived,
groupRunning = groupRunning,
onSelectAssignee = { selectedAssignee = it },
onSelectTenant = { selectedTenant = it },
onToggleIncludeArchived = { includeArchived = it },
onToggleGroupRunning = { groupRunning = it },
onClearFilters = {
selectedAssignee = null
selectedTenant = null
includeArchived = false
groupRunning = false
},
onDismiss = { showFilterSheet = false },
)
}
if (isMultiSelectMode) {
Card(
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh,
),
modifier =
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(),
) {
Text(
text = "${selectedTaskIds.size} selected",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TextButton(
onClick = {
selectedTaskIds =
if (selectedTaskIds.size == filteredTasks.size) {
emptySet()
} else {
filteredTasks.map { it.id }.toSet()
}
},
) {
Text(if (selectedTaskIds.size == filteredTasks.size) "Clear" else "All")
}
Button(
onClick = { showBulkMoveDialog = true },
enabled = selectedTaskIds.isNotEmpty(),
) {
Text("Move")
}
Button(
onClick = {
viewModel.bulkArchive(selectedTaskIds.toList())
isMultiSelectMode = false
selectedTaskIds = emptySet()
},
enabled = selectedTaskIds.isNotEmpty(),
) {
Text("Archive")
}
IconButton(
onClick = {
isMultiSelectMode = false
selectedTaskIds = emptySet()
},
) {
Icon(Icons.Default.Close, contentDescription = "Close multi-select")
}
}
}
}
}
if (showBulkMoveDialog) {
AlertDialog(
onDismissRequest = { showBulkMoveDialog = false },
title = { Text("Move ${selectedTaskIds.size} tasks") },
text = {
Column {
state.columns.forEach { col ->
TextButton(
onClick = {
viewModel.bulkMove(selectedTaskIds.toList(), col.name)
showBulkMoveDialog = false
isMultiSelectMode = false
selectedTaskIds = emptySet()
},
modifier = Modifier.fillMaxWidth(),
) {
Text(col.name.uppercase(), modifier = Modifier.fillMaxWidth())
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = { showBulkMoveDialog = false }) {
Text("Cancel")
}
},
)
}
if (showCreateBoardDialog) {
var boardSlug by remember { mutableStateOf("") }
var boardName by remember { mutableStateOf("") }
var boardDesc by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = { showCreateBoardDialog = false },
title = { Text("Create New Board") },
text = {
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") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = boardName,
onValueChange = { boardName = it },
label = { Text("Display Name") },
placeholder = { Text("e.g. Sprint 3") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = boardDesc,
onValueChange = { boardDesc = it },
label = { Text("Description") },
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Button(
onClick = {
if (boardSlug.isNotBlank()) {
viewModel.createBoard(
slug = boardSlug.trim(),
name = boardName.trim().ifBlank { null },
description = boardDesc.trim().ifBlank { null },
)
showCreateBoardDialog = false
}
},
enabled = boardSlug.isNotBlank(),
) {
Text("Create")
}
},
dismissButton = {
TextButton(onClick = { showCreateBoardDialog = false }) {
Text("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 (showDeleteBoardDialog && state.selectedBoard != null) {
AlertDialog(
onDismissRequest = { showDeleteBoardDialog = false },
title = { Text("Delete Board") },
text = {
Text(
"Are you sure you want to delete board '${state.selectedBoard?.displayName}'? This action cannot be undone.",
)
},
confirmButton = {
Button(
onClick = {
state.selectedBoard?.let { board ->
viewModel.deleteBoard(board.id)
}
showDeleteBoardDialog = false
},
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
),
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { showDeleteBoardDialog = false }) {
Text("Cancel")
}
},
)
}
}
}
}
@@ -360,51 +730,6 @@ private fun LiveStatusPill(isLive: Boolean) {
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddTaskDialog(
onDismiss: () -> Unit,
onConfirm: (title: String, desc: String?) -> Unit,
) {
var title by remember { mutableStateOf("") }
var desc by remember { mutableStateOf("") }
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.kanban_add_new_task)) },
text = {
Column {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text(stringResource(R.string.kanban_task_title)) },
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = desc,
onValueChange = { desc = it },
label = { Text(stringResource(R.string.kanban_task_desc)) },
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Button(
onClick = { if (title.isNotBlank()) onConfirm(title, desc.ifBlank { null }) },
enabled = title.isNotBlank(),
) {
Text(stringResource(R.string.action_add))
}
},
dismissButton = {
Button(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
private fun KanbanTaskAction.labelRes(): Int =
when (this) {
KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage
@@ -0,0 +1,750 @@
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.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
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.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.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
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.Tab
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.platform.LocalContext
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.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.MarkdownText
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.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KanbanTaskScreen(
boardSlug: String,
taskId: String,
onBack: () -> Unit,
modifier: Modifier = Modifier,
viewModel: KanbanTaskViewModel = viewModel { KanbanTaskViewModel() },
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
var selectedTabIndex by remember { mutableIntStateOf(0) }
val tabs = listOf("Overview", "Discussion", "Runs", "Activity", "Files")
LaunchedEffect(boardSlug, taskId) {
viewModel.loadTask(boardSlug, taskId)
}
ToastEffect(toastMessage = state.toastMessage, onClearToast = viewModel::clearToast)
HermesScaffold(
title = {
Column {
Text(
text = state.detail?.task?.title ?: "Task Details",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
)
Text(
text = "#${taskId.removePrefix("t_").take(8)} • $boardSlug",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
navigationIcon = NavIcon.Back(onBack),
drawerGesturesEnabled = false,
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadTask(boardSlug, taskId) },
) { paddingValues ->
when {
state.isLoading && state.detail == null -> {
SkeletonListState(modifier = Modifier.padding(paddingValues))
}
state.errorMessage != null && state.detail == null -> {
ErrorState(
message = state.errorMessage ?: "",
onRetry = { viewModel.loadTask(boardSlug, taskId) },
modifier = Modifier.padding(paddingValues),
)
}
state.detail != null -> {
val detail = state.detail!!
val task = detail.task
Column(modifier = Modifier.fillMaxSize()) {
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTabIndex == index,
onClick = { selectedTabIndex = index },
text = { Text(title) },
)
}
}
when (selectedTabIndex) {
0 -> {
TaskOverviewTab(
task = task,
onSaveDescription = { newBody ->
viewModel.updateDescription(boardSlug, taskId, newBody)
},
onReclaim = {
viewModel.reclaim(boardSlug, taskId)
},
)
}
1 -> {
TaskDiscussionTab(
comments = detail.comments,
isRunning = task.status.equals("running", ignoreCase = true),
isSubmitting = state.isSubmittingComment,
onAddComment = { body ->
viewModel.addComment(boardSlug, taskId, body)
},
onNoteAndRequeue = { body ->
viewModel.postNoteAndRequeue(boardSlug, taskId, body)
},
)
}
2 -> {
TaskRunsTab(
runs = detail.runs,
workerLog = state.workerLog,
)
}
3 -> {
TaskActivityTab(
events = detail.events,
)
}
4 -> {
TaskFilesTab(
attachments = detail.attachments ?: emptyList(),
isUploading = state.isUploadingAttachment,
onUpload = { filename, mimeType, bytes ->
viewModel.uploadAttachment(boardSlug, taskId, filename, mimeType, bytes)
},
)
}
}
}
}
}
}
}
@Composable
private fun TaskOverviewTab(
task: com.m57.hermescontrol.data.model.KanbanTaskFull,
onSaveDescription: (String) -> Unit,
onReclaim: () -> Unit,
modifier: Modifier = Modifier,
) {
var isEditingDescription by remember { mutableStateOf(false) }
var descriptionDraft by remember(task.body) { mutableStateOf(task.body ?: "") }
val statusColors = LocalHermesStatusColors.current
LazyColumn(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
item {
// Diagnostics alerts
if (task.diagnostics.isNotEmpty()) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) {
task.diagnostics.forEach { diag ->
Card(
colors =
CardDefaults.cardColors(
containerColor = statusColors.error.copy(alpha = 0.15f),
),
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
tint = statusColors.error,
modifier = Modifier.size(16.dp),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = diag.title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = statusColors.error,
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = diag.detail,
style = MaterialTheme.typography.bodySmall,
)
if (diag.actions.any { it.kind == "reclaim" }) {
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = onReclaim,
shape = RoundedCornerShape(6.dp),
) {
Text("Reclaim task")
}
}
}
}
}
}
}
// Metadata card
Card(
shape = RoundedCornerShape(8.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = "Attributes",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(8.dp))
MetaRow(label = "Status", value = task.status.uppercase())
task.assignee?.let { MetaRow(label = "Assignee", value = it) }
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()) }
}
}
// Description 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 = "Description",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
if (!isEditingDescription) {
IconButton(onClick = { isEditingDescription = true }) {
Icon(
Icons.Filled.Edit,
contentDescription = "Edit Description",
modifier = Modifier.size(16.dp),
)
}
}
}
if (isEditingDescription) {
OutlinedTextField(
value = descriptionDraft,
onValueChange = { descriptionDraft = it },
modifier = Modifier.fillMaxWidth(),
minLines = 4,
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
OutlinedButton(onClick = { isEditingDescription = false }) {
Text("Cancel")
}
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = {
onSaveDescription(descriptionDraft)
isEditingDescription = false
}) {
Text("Save")
}
}
} else {
SelectionContainer {
MarkdownText(
text = task.body?.ifBlank { "No description provided." } ?: "No description provided.",
textColor = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
// Result/Summary Section if completed
val summary = task.result ?: task.latestSummary
if (!summary.isNullOrBlank()) {
Card(
shape = RoundedCornerShape(8.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = "Latest Outcome / Summary",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(6.dp))
SelectionContainer {
MarkdownText(
text = summary,
textColor = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@Composable
private fun TaskDiscussionTab(
comments: List<com.m57.hermescontrol.data.model.KanbanComment>,
isRunning: Boolean,
isSubmitting: Boolean,
onAddComment: (String) -> Unit,
onNoteAndRequeue: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var commentText by remember { mutableStateOf("") }
Column(modifier = modifier.fillMaxSize()) {
LazyColumn(
modifier =
Modifier
.weight(1f)
.padding(horizontal = 16.dp, vertical = 8.dp),
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,
)
}
}
} else {
items(comments, key = { it.id }) { comment ->
Card(
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(10.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = comment.author,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
)
}
Spacer(modifier = Modifier.height(4.dp))
SelectionContainer {
MarkdownText(
text = comment.body,
textColor = MaterialTheme.colorScheme.onSurface,
)
}
}
}
}
}
}
HorizontalDivider()
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))
}
Button(
onClick = {
onAddComment(commentText)
commentText = ""
},
enabled = commentText.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")
}
}
}
}
}
}
@Composable
private fun TaskRunsTab(
runs: List<com.m57.hermescontrol.data.model.KanbanRun>,
workerLog: com.m57.hermescontrol.data.model.WorkerLog?,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
item {
Text("Attempt History", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
}
if (runs.isEmpty()) {
item {
Text("No worker attempts recorded yet.", style = MaterialTheme.typography.bodySmall)
}
} else {
items(runs, key = { it.id }) { run ->
Card(
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
"Run #${run.id}",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
)
Text(
text = (run.outcome ?: run.status).uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color =
if (run.outcome == "completed") {
LocalHermesStatusColors.current.success
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
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)
}
}
}
}
}
// Live Log Output
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)
Spacer(modifier = Modifier.height(6.dp))
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = workerLog.content,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(10.dp),
)
}
}
}
}
}
@Composable
private fun TaskActivityTab(
events: List<com.m57.hermescontrol.data.model.KanbanDetailEvent>,
modifier: Modifier = Modifier,
) {
LazyColumn(
modifier = modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Text("Event Timeline", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
}
if (events.isEmpty()) {
item {
Text("No activity events.", style = MaterialTheme.typography.bodySmall)
}
} else {
items(events, key = { it.id }) { event ->
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier =
Modifier
.size(8.dp)
.background(MaterialTheme.colorScheme.primary, shape = CircleShape),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = event.kind,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.SemiBold,
)
}
}
}
}
}
@Composable
private fun MetaRow(
label: String,
value: String,
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 2.dp),
) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(text = value, style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Medium)
}
}
@Composable
private fun TaskFilesTab(
attachments: List<com.m57.hermescontrol.data.model.KanbanAttachment>,
isUploading: Boolean,
onUpload: (filename: String, mimeType: String, bytes: ByteArray) -> Unit,
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 mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
if (bytes != null) {
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),
) {
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 (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,
)
}
}
} 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(),
) {
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,
)
}
}
}
}
}
}
}
@@ -0,0 +1,301 @@
package com.m57.hermescontrol.ui.kanban
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.UpdateTaskBody
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.repository.KanbanRepositoryImpl
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
import okhttp3.RequestBody.Companion.toRequestBody
data class KanbanTaskUiState(
val isLoading: Boolean = false,
val detail: KanbanTaskDetailResponse? = null,
val workerLog: WorkerLog? = null,
val errorMessage: String? = null,
val toastMessage: String? = null,
val isSubmittingComment: Boolean = false,
val isSavingDescription: Boolean = false,
val isReclaiming: Boolean = false,
val isUploadingAttachment: Boolean = false,
)
class KanbanTaskViewModel(
private val repository: KanbanRepository = KanbanRepositoryImpl(),
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(KanbanTaskUiState())
val uiState: StateFlow<KanbanTaskUiState> = _uiState.asStateFlow()
fun loadTask(
board: String,
taskId: String,
) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
viewModelScope.launch {
when (val result = repository.getTask(taskId = taskId, board = board)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isLoading = false,
detail = result.data,
)
}
if (result.data.task.status
.equals("running", ignoreCase = true)
) {
loadLog(board, taskId)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load task details: ${result.error.message}",
)
}
}
}
}
}
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) }
}
is NetworkResult.Failure -> {
// Failures to read log don't blank the task screen
}
}
}
}
fun updateDescription(
board: String,
taskId: String,
newBody: String,
) {
_uiState.update { it.copy(isSavingDescription = true) }
viewModelScope.launch {
val result =
repository.updateTask(
taskId = taskId,
board = board,
body = UpdateTaskBody(body = newBody),
)
when (result) {
is NetworkResult.Success -> {
_uiState.update { state ->
state.copy(
isSavingDescription = false,
detail =
state.detail?.let { d ->
d.copy(task = d.task.copy(body = newBody))
},
toastMessage = "Description saved",
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isSavingDescription = false,
toastMessage = "Failed to save: ${result.error.message}",
)
}
}
}
}
}
fun addComment(
board: String,
taskId: String,
body: String,
) {
if (body.isBlank()) return
_uiState.update { it.copy(isSubmittingComment = true) }
viewModelScope.launch {
when (val result = repository.addComment(taskId = taskId, board = board, body = body)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isSubmittingComment = false,
toastMessage = "Comment added",
)
}
loadTask(board, taskId)
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isSubmittingComment = false,
toastMessage = "Failed to add comment: ${result.error.message}",
)
}
}
}
}
}
fun postNoteAndRequeue(
board: String,
taskId: String,
body: String,
) {
if (body.isBlank()) return
_uiState.update { it.copy(isSubmittingComment = true) }
viewModelScope.launch {
val commentRes = repository.addComment(taskId = taskId, board = board, body = body)
if (commentRes is NetworkResult.Failure) {
_uiState.update {
it.copy(
isSubmittingComment = false,
toastMessage = "Failed to post note: ${commentRes.error.message}",
)
}
return@launch
}
val reclaimRes = repository.reclaimTask(taskId = taskId, board = board, reason = "Requeued with note")
when (reclaimRes) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isSubmittingComment = false,
toastMessage = "Note posted & task requeued",
)
}
loadTask(board, taskId)
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isSubmittingComment = false,
toastMessage = "Note posted, but reclaim failed: ${reclaimRes.error.message}",
)
}
}
}
}
}
fun reclaim(
board: String,
taskId: String,
reason: String? = null,
) {
_uiState.update { it.copy(isReclaiming = true) }
viewModelScope.launch {
when (val result = repository.reclaimTask(taskId = taskId, board = board, reason = reason)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isReclaiming = false,
toastMessage = "Task reclaimed",
)
}
loadTask(board, taskId)
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isReclaiming = false,
toastMessage = "Failed to reclaim: ${result.error.message}",
)
}
}
}
}
}
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,
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,
part: MultipartBody.Part,
) {
_uiState.update { it.copy(isUploadingAttachment = true) }
viewModelScope.launch {
when (val result = repository.uploadAttachment(taskId = taskId, board = board, file = part)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isUploadingAttachment = false,
toastMessage = "Attachment uploaded",
)
}
loadTask(board, taskId)
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isUploadingAttachment = false,
toastMessage = "Failed to upload: ${result.error.message}",
)
}
}
}
}
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
}
@@ -2,17 +2,25 @@ 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.local.InMemoryKanbanPreferencesStore
import com.m57.hermescontrol.data.local.KanbanPreferencesStore
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.KanbanBoard
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.remote.ApiClient
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.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.data.ws.KanbanLiveStatus
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
@@ -20,14 +28,16 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
data class KanbanUiState(
val isLoading: Boolean = false,
val isCreatingTask: Boolean = false,
val operatingTaskIds: Set<String> = emptySet(),
val boards: List<KanbanBoard> = emptyList(),
val selectedBoard: KanbanBoard? = null,
val columns: List<KanbanColumn> = emptyList(),
val tasks: List<KanbanTask> = emptyList(),
val profiles: List<KanbanProfile> = emptyList(),
val isLive: Boolean = false,
val errorMessage: String? = null,
val toastMessage: String? = null,
@@ -113,7 +123,12 @@ fun kanbanActionsForStatus(status: String): List<KanbanTaskAction> =
}
class KanbanViewModel(
private val repository: KanbanRepository = KanbanRepositoryImpl(),
private val preferences: KanbanPreferencesStore = InMemoryKanbanPreferencesStore(),
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
private val endpointProvider: () -> String = {
runCatching { AuthManager.endpointForBuild().baseUrl.toString() }.getOrDefault("default")
},
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(KanbanUiState())
@@ -122,90 +137,290 @@ class KanbanViewModel(
private var eventsClient: KanbanEventsClient? = null
private var eventsBoard: String? = null
private var reloadJob: Job? = null
private var currentLoadGen: Int = 0
fun loadBoards() {
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getKanbanBoards() } },
onStart = { _uiState.update { it.copy(isLoading = true, errorMessage = null) } },
onSuccess = { data ->
val boards = data.boards.orEmpty()
_uiState.update { it.copy(isLoading = false, boards = boards) }
val currentSlug = data.current
val currentBoard = boards.find { it.id == currentSlug } ?: boards.firstOrNull()
if (currentBoard != null) {
selectBoard(currentBoard)
val endpoint = endpointProvider()
val savedSlug = preferences.getSelectedBoard(endpoint)
val previouslySelectedId = _uiState.value.selectedBoard?.id ?: savedSlug
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
loadProfiles()
viewModelScope.launch {
when (val result = repository.getBoards()) {
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.firstOrNull()
if (targetBoard != null) {
selectBoard(targetBoard)
} else {
_uiState.update { it.copy(selectedBoard = null, columns = emptyList(), tasks = emptyList()) }
}
}
},
onError = { errorMsg ->
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban boards: $errorMsg",
)
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban boards: ${result.error.message}",
)
}
}
},
)
}
}
}
fun selectBoard(board: KanbanBoard) {
val current = _uiState.value.selectedBoard
if (current?.id == board.id) {
// Already-active board: reload its data and (re)connect the events
// stream WITHOUT the /switch round-trip — refresh and createTask
// re-select the same board and must not POST a redundant switch.
viewModelScope.launch { loadBoardIntoState(board) }
connectEvents(board)
return
}
val endpoint = endpointProvider()
preferences.setSelectedBoard(endpoint, board.id)
val gen = ++currentLoadGen
_uiState.update { it.copy(selectedBoard = board, isLoading = true, errorMessage = null) }
viewModelScope.launch {
val switchResult =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.switchKanbanBoard(board.id) }
}
if (switchResult is NetworkResult.Failure) {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to switch Kanban board: ${switchResult.error.message}",
)
}
return@launch
}
loadBoardIntoState(board)
loadBoardIntoState(board, gen)
connectEvents(board)
}
}
fun loadProfiles() {
viewModelScope.launch {
when (val result = repository.getProfiles()) {
is NetworkResult.Success -> {
_uiState.update { it.copy(profiles = result.data.profiles) }
}
is NetworkResult.Failure -> {
// Do not block UI if profiles fetch fails
}
}
}
}
fun createTask(
body: CreateTaskBody,
targetStatus: String = "todo",
) {
val board = _uiState.value.selectedBoard ?: return
if (_uiState.value.isCreatingTask) return
_uiState.update { it.copy(isCreatingTask = true) }
viewModelScope.launch {
val result =
repository.createTask(
board = board.id,
body = body,
)
when (result) {
is NetworkResult.Success -> {
val createdTask = result.data.task
if (createdTask != null &&
targetStatus.isNotBlank() &&
!createdTask.status.equals(targetStatus, ignoreCase = true)
) {
repository.updateTask(
taskId = createdTask.id,
board = board.id,
body = UpdateTaskBody(status = targetStatus),
)
}
val warning = result.data.warning
val msg =
if (!warning.isNullOrBlank()) {
"Task created: $warning"
} else {
"Task created successfully"
}
_uiState.update { it.copy(isCreatingTask = false, toastMessage = msg) }
reloadBoardSilently()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isCreatingTask = false,
toastMessage = "Failed to create task: ${result.error.message}",
)
}
}
}
}
}
fun createTask(
title: String,
description: String?,
status: String,
status: String = "todo",
) {
createTask(
body =
CreateTaskBody(
title = title,
body = description,
),
targetStatus = status,
)
}
suspend fun estimateNewTask(
title: String,
body: String?,
): TaskEstimate? =
when (val result = repository.estimateNew(title, body)) {
is NetworkResult.Success -> result.data
is NetworkResult.Failure -> null
}
fun createBoard(
slug: String,
name: String? = null,
description: String? = null,
) {
val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall {
ApiClient.hermesApi.createKanbanTask(
board = board.id,
task =
com.m57.hermescontrol.data.model.CreateTaskBody(
title = title,
body = description,
),
)
}
}
when (result) {
_uiState.update { it.copy(isLoading = true) }
val body =
CreateBoardBody(
slug = slug,
name = name,
description = description,
)
when (val res = repository.createBoard(body)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Task created successfully") }
selectBoard(board)
val boardName = res.data.board?.name ?: slug
_uiState.update { it.copy(toastMessage = "Board created: $boardName") }
loadBoards()
}
is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to create task: ${result.error.message}") }
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Failed to create board: ${res.error.message}",
)
}
}
}
}
}
fun renameBoard(
slug: String,
newName: String,
) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.updateBoard(slug, RenameBoardBody(name = newName))) {
is NetworkResult.Success -> {
val boardName = res.data.board?.name ?: newName
_uiState.update { it.copy(toastMessage = "Board renamed to $boardName") }
loadBoards()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Failed to rename board: ${res.error.message}",
)
}
}
}
}
}
fun deleteBoard(slug: String) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.deleteBoard(slug, delete = true)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Board deleted") }
loadBoards()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Failed to delete board: ${res.error.message}",
)
}
}
}
}
}
fun bulkMove(
taskIds: List<String>,
targetStatus: String,
) {
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") }
reloadBoardSilently()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Bulk move failed: ${res.error.message}",
)
}
}
}
}
}
fun bulkArchive(taskIds: List<String>) {
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") }
reloadBoardSilently()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Bulk archive failed: ${res.error.message}",
)
}
}
}
}
}
fun bulkAssign(
taskIds: List<String>,
assignee: String?,
) {
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") }
reloadBoardSilently()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Bulk assign failed: ${res.error.message}",
)
}
}
}
}
@@ -216,32 +431,75 @@ class KanbanViewModel(
action: KanbanTaskAction,
summary: String? = null,
) {
val board = _uiState.value.selectedBoard ?: return
if (task.id in _uiState.value.operatingTaskIds) return
val originalStatus = task.status
// Optimistically update, desktop-style
// Optimistically update both tasks and columns, desktop-style
_uiState.update { state ->
val updatedTasks =
state.tasks.map {
if (it.id == task.id) it.copy(status = action.targetStatus) else it
}
val updatedColumns =
state.columns.map { col ->
val filtered = col.tasks.filter { it.id != task.id }
val newTasks =
if (col.name.equals(action.targetStatus, ignoreCase = true)) {
val moved = updatedTasks.find { it.id == task.id }
if (moved != null) filtered + moved else filtered
} else {
filtered
}
col.copy(tasks = newTasks)
}
state.copy(
tasks =
state.tasks.map {
if (it.id == task.id) it.copy(status = action.targetStatus) else it
},
tasks = updatedTasks,
columns = updatedColumns,
operatingTaskIds = state.operatingTaskIds + task.id,
)
}
viewModelScope.launch {
val body =
buildMap<String, String?> {
put("status", action.targetStatus)
if (action.needsSummary && !summary.isNullOrBlank()) {
put("result", summary)
put("summary", summary)
UpdateTaskBody(
status = action.targetStatus,
summary = if (action.needsSummary && !summary.isNullOrBlank()) summary else null,
result = if (action.needsSummary && !summary.isNullOrBlank()) summary else null,
)
val result = repository.updateTask(task.id, board = board.id, body = body)
when (result) {
is NetworkResult.Success -> {
val returnedTask = result.data.task
_uiState.update { state ->
val finalStatus = returnedTask?.status ?: action.targetStatus
val updatedTasks =
state.tasks.map {
if (it.id == task.id) (returnedTask ?: it.copy(status = finalStatus)) else it
}
val updatedColumns =
state.columns.map { col ->
val filtered = col.tasks.filter { it.id != task.id }
val newTasks =
if (col.name.equals(finalStatus, ignoreCase = true)) {
val moved = updatedTasks.find { it.id == task.id }
if (moved != null) filtered + moved else filtered
} else {
filtered
}
col.copy(tasks = newTasks)
}
state.copy(
tasks = updatedTasks,
columns = updatedColumns,
operatingTaskIds = state.operatingTaskIds - task.id,
)
}
}
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.updateKanbanTask(task.id, body) }
is NetworkResult.Failure -> {
revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}")
}
if (result is NetworkResult.Failure) {
revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}")
}
}
}
@@ -252,11 +510,26 @@ class KanbanViewModel(
errorMsg: String,
) {
_uiState.update { state ->
val updatedTasks =
state.tasks.map {
if (it.id == taskId) it.copy(status = originalStatus) else it
}
val updatedColumns =
state.columns.map { col ->
val filtered = col.tasks.filter { it.id != taskId }
val newTasks =
if (col.name.equals(originalStatus, ignoreCase = true)) {
val moved = updatedTasks.find { it.id == taskId }
if (moved != null) filtered + moved else filtered
} else {
filtered
}
col.copy(tasks = newTasks)
}
state.copy(
tasks =
state.tasks.map {
if (it.id == taskId) it.copy(status = originalStatus) else it
},
tasks = updatedTasks,
columns = updatedColumns,
operatingTaskIds = state.operatingTaskIds - taskId,
toastMessage = errorMsg,
)
}
@@ -300,47 +573,60 @@ class KanbanViewModel(
/** Re-fetch the current board without touching the loading spinner. */
private fun reloadBoardSilently() {
val board = _uiState.value.selectedBoard ?: return
val gen = currentLoadGen
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
}
val result = repository.getBoard(board = board.id)
if (gen != currentLoadGen) return@launch
if (result is NetworkResult.Success) {
val body = result.data
_uiState.update {
it.copy(
columns = body.columns,
tasks = body.columns.flatMap { it.tasks },
)
if (it.selectedBoard?.id != board.id) {
it
} else {
it.copy(
columns = body.columns,
tasks = body.columns.flatMap { col -> col.tasks },
)
}
}
}
}
}
private suspend fun loadBoardIntoState(board: KanbanBoard) {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
}
private suspend fun loadBoardIntoState(
board: KanbanBoard,
gen: Int = currentLoadGen,
) {
val result = repository.getBoard(board = board.id)
if (gen != currentLoadGen) return
when (result) {
is NetworkResult.Success -> {
val body = result.data
val allTasks = body.columns.flatMap { it.tasks }
_uiState.update {
it.copy(
isLoading = false,
columns = body.columns,
tasks = allTasks,
)
if (it.selectedBoard?.id != board.id) {
it
} else {
it.copy(
isLoading = false,
columns = body.columns,
tasks = allTasks,
)
}
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban tasks: ${result.error.message}",
)
if (it.selectedBoard?.id != board.id) {
it
} else {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban tasks: ${result.error.message}",
)
}
}
}
}
@@ -0,0 +1,535 @@
package com.m57.hermescontrol.ui.kanban.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
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.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
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.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.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.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
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.TaskEstimate
import kotlinx.coroutines.launch
private const val PARKED_VALUE = "__parked__"
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KanbanCreateTaskDialog(
columns: List<KanbanColumn>,
defaultColumn: String,
profiles: List<KanbanProfile>,
existingTasks: List<KanbanTask>,
isCreating: Boolean,
onDismiss: () -> Unit,
onConfirm: (body: CreateTaskBody, targetStatus: String) -> Unit,
modifier: Modifier = Modifier,
onEstimate: (suspend (title: String, body: String?) -> TaskEstimate?)? = null,
) {
var title by remember { mutableStateOf("") }
var desc by remember { mutableStateOf("") }
var selectedColumn by remember(defaultColumn) { mutableStateOf(defaultColumn) }
var selectedAssignee by remember { mutableStateOf<String?>(null) }
var priority by remember { mutableIntStateOf(0) }
var goalMode by remember { mutableStateOf(false) }
// Estimation state
var estimate by remember { mutableStateOf<TaskEstimate?>(null) }
var isEstimating by remember { mutableStateOf(false) }
val coroutineScope = rememberCoroutineScope()
// Advanced options
var showAdvanced by remember { mutableStateOf(false) }
var parentTaskId by remember { mutableStateOf<String?>(null) }
var modelOverride by remember { mutableStateOf("") }
var providerOverride by remember { mutableStateOf("") }
var reasoningEffort by remember { mutableStateOf<String?>(null) }
var workspaceKind by remember { mutableStateOf<String?>(null) }
var skillsText by remember { mutableStateOf("") }
var assigneeExpanded by remember { mutableStateOf(false) }
var columnExpanded by remember { mutableStateOf(false) }
var parentExpanded by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = { if (!isCreating) onDismiss() },
modifier = modifier,
title = { Text(stringResource(R.string.kanban_add_new_task)) },
text = {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text(stringResource(R.string.kanban_task_title)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isCreating,
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = desc,
onValueChange = { desc = it },
label = { Text(stringResource(R.string.kanban_task_desc)) },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 5,
enabled = !isCreating,
)
Spacer(modifier = Modifier.height(12.dp))
// Target Column selector
if (columns.isNotEmpty()) {
ExposedDropdownMenuBox(
expanded = columnExpanded,
onExpandedChange = { if (!isCreating) columnExpanded = it },
) {
OutlinedTextField(
value = selectedColumn.uppercase(),
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.kanban_target_column)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = columnExpanded) },
modifier =
Modifier
.menuAnchor(
ExposedDropdownMenuAnchorType.PrimaryNotEditable,
).fillMaxWidth(),
enabled = !isCreating,
)
ExposedDropdownMenu(
expanded = columnExpanded,
onDismissRequest = { columnExpanded = false },
) {
columns.forEach { col ->
DropdownMenuItem(
text = { Text(col.name.uppercase()) },
onClick = {
selectedColumn = col.name
columnExpanded = false
},
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
}
// Assignee dropdown
ExposedDropdownMenuBox(
expanded = assigneeExpanded,
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 ?: ""
}
OutlinedTextField(
value = displayAssignee,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.kanban_assignee)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = assigneeExpanded) },
modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable).fillMaxWidth(),
enabled = !isCreating,
)
ExposedDropdownMenu(
expanded = assigneeExpanded,
onDismissRequest = { assigneeExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_assignee_unassigned)) },
onClick = {
selectedAssignee = null
assigneeExpanded = false
},
)
profiles.forEach { profile ->
DropdownMenuItem(
text = {
Column {
Text(profile.name)
if (profile.model != null) {
Text(
profile.model,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
onClick = {
selectedAssignee = profile.name
assigneeExpanded = false
},
)
}
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_assignee_parked)) },
onClick = {
selectedAssignee = PARKED_VALUE
assigneeExpanded = false
},
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Priority chips
Text(
text = stringResource(R.string.kanban_priority),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
) {
listOf(
0 to stringResource(R.string.kanban_priority_normal),
1 to stringResource(R.string.kanban_priority_med),
2 to stringResource(R.string.kanban_priority_high),
3 to stringResource(R.string.kanban_priority_urgent),
).forEach { (pVal, label) ->
FilterChip(
selected = priority == pVal,
onClick = { priority = pVal },
label = { Text(label) },
enabled = !isCreating,
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// Autonomous Goal Mode switch
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier =
Modifier
.fillMaxWidth()
.clickable(enabled = !isCreating) { goalMode = !goalMode }
.padding(vertical = 4.dp),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(R.string.kanban_goal_mode),
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = stringResource(R.string.kanban_goal_mode_desc),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.width(8.dp))
Switch(
checked = goalMode,
onCheckedChange = { goalMode = it },
enabled = !isCreating,
)
}
Spacer(modifier = Modifier.height(8.dp))
// Advanced Options Toggle
TextButton(
onClick = { showAdvanced = !showAdvanced },
modifier = Modifier.align(Alignment.Start),
) {
Icon(
imageVector = if (showAdvanced) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.kanban_advanced_options))
}
AnimatedVisibility(visible = showAdvanced) {
Column(modifier = Modifier.fillMaxWidth()) {
// 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}"
} ?: stringResource(R.string.kanban_parent_none)
OutlinedTextField(
value = displayParent,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.kanban_parent_task)) },
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(
expanded = parentExpanded,
)
},
modifier =
Modifier
.menuAnchor(
ExposedDropdownMenuAnchorType.PrimaryNotEditable,
).fillMaxWidth(),
enabled = !isCreating,
)
ExposedDropdownMenu(
expanded = parentExpanded,
onDismissRequest = { parentExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_parent_none)) },
onClick = {
parentTaskId = null
parentExpanded = false
},
)
existingTasks.forEach { task ->
DropdownMenuItem(
text = { Text("#${task.id.removePrefix("t_").take(6)} • ${task.title}") },
onClick = {
parentTaskId = task.id
parentExpanded = false
},
)
}
}
}
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,
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),
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 "Scratch (Default)",
"worktree" to "Worktree",
"dir" to "Dir",
).forEach { (kind, label) ->
FilterChip(
selected = workspaceKind == kind,
onClick = { workspaceKind = kind },
label = { Text(label) },
enabled = !isCreating,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Skills
OutlinedTextField(
value = skillsText,
onValueChange = { skillsText = it },
label = { Text(stringResource(R.string.kanban_skills)) },
placeholder = { Text("e.g. git, coding, tests") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isCreating,
)
}
}
if (onEstimate != null) {
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
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")
}
}
}
}
},
confirmButton = {
Button(
onClick = {
if (title.isNotBlank()) {
val skillsList =
skillsText
.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
.takeIf { it.isNotEmpty() }
val body =
CreateTaskBody(
title = title.trim(),
body = desc.trim().ifBlank { null },
assignee = if (selectedAssignee == PARKED_VALUE) null else selectedAssignee,
priority = priority,
goalMode = goalMode,
parents = parentTaskId?.let { listOf(it) } ?: emptyList(),
modelOverride = modelOverride.trim().ifBlank { null },
providerOverride = providerOverride.trim().ifBlank { null },
reasoningEffort = reasoningEffort,
workspaceKind = workspaceKind,
skills = skillsList,
triage = selectedColumn.equals("triage", ignoreCase = true),
)
onConfirm(body, selectedColumn)
}
},
enabled = title.isNotBlank() && !isCreating,
) {
if (isCreating) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(8.dp))
}
Text(stringResource(R.string.action_add))
}
},
dismissButton = {
TextButton(
onClick = onDismiss,
enabled = !isCreating,
) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
@@ -0,0 +1,189 @@
package com.m57.hermescontrol.ui.kanban.components
import androidx.compose.foundation.clickable
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.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KanbanFilterSheet(
assignees: List<String>,
tenants: List<String>,
selectedAssignee: String?,
selectedTenant: String?,
includeArchived: Boolean,
groupRunning: Boolean,
onSelectAssignee: (String?) -> Unit,
onSelectTenant: (String?) -> Unit,
onToggleIncludeArchived: (Boolean) -> Unit,
onToggleGroupRunning: (Boolean) -> Unit,
onClearFilters: () -> Unit,
onDismiss: () -> Unit,
) {
ModalBottomSheet(
onDismissRequest = onDismiss,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Filters & View Options",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
TextButton(onClick = onClearFilters) {
Text("Clear all")
}
}
Spacer(modifier = Modifier.height(8.dp))
// Show Archived Switch
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Text("Show archived tasks", style = MaterialTheme.typography.bodyMedium)
Switch(
checked = includeArchived,
onCheckedChange = onToggleIncludeArchived,
)
}
// Group Running Switch
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Text("Group running lane by profile", style = MaterialTheme.typography.bodyMedium)
Switch(
checked = groupRunning,
onCheckedChange = onToggleGroupRunning,
)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
// Assignee Filter
Text(
text = "Assignee",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 4.dp),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable { onSelectAssignee(null) }
.padding(vertical = 4.dp),
) {
RadioButton(
selected = selectedAssignee == null,
onClick = { onSelectAssignee(null) },
)
Text("All assignees", style = MaterialTheme.typography.bodyMedium)
}
assignees.forEach { assignee ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable { onSelectAssignee(assignee) }
.padding(vertical = 4.dp),
) {
RadioButton(
selected = selectedAssignee == assignee,
onClick = { onSelectAssignee(assignee) },
)
Text(assignee, style = MaterialTheme.typography.bodyMedium)
}
}
if (tenants.isNotEmpty()) {
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = "Tenant",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 4.dp),
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable { onSelectTenant(null) }
.padding(vertical = 4.dp),
) {
RadioButton(
selected = selectedTenant == null,
onClick = { onSelectTenant(null) },
)
Text("All tenants", style = MaterialTheme.typography.bodyMedium)
}
tenants.forEach { tenant ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable { onSelectTenant(tenant) }
.padding(vertical = 4.dp),
) {
RadioButton(
selected = selectedTenant == tenant,
onClick = { onSelectTenant(tenant) },
)
Text(tenant, style = MaterialTheme.typography.bodyMedium)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@@ -0,0 +1,283 @@
package com.m57.hermescontrol.ui.kanban.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.ChatBubbleOutline
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Hub
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.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.data.model.KanbanTask
import com.m57.hermescontrol.theme.LocalHermesStatusColors
@Composable
fun KanbanTaskCard(
task: KanbanTask,
onTaskClick: (KanbanTask) -> Unit,
modifier: Modifier = Modifier,
isSelected: Boolean = false,
onActionClick: ((KanbanTask) -> Unit)? = null,
) {
val statusColors = LocalHermesStatusColors.current
val statusTone =
when (task.status.lowercase()) {
"ready" -> statusColors.info
"running" -> statusColors.success
"blocked" -> statusColors.error
"review" -> statusColors.warning
"done" -> statusColors.success
else -> MaterialTheme.colorScheme.outline
}
val cardColors =
if (isSelected) {
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f),
)
} else {
CardDefaults.cardColors()
}
Card(
onClick = { onTaskClick(task) },
colors = cardColors,
shape = RoundedCornerShape(8.dp),
modifier = modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier =
Modifier
.size(6.dp)
.background(color = statusTone, shape = CircleShape),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = task.status.uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = statusTone,
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "#${task.id.removePrefix("t_").take(6)}",
style = MaterialTheme.typography.labelSmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (onActionClick != null) {
Spacer(modifier = Modifier.width(4.dp))
IconButton(
onClick = { onActionClick(task) },
modifier = Modifier.size(24.dp),
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Task actions",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Spacer(modifier = Modifier.height(6.dp))
Text(
text = task.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
val summary = task.latestSummary ?: task.body
if (!summary.isNullOrBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = summary,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
task.assignee?.let { assignee ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(end = 8.dp),
) {
Icon(
imageVector = Icons.Filled.Person,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(3.dp))
Text(
text = assignee,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Spacer(modifier = Modifier.weight(1f))
// Priority
val priority = task.priority ?: 0
if (priority > 0) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 6.dp),
) {
Icon(
imageVector = Icons.Filled.ArrowUpward,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = statusColors.warning,
)
Text(
text = priority.toString(),
style = MaterialTheme.typography.labelSmall,
color = statusColors.warning,
)
}
}
// Child progress
task.progress?.let { prog ->
if (prog.total > 0) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 6.dp),
) {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = "${prog.done}/${prog.total}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Comments
if (task.commentCount > 0) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 6.dp),
) {
Icon(
imageVector = Icons.Filled.ChatBubbleOutline,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = task.commentCount.toString(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Links
val links = task.linkCounts?.let { it.parents + it.children } ?: 0
if (links > 0) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 6.dp),
) {
Icon(
imageVector = Icons.Filled.Hub,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = links.toString(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Warnings
val warnings = task.warnings?.count ?: 0
if (warnings > 0) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 6.dp),
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = null,
modifier = Modifier.size(12.dp),
tint = statusColors.error,
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = warnings.toString(),
style = MaterialTheme.typography.labelSmall,
color = statusColors.error,
)
}
}
}
}
}
}
+19
View File
@@ -342,6 +342,25 @@
<string name="kanban_live">Live</string>
<string name="kanban_offline">Offline</string>
<string name="kanban_no_tasks">No tasks</string>
<string name="kanban_priority">Priority</string>
<string name="kanban_priority_normal">0 (Normal)</string>
<string name="kanban_priority_med">1 (Med)</string>
<string name="kanban_priority_high">2 (High)</string>
<string name="kanban_priority_urgent">3 (Urgent)</string>
<string name="kanban_assignee">Assignee</string>
<string name="kanban_assignee_unassigned">Default (Unassigned)</string>
<string name="kanban_assignee_parked">Parked (No worker)</string>
<string name="kanban_goal_mode">Autonomous Goal Mode</string>
<string name="kanban_goal_mode_desc">Judge validates task completion across multi-turn iterations</string>
<string name="kanban_advanced_options">Advanced Options</string>
<string name="kanban_parent_task">Parent Task</string>
<string name="kanban_parent_none">None</string>
<string name="kanban_model_override">Model Override</string>
<string name="kanban_provider_override">Provider Override</string>
<string name="kanban_reasoning_effort">Reasoning Effort</string>
<string name="kanban_workspace_kind">Workspace Kind</string>
<string name="kanban_skills">Skills</string>
<string name="kanban_target_column">Column</string>
<string name="action_add">Add</string>
<!-- Skills Screen -->
<string name="skills_status_all">All Statuses</string>
@@ -1205,10 +1205,9 @@ class E2eIntegrationTest {
runTest {
val board = KanbanBoard("board-1", "Backlog", null)
val task = KanbanTask("task-1", "Do laundry", null, "todo", null)
coEvery { mockApiService.getKanbanBoards() } returns
coEvery { mockApiService.getBoards(any()) } returns
Response.success(KanbanBoardsResponse(listOf(board), "board-1"))
coEvery { mockApiService.switchKanbanBoard("board-1") } returns Response.success(Unit)
coEvery { mockApiService.getKanbanBoard() } returns
coEvery { mockApiService.getBoard("board-1", any(), any()) } returns
Response.success(KanbanBoardResponse(listOf(KanbanColumn("todo", listOf(task))), null, null))
val viewModel = KanbanViewModel(eventsClientProvider = { mockk<KanbanEventsClient>(relaxed = true) })
@@ -1230,11 +1229,16 @@ class E2eIntegrationTest {
// Desktop-style transition: todo -> ready is a direct write the
// backend accepts; the VM PATCHes the target status.
coEvery {
mockApiService.updateKanbanTask(
"task-1",
mapOf("status" to "ready"),
mockApiService.updateTask(
eq("task-1"),
eq("board-1"),
any(),
)
} returns
Response.success(
com.m57.hermescontrol.data.model
.UpdateTaskResponse(task = task.copy(status = "ready")),
)
} returns Response.success(Unit)
viewModel.moveTask(viewModel.uiState.value.tasks[0], KanbanTaskAction.READY)
advanceUntilIdle()
assertEquals(
@@ -1243,9 +1247,13 @@ class E2eIntegrationTest {
.status,
)
// Create path: the wrapped {"task": ...} response must not
// break deserialization — the API layer reads it as Unit.
coEvery { mockApiService.createKanbanTask(any(), any()) } returns Response.success(Unit)
// Create path: returns CreateTaskResponse
coEvery { mockApiService.createTask(any(), any()) } returns
Response.success(
com.m57.hermescontrol.data.model.CreateTaskResponse(
task = KanbanTask("task-2", "Fresh task", status = "todo"),
),
)
viewModel.createTask("Fresh task", null, "todo")
advanceUntilIdle()
assertEquals("Task created successfully", viewModel.uiState.value.toastMessage)
@@ -0,0 +1,292 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.serialization.encodeToString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class KanbanSerializationTest {
private val json = OkHttpProvider.json
@Test
fun testBoardResponseSerialization() {
val raw =
"""
{
"columns": [
{
"name": "todo",
"tasks": [
{
"id": "t_101",
"title": "Build mobile Kanban",
"body": "Full desktop parity",
"status": "todo",
"assignee": "researcher",
"priority": 1,
"tenant": "mobile",
"created_at": 1718000000,
"latest_summary": "Initial draft ready",
"comment_count": 3,
"link_counts": { "parents": 1, "children": 2 },
"progress": { "done": 1, "total": 2 },
"warnings": { "count": 1, "highest_severity": "warning" },
"started_at": 1718000100,
"worker_pid": 12345,
"last_heartbeat_at": 1718000200
}
]
}
],
"assignees": ["researcher", "reviewer"],
"tenants": ["mobile"],
"latest_event_id": 42,
"now": 1718000500
}
""".trimIndent()
val decoded = json.decodeFromString<KanbanBoardResponse>(raw)
assertEquals(1, decoded.columns.size)
assertEquals("todo", decoded.columns[0].name)
val task = decoded.columns[0].tasks[0]
assertEquals("t_101", task.id)
assertEquals("Build mobile Kanban", task.title)
assertEquals("Full desktop parity", task.body)
assertEquals("Full desktop parity", task.description)
assertEquals("researcher", task.assignee)
assertEquals("researcher", task.assignedTo)
assertEquals(1, task.priority)
assertEquals(3, task.commentCount)
assertEquals(1, task.linkCounts?.parents)
assertEquals(2, task.linkCounts?.children)
assertEquals(1, task.progress?.done)
assertEquals(2, task.progress?.total)
assertEquals(1, task.warnings?.count)
assertEquals("warning", task.warnings?.highestSeverity)
assertEquals(12345, task.workerPid)
assertEquals(42L, decoded.latestEventId)
assertEquals(1718000500L, decoded.now)
}
@Test
fun testTaskDetailResponseSerialization() {
val raw =
"""
{
"task": {
"id": "t_202",
"title": "Fix memory leak",
"body": "Detailed description",
"status": "running",
"assignee": "debugger",
"result": "Resolved leak",
"created_by": "m57",
"model_override": "gpt-4o",
"provider_override": "openai",
"reasoning_effort": "high",
"completed_at": 1718000900,
"last_failure_error": null,
"workspace_kind": "worktree",
"workspace_path": "/tmp/wt-1",
"branch_name": "feat/leak-fix",
"consecutive_failures": 0,
"diagnostics": [
{
"kind": "stale_heartbeat",
"severity": "warning",
"title": "Worker heartbeat missing",
"detail": "Last ping was 3 minutes ago",
"count": 1,
"last_seen_at": 1718000850,
"actions": [
{ "kind": "reclaim", "label": "Reclaim task", "suggested": true }
]
}
]
},
"comments": [
{
"id": 1,
"author": "mobile",
"body": "Steering comment",
"created_at": 1718000800
}
],
"events": [
{
"id": 10,
"kind": "status",
"payload": { "status": "running" },
"created_at": 1718000700
}
],
"attachments": [
{
"id": 5,
"task_id": "t_202",
"filename": "leak.png",
"content_type": "image/png",
"size": 1024,
"uploaded_by": "mobile",
"stored_path": "/files/leak.png",
"created_at": 1718000750
}
],
"links": {
"parents": ["t_100"],
"children": ["t_300"]
},
"child_results": [
{
"id": "t_300",
"title": "Child task",
"status": "done",
"latest_summary": "Finished child",
"result": "OK"
}
],
"runs": [
{
"id": 7,
"profile": "debugger",
"status": "done",
"outcome": "completed",
"summary": "Completed attempt 1",
"worker_pid": 9876,
"started_at": 1718000500,
"ended_at": 1718000900
}
]
}
""".trimIndent()
val decoded = json.decodeFromString<KanbanTaskDetailResponse>(raw)
assertEquals("t_202", decoded.task.id)
assertEquals("gpt-4o", decoded.task.modelOverride)
assertEquals("high", decoded.task.reasoningEffort)
assertEquals(1, decoded.task.diagnostics.size)
assertEquals("stale_heartbeat", decoded.task.diagnostics[0].kind)
assertTrue(
decoded.task.diagnostics[0]
.actions[0]
.suggested,
)
assertEquals(1, decoded.comments.size)
assertEquals("Steering comment", decoded.comments[0].body)
assertEquals(1, decoded.events.size)
assertEquals("status", decoded.events[0].kind)
assertEquals(1, decoded.attachments?.size)
assertEquals("leak.png", decoded.attachments?.get(0)?.filename)
assertEquals(listOf("t_100"), decoded.links.parents)
assertEquals(listOf("t_300"), decoded.links.children)
assertEquals(1, decoded.childResults.size)
assertEquals(1, decoded.runs.size)
assertEquals(7L, decoded.runs[0].id)
assertEquals("completed", decoded.runs[0].outcome)
}
@Test
fun testCreateAndUpdatePayloadSerialization() {
val createBody =
CreateTaskBody(
title = "New Task",
body = "Task description",
assignee = "default",
priority = 2,
parents = listOf("t_001"),
triage = true,
modelOverride = "claude-3-5-sonnet",
reasoningEffort = "none",
)
val createJson = json.encodeToString(createBody)
assertTrue(createJson.contains("\"title\":\"New Task\""))
assertTrue(createJson.contains("\"triage\":true"))
assertTrue(createJson.contains("\"parents\":[\"t_001\"]"))
assertTrue(createJson.contains("\"reasoning_effort\":\"none\""))
val updateBody =
UpdateTaskBody(
status = "blocked",
blockReason = "Waiting for credentials",
clearModelOverride = true,
clearReasoningEffort = true,
)
val updateJson = json.encodeToString(updateBody)
assertTrue(updateJson.contains("\"status\":\"blocked\""))
assertTrue(updateJson.contains("\"block_reason\":\"Waiting for credentials\""))
assertTrue(updateJson.contains("\"clear_model_override\":true"))
assertTrue(updateJson.contains("\"clear_reasoning_effort\":true"))
}
@Test
fun testBulkAndSettingsSerialization() {
val bulk =
BulkTasksBody(
ids = listOf("t_1", "t_2"),
status = "ready",
reclaimFirst = true,
)
val bulkJson = json.encodeToString(bulk)
assertTrue(bulkJson.contains("\"ids\":[\"t_1\",\"t_2\"]"))
assertTrue(bulkJson.contains("\"reclaim_first\":true"))
val orchestrationRaw =
"""
{
"orchestrator_profile": "lead",
"default_assignee": "coder",
"auto_decompose": true,
"auto_promote_children": false,
"resolved_orchestrator_profile": "lead",
"resolved_default_assignee": "coder",
"active_profile": "default"
}
""".trimIndent()
val orchestration = json.decodeFromString<OrchestrationSettings>(orchestrationRaw)
assertEquals("lead", orchestration.orchestratorProfile)
assertEquals("coder", orchestration.defaultAssignee)
assertTrue(orchestration.autoDecompose)
assertFalse(orchestration.autoPromoteChildren)
}
@Test
fun testWorkerLogAndEstimateSerialization() {
val logRaw =
"""
{
"task_id": "t_999",
"path": "/logs/worker.log",
"exists": true,
"size_bytes": 4096,
"content": "Running tests...\nAll passed!",
"truncated": false
}
""".trimIndent()
val log = json.decodeFromString<WorkerLog>(logRaw)
assertEquals("t_999", log.taskId)
assertTrue(log.exists)
assertEquals(4096L, log.sizeBytes)
assertEquals("Running tests...\nAll passed!", log.content)
assertFalse(log.truncated)
val estRaw =
"""
{
"ok": true,
"est_tokens": 15000,
"complexity": "M",
"rationale": "Multi-file changes needed",
"model": "auxiliary"
}
""".trimIndent()
val estimate = json.decodeFromString<TaskEstimate>(estRaw)
assertTrue(estimate.ok)
assertEquals(15000, estimate.estTokens)
assertEquals("M", estimate.complexity)
assertEquals("Multi-file changes needed", estimate.rationale)
}
}
@@ -0,0 +1,311 @@
package com.m57.hermescontrol.data.remote
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.OrchestrationSettingsUpdate
import com.m57.hermescontrol.data.model.ReassignTaskBody
import com.m57.hermescontrol.data.model.ReclaimTaskBody
import com.m57.hermescontrol.data.model.RenameBoardBody
import com.m57.hermescontrol.data.model.UpdateTaskBody
import kotlinx.coroutines.runBlocking
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
class KanbanApiServiceTest {
private lateinit var mockServer: MockWebServer
private lateinit var api: KanbanApiService
@Before
fun setUp() {
mockServer = MockWebServer()
mockServer.start()
api =
Retrofit
.Builder()
.baseUrl(mockServer.url("/"))
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
.build()
.create(KanbanApiService::class.java)
}
@After
fun tearDown() {
mockServer.shutdown()
}
@Test
fun testGetBoards() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{
"boards": [
{ "slug": "default", "name": "Default Board", "total": 5, "is_current": true }
],
"current": "default"
}
""".trimIndent(),
),
)
val response = api.getBoards(includeArchived = true)
assertTrue(response.isSuccessful)
val body = response.body()
assertNotNull(body)
assertEquals(1, body?.boards?.size)
assertEquals("default", body?.boards?.get(0)?.id)
assertEquals("Default Board", body?.boards?.get(0)?.name)
assertEquals("default", body?.current)
val request = mockServer.takeRequest()
assertEquals("GET", request.method)
assertEquals("/api/plugins/kanban/boards?include_archived=true", request.path)
}
@Test
fun testGetBoardScoped() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{
"columns": [
{ "name": "ready", "tasks": [] }
],
"assignees": ["lead"],
"tenants": [],
"latest_event_id": 99,
"now": 1718000000
}
""".trimIndent(),
),
)
val response = api.getBoard(board = "dev", includeArchived = false, tenant = "team-a")
assertTrue(response.isSuccessful)
val body = response.body()
assertNotNull(body)
assertEquals(1, body?.columns?.size)
assertEquals("ready", body?.columns?.get(0)?.name)
val request = mockServer.takeRequest()
assertEquals("GET", request.method)
assertTrue(request.path?.contains("board=dev") == true)
assertTrue(request.path?.contains("tenant=team-a") == true)
}
@Test
fun testCreateAndPatchTask() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{
"task": {
"id": "t_new",
"title": "Implement feature",
"status": "triage"
},
"warning": "Dispatcher not running"
}
""".trimIndent(),
),
)
val createRes =
api.createTask(
board = "dev",
body =
CreateTaskBody(
title = "Implement feature",
triage = true,
),
)
assertTrue(createRes.isSuccessful)
assertEquals("t_new", createRes.body()?.task?.id)
assertEquals("Dispatcher not running", createRes.body()?.warning)
val createReq = mockServer.takeRequest()
assertEquals("POST", createReq.method)
assertEquals("/api/plugins/kanban/tasks?board=dev", createReq.path)
assertTrue(createReq.body.readUtf8().contains("\"triage\":true"))
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{
"task": {
"id": "t_new",
"title": "Implement feature",
"status": "ready"
}
}
""".trimIndent(),
),
)
val patchRes =
api.updateTask(
taskId = "t_new",
board = "dev",
body =
UpdateTaskBody(
status = "ready",
),
)
assertTrue(patchRes.isSuccessful)
assertEquals("ready", patchRes.body()?.task?.status)
val patchReq = mockServer.takeRequest()
assertEquals("PATCH", patchReq.method)
assertEquals("/api/plugins/kanban/tasks/t_new?board=dev", patchReq.path)
}
@Test
fun testBulkTasks() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{
"results": [
{ "id": "t_1", "ok": true },
{ "id": "t_2", "ok": false, "error": "transition refused" }
]
}
""".trimIndent(),
),
)
val res =
api.bulkTasks(
board = "dev",
body =
BulkTasksBody(
ids = listOf("t_1", "t_2"),
status = "done",
),
)
assertTrue(res.isSuccessful)
val results = res.body()?.results
assertEquals(2, results?.size)
assertTrue(results?.get(0)?.ok == true)
assertEquals("transition refused", results?.get(1)?.error)
val req = mockServer.takeRequest()
assertEquals("POST", req.method)
assertEquals("/api/plugins/kanban/tasks/bulk?board=dev", req.path)
}
@Test
fun testReassignAndReclaim() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "ok": true, "task_id": "t_1", "assignee": "reviewer" }"""),
)
val reassignRes =
api.reassignTask(
taskId = "t_1",
board = "dev",
body = ReassignTaskBody(profile = "reviewer", reclaimFirst = true),
)
assertTrue(reassignRes.isSuccessful)
assertEquals("reviewer", reassignRes.body()?.assignee)
val reassignReq = mockServer.takeRequest()
assertEquals("POST", reassignReq.method)
assertEquals("/api/plugins/kanban/tasks/t_1/reassign?board=dev", reassignReq.path)
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "ok": true, "task_id": "t_1" }"""),
)
val reclaimRes =
api.reclaimTask(
taskId = "t_1",
board = "dev",
body = ReclaimTaskBody(reason = "Worker unresponsive"),
)
assertTrue(reclaimRes.isSuccessful)
val reclaimReq = mockServer.takeRequest()
assertEquals("POST", reclaimReq.method)
assertEquals("/api/plugins/kanban/tasks/t_1/reclaim?board=dev", reclaimReq.path)
}
@Test
fun testOrchestrationAndDispatchNudge() =
runBlocking {
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody(
"""
{
"orchestrator_profile": "boss",
"default_assignee": "worker",
"auto_decompose": true,
"auto_promote_children": true,
"resolved_orchestrator_profile": "boss",
"resolved_default_assignee": "worker",
"active_profile": "default"
}
""".trimIndent(),
),
)
val updateRes =
api.updateOrchestration(
OrchestrationSettingsUpdate(
orchestratorProfile = "boss",
defaultAssignee = "worker",
),
)
assertTrue(updateRes.isSuccessful)
assertEquals("boss", updateRes.body()?.orchestratorProfile)
val putReq = mockServer.takeRequest()
assertEquals("PUT", putReq.method)
assertEquals("/api/plugins/kanban/orchestration", putReq.path)
mockServer.enqueue(
MockResponse()
.setResponseCode(200)
.setBody("""{ "spawned": [] }"""),
)
val nudgeRes = api.nudgeDispatcher(board = "dev")
assertTrue(nudgeRes.isSuccessful)
val nudgeReq = mockServer.takeRequest()
assertEquals("POST", nudgeReq.method)
assertEquals("/api/plugins/kanban/dispatch?board=dev", nudgeReq.path)
}
}
@@ -0,0 +1,110 @@
package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.remote.KanbanApiService
import com.m57.hermescontrol.data.remote.NetworkResult
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class KanbanRepositoryTest {
private val testDispatcher = UnconfinedTestDispatcher()
private val mockApi = mockk<KanbanApiService>(relaxed = true)
private lateinit var repository: KanbanRepository
@Before
fun setUp() {
repository = KanbanRepositoryImpl(apiProvider = { mockApi }, ioDispatcher = testDispatcher)
}
@Test
fun testGetBoardsPassesIncludeArchived() =
runTest(testDispatcher) {
coEvery { mockApi.getBoards(true) } returns Response.success(KanbanBoardsResponse())
val result = repository.getBoards(includeArchived = true)
assertTrue(result is NetworkResult.Success)
coVerify { mockApi.getBoards(true) }
}
@Test
fun testGetBoardScopedPassesBoardAndTenant() =
runTest(testDispatcher) {
coEvery {
mockApi.getBoard(
board = "custom",
includeArchived = false,
tenant = "team-a",
)
} returns Response.success(KanbanBoardResponse())
val result = repository.getBoard(board = "custom", includeArchived = false, tenant = "team-a")
assertTrue(result is NetworkResult.Success)
coVerify { mockApi.getBoard(board = "custom", includeArchived = false, tenant = "team-a") }
}
@Test
fun testCreateTaskScopesToBoard() =
runTest(testDispatcher) {
val body = CreateTaskBody(title = "New Task")
coEvery { mockApi.createTask(board = "ops", body = body) } returns
Response.success(
CreateTaskResponse(task = KanbanTask(id = "t_1", title = "New Task", status = "triage")),
)
val result = repository.createTask(board = "ops", body = body)
assertTrue(result is NetworkResult.Success)
assertEquals("t_1", (result as NetworkResult.Success).data.task?.id)
coVerify { mockApi.createTask(board = "ops", body = body) }
}
@Test
fun testUpdateTaskScopesToBoard() =
runTest(testDispatcher) {
val body = UpdateTaskBody(status = "ready")
coEvery { mockApi.updateTask(taskId = "t_1", board = "ops", body = body) } returns
Response.success(
UpdateTaskResponse(task = KanbanTask(id = "t_1", title = "New Task", status = "ready")),
)
val result = repository.updateTask(taskId = "t_1", board = "ops", body = body)
assertTrue(result is NetworkResult.Success)
assertEquals("ready", (result as NetworkResult.Success).data.task?.status)
coVerify { mockApi.updateTask(taskId = "t_1", board = "ops", body = body) }
}
@Test
fun testDeleteTaskScopesToBoard() =
runTest(testDispatcher) {
coEvery { mockApi.deleteTask(taskId = "t_1", board = "ops") } returns Response.success(Unit)
val result = repository.deleteTask(taskId = "t_1", board = "ops")
assertTrue(result is NetworkResult.Success)
coVerify { mockApi.deleteTask(taskId = "t_1", board = "ops") }
}
@Test
fun testHttpErrorWrapsFailure() =
runTest(testDispatcher) {
coEvery { mockApi.getBoard("missing", false, null) } returns
Response.error(404, "Not Found".toResponseBody())
val result = repository.getBoard("missing")
assertTrue(result is NetworkResult.Failure)
}
}
@@ -0,0 +1,104 @@
package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.ws.KanbanEvent
import com.m57.hermescontrol.data.ws.KanbanEventsClient
import com.m57.hermescontrol.data.ws.KanbanEventsEnvelope
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class KanbanSyncCoordinatorTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
private lateinit var coordinator: KanbanSyncCoordinator
@Before
fun setUp() {
coordinator =
KanbanSyncCoordinator(
repository = mockRepository,
eventsClientProvider = { mockEventsClient },
reloadDebounceMs = 250L,
pollIntervalMs = 60_000L,
nudgeDebounceMs = 400L,
)
}
@Test
fun testLiveStatusTransitions() =
runTest(testDispatcher) {
val statusSlot = slot<(KanbanLiveStatus) -> Unit>()
verify(exactly = 0) { mockEventsClient.connect(any(), any(), any(), any(), any()) }
coordinator.connect(testScope, "dev", onReload = {})
verify { mockEventsClient.connect(any(), eq("dev"), any(), any(), capture(statusSlot)) }
statusSlot.captured(KanbanLiveStatus.CONNECTED)
assertEquals(KanbanSyncStatus.Live, coordinator.syncStatus.value)
statusSlot.captured(KanbanLiveStatus.DISCONNECTED)
assertTrue(coordinator.syncStatus.value is KanbanSyncStatus.Polling)
statusSlot.captured(KanbanLiveStatus.AUTH_FAILED)
assertEquals(KanbanSyncStatus.AuthFailed, coordinator.syncStatus.value)
}
@Test
fun testDebouncedEventReload() =
runTest(testDispatcher) {
val eventsSlot = slot<(KanbanEventsEnvelope) -> Unit>()
var reloadCount = 0
coordinator.connect(testScope, "dev", onReload = { reloadCount++ })
verify { mockEventsClient.connect(any(), eq("dev"), any(), capture(eventsSlot), any()) }
eventsSlot.captured(KanbanEventsEnvelope(events = listOf(KanbanEvent(id = 1, kind = "status"))))
testDispatcher.scheduler.advanceTimeBy(100)
assertEquals(0, reloadCount)
testDispatcher.scheduler.advanceTimeBy(151)
assertEquals(1, reloadCount)
}
@Test
fun testDebouncedNudge() =
runTest(testDispatcher) {
coordinator.scheduleNudge(testScope, "dev")
testDispatcher.scheduler.advanceTimeBy(200)
// Still within debounce window
coVerify(exactly = 0) { mockRepository.nudgeDispatcher(any()) }
testDispatcher.scheduler.advanceTimeBy(201)
testDispatcher.scheduler.advanceUntilIdle()
coVerify(exactly = 1) { mockRepository.nudgeDispatcher("dev") }
}
@Test
fun testDisconnectCancelsEverything() =
runTest(testDispatcher) {
coordinator.connect(testScope, "dev", onReload = {})
coordinator.disconnect()
verify { mockEventsClient.disconnect() }
assertEquals(KanbanSyncStatus.Disconnected, coordinator.syncStatus.value)
}
}
@@ -30,6 +30,16 @@ class KanbanEventsClientTest {
assertEquals(1710000000L, event.createdAt)
}
@Test
fun `parses event with numeric run_id from sqlite`() {
val frame =
"""{"events":[{"id":8,"task_id":"t-2","run_id":42,"kind":"status","payload":null,"created_at":1710000001}],"cursor":8}"""
val envelope = parseKanbanEventsFrame(frame)
assertNotNull(envelope)
assertEquals(1, envelope!!.events.size)
assertEquals("42", envelope.events.first().runId)
}
@Test
fun `parses empty events batch`() {
val envelope = parseKanbanEventsFrame("""{"events":[],"cursor":3}""")
@@ -0,0 +1,493 @@
package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore
import com.m57.hermescontrol.data.model.BulkTaskResult
import com.m57.hermescontrol.data.model.BulkTasksResponse
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.DeleteBoardResponse
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.model.RenameBoardResponse
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.remote.NetworkError
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.CompletableDeferred
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.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class KanbanMutationTest {
private val testDispatcher = StandardTestDispatcher()
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
private val preferences = InMemoryKanbanPreferencesStore()
private fun createViewModel(): KanbanViewModel =
KanbanViewModel(
repository = mockRepository,
preferences = preferences,
eventsClientProvider = { mockEventsClient },
endpointProvider = { "http://127.0.0.1:9119" },
)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testCreateTaskSuccessWithWarning() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev")
val newTask = KanbanTask(id = "t_100", title = "New task", status = "todo")
coEvery { mockRepository.getBoard("dev") } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
coEvery { mockRepository.createTask("dev", any()) } returns
NetworkResult.Success(CreateTaskResponse(task = newTask, warning = "Dispatcher not running"))
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
vm.createTask("New task", "Description", "todo")
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
mockRepository.createTask(
"dev",
match { it.title == "New task" && it.body == "Description" },
)
}
assertTrue(
vm.uiState.value.toastMessage
?.contains("Dispatcher not running") == true,
)
}
@Test
fun testCreateTaskFailurePreservesStateAndShowsToast() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev")
coEvery { mockRepository.getBoard("dev") } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
coEvery { mockRepository.createTask("dev", any()) } returns
NetworkResult.Failure(NetworkError.Http(400, "Validation failed"))
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
vm.createTask("Bad task", null, "todo")
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(
vm.uiState.value.toastMessage
?.contains("Validation failed") == true,
)
assertFalse(vm.uiState.value.isCreatingTask)
}
@Test
fun testMoveTaskUpdatesBothTasksAndColumnsOptimistically() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev")
val task = KanbanTask(id = "t_1", title = "Task 1", status = "todo")
val colTodo = KanbanColumn("todo", listOf(task))
val colReady = KanbanColumn("ready", emptyList())
val deferred = CompletableDeferred<NetworkResult<UpdateTaskResponse>>()
coEvery { mockRepository.getBoard("dev") } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(colTodo, colReady)))
coEvery { mockRepository.updateTask("t_1", "dev", any()) } coAnswers { deferred.await() }
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
// Initiate move
vm.moveTask(task, KanbanTaskAction.READY)
// Optimistic check: both tasks AND columns must reflect "ready"!
assertEquals(
"ready",
vm.uiState.value.tasks
.first { it.id == "t_1" }
.status,
)
assertTrue(
vm.uiState.value.columns
.first { it.name == "todo" }
.tasks
.none { it.id == "t_1" },
)
assertTrue(
vm.uiState.value.columns
.first { it.name == "ready" }
.tasks
.any { it.id == "t_1" },
)
assertTrue(
vm.uiState.value.operatingTaskIds
.contains("t_1"),
)
// Complete network call
deferred.complete(NetworkResult.Success(UpdateTaskResponse(task = task.copy(status = "ready"))))
testDispatcher.scheduler.advanceUntilIdle()
assertFalse(
vm.uiState.value.operatingTaskIds
.contains("t_1"),
)
assertEquals(
"ready",
vm.uiState.value.tasks
.first { it.id == "t_1" }
.status,
)
}
@Test
fun testMoveTaskFailureRevertsTasksAndColumns() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev")
val task = KanbanTask(id = "t_1", title = "Task 1", status = "todo")
val colTodo = KanbanColumn("todo", listOf(task))
val colReady = KanbanColumn("ready", emptyList())
coEvery { mockRepository.getBoard("dev") } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(colTodo, colReady)))
coEvery { mockRepository.updateTask("t_1", "dev", any()) } returns
NetworkResult.Failure(NetworkError.Http(409, "Parent blocked"))
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
vm.moveTask(task, KanbanTaskAction.READY)
testDispatcher.scheduler.advanceUntilIdle()
// Reverted back to "todo" in both tasks and columns!
assertEquals(
"todo",
vm.uiState.value.tasks
.first { it.id == "t_1" }
.status,
)
assertTrue(
vm.uiState.value.columns
.first { it.name == "todo" }
.tasks
.any { it.id == "t_1" },
)
assertTrue(
vm.uiState.value.columns
.first { it.name == "ready" }
.tasks
.none { it.id == "t_1" },
)
assertTrue(
vm.uiState.value.toastMessage
?.contains("Parent blocked") == true,
)
assertFalse(
vm.uiState.value.operatingTaskIds
.contains("t_1"),
)
}
@Test
fun testDuplicateConcurrentMoveIgnored() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev")
val task = KanbanTask(id = "t_1", title = "Task 1", status = "todo")
val colTodo = KanbanColumn("todo", listOf(task))
val deferred = CompletableDeferred<NetworkResult<UpdateTaskResponse>>()
coEvery { mockRepository.getBoard("dev") } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(colTodo)))
coEvery { mockRepository.updateTask("t_1", "dev", any()) } coAnswers { deferred.await() }
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
// First tap
vm.moveTask(task, KanbanTaskAction.READY)
// Second duplicate tap while in-flight
vm.moveTask(task, KanbanTaskAction.ARCHIVE)
testDispatcher.scheduler.runCurrent()
// Only 1 updateTask call initiated
coVerify(exactly = 1) { mockRepository.updateTask("t_1", "dev", any()) }
}
@Test
fun testCreateTaskWithFullOptionsAndStatusAlignment() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev")
val returnedTask = KanbanTask(id = "t_101", title = "Complex Task", status = "ready")
coEvery { mockRepository.getBoard("dev") } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
coEvery { mockRepository.createTask("dev", any()) } returns
NetworkResult.Success(CreateTaskResponse(task = returnedTask))
coEvery { mockRepository.updateTask("t_101", "dev", any()) } returns
NetworkResult.Success(UpdateTaskResponse(task = returnedTask.copy(status = "todo")))
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
val body =
CreateTaskBody(
title = "Complex Task",
body = "Rich spec",
assignee = "reviewer",
priority = 2,
goalMode = true,
modelOverride = "claude-3-5-sonnet",
providerOverride = "anthropic",
reasoningEffort = "high",
skills = listOf("kotlin", "compose"),
parents = listOf("t_parent"),
workspaceKind = "worktree",
)
vm.createTask(body = body, targetStatus = "todo")
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
mockRepository.createTask(
"dev",
match {
it.title == "Complex Task" &&
it.body == "Rich spec" &&
it.assignee == "reviewer" &&
it.priority == 2 &&
it.goalMode &&
it.modelOverride == "claude-3-5-sonnet" &&
it.providerOverride == "anthropic" &&
it.reasoningEffort == "high" &&
it.skills == listOf("kotlin", "compose") &&
it.parents == listOf("t_parent") &&
it.workspaceKind == "worktree"
},
)
}
// Since backend returned "ready" but user wanted "todo", status alignment update is sent
coVerify {
mockRepository.updateTask(
"t_101",
"dev",
match { it.status == "todo" },
)
}
}
@Test
fun testLoadProfilesPopulatesState() =
runTest(testDispatcher) {
val profiles =
listOf(
com.m57.hermescontrol.data.model
.KanbanProfile(name = "researcher", model = "gpt-4o"),
com.m57.hermescontrol.data.model
.KanbanProfile(name = "reviewer", model = "claude-3-5-sonnet"),
)
coEvery { mockRepository.getProfiles() } returns
NetworkResult.Success(
com.m57.hermescontrol.data.model
.KanbanProfilesResponse(profiles),
)
val vm = createViewModel()
vm.loadProfiles()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(2, vm.uiState.value.profiles.size)
assertEquals(
"researcher",
vm.uiState.value.profiles[0]
.name,
)
assertEquals(
"reviewer",
vm.uiState.value.profiles[1]
.name,
)
}
@Test
fun testCreateBoardSuccess() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "sprint-2", name = "Sprint 2")
coEvery { mockRepository.createBoard(any()) } returns
NetworkResult.Success(CreateBoardResponse(board = board))
coEvery { mockRepository.getBoards() } returns
NetworkResult.Success(
com.m57.hermescontrol.data.model
.KanbanBoardsResponse(listOf(board), "sprint-2"),
)
val vm = createViewModel()
vm.createBoard(slug = "sprint-2", name = "Sprint 2", description = "Next cycle")
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
mockRepository.createBoard(
match {
it.slug == "sprint-2" && it.name == "Sprint 2" && it.description == "Next cycle"
},
)
}
assertEquals("Board created: Sprint 2", vm.uiState.value.toastMessage)
}
@Test
fun testRenameBoardSuccess() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev Renamed")
coEvery { mockRepository.updateBoard("dev", any()) } returns
NetworkResult.Success(RenameBoardResponse(board = board))
coEvery { mockRepository.getBoards() } returns
NetworkResult.Success(
com.m57.hermescontrol.data.model
.KanbanBoardsResponse(listOf(board), "dev"),
)
val vm = createViewModel()
vm.renameBoard("dev", "Dev Renamed")
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
mockRepository.updateBoard("dev", match { it.name == "Dev Renamed" })
}
assertEquals("Board renamed to Dev Renamed", vm.uiState.value.toastMessage)
}
@Test
fun testDeleteBoardSuccess() =
runTest(testDispatcher) {
coEvery { mockRepository.deleteBoard("old-board", delete = true) } returns
NetworkResult.Success(DeleteBoardResponse(current = null))
coEvery { mockRepository.getBoards() } returns
NetworkResult.Success(
com.m57.hermescontrol.data.model
.KanbanBoardsResponse(emptyList(), null),
)
val vm = createViewModel()
vm.deleteBoard("old-board")
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockRepository.deleteBoard("old-board", delete = true) }
assertEquals("Board deleted", vm.uiState.value.toastMessage)
}
@Test
fun testBulkMoveTasksSuccess() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev")
coEvery { mockRepository.getBoard("dev") } 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", true),
),
),
)
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
vm.bulkMove(listOf("t_1", "t_2"), "done")
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
mockRepository.bulkTasks(
"dev",
match { it.ids == listOf("t_1", "t_2") && it.status == "done" },
)
}
assertEquals("Moved 2 tasks to done", vm.uiState.value.toastMessage)
}
@Test
fun testBulkArchiveTasksSuccess() =
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(
listOf(
BulkTaskResult("t_1", true),
),
),
)
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
vm.bulkArchive(listOf("t_1"))
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
mockRepository.bulkTasks(
"dev",
match { it.ids == listOf("t_1") && it.archive },
)
}
assertEquals("Archived 1 tasks", vm.uiState.value.toastMessage)
}
@Test
fun testEstimateNewTaskSuccess() =
runTest(testDispatcher) {
coEvery { mockRepository.estimateNew("Title", "Body") } returns
NetworkResult.Success(TaskEstimate(ok = true, estTokens = 1200, complexity = "low"))
val vm = createViewModel()
val estimate = vm.estimateNewTask("Title", "Body")
assertEquals(1200, estimate?.estTokens)
assertEquals("low", estimate?.complexity)
}
}
@@ -0,0 +1,244 @@
package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore
import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
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.CompletableDeferred
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 KanbanScopeTest {
private val testDispatcher = StandardTestDispatcher()
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
private val preferences = InMemoryKanbanPreferencesStore()
private fun createViewModel(): KanbanViewModel =
KanbanViewModel(
repository = mockRepository,
preferences = preferences,
eventsClientProvider = { mockEventsClient },
)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testBoardSwitchScopesCorrectlyForSameTaskId() =
runTest(testDispatcher) {
val boardA = KanbanBoard(id = "board_a", name = "Board A")
val boardB = KanbanBoard(id = "board_b", name = "Board B")
val taskA = KanbanTask(id = "t_1", title = "Task in Board A", status = "todo")
val taskB = KanbanTask(id = "t_1", title = "Task in Board B", status = "ready")
coEvery { mockRepository.getBoards() } returns
NetworkResult.Success(KanbanBoardsResponse(boards = listOf(boardA, boardB), current = "board_a"))
coEvery { mockRepository.getBoard("board_a") } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", listOf(taskA)))))
coEvery { mockRepository.getBoard("board_b") } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("ready", listOf(taskB)))))
val vm = createViewModel()
vm.loadBoards()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
"board_a",
vm.uiState.value.selectedBoard
?.id,
)
assertEquals(
"Task in Board A",
vm.uiState.value.tasks
.first()
.title,
)
// Switch to board B
vm.selectBoard(boardB)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
"board_b",
vm.uiState.value.selectedBoard
?.id,
)
assertEquals(
"Task in Board B",
vm.uiState.value.tasks
.first()
.title,
)
assertEquals(
"ready",
vm.uiState.value.tasks
.first()
.status,
)
}
@Test
fun testDelayedLateResponseDoesNotOverwriteNewerSelection() =
runTest(testDispatcher) {
val boardA = KanbanBoard(id = "board_a", name = "Board A")
val boardB = KanbanBoard(id = "board_b", name = "Board B")
val taskA = KanbanTask(id = "t_a", title = "Task A", status = "todo")
val taskB = KanbanTask(id = "t_b", title = "Task B", status = "todo")
val deferredB = CompletableDeferred<NetworkResult<KanbanBoardResponse>>()
coEvery { mockRepository.getBoard("board_a") } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", listOf(taskA)))))
coEvery { mockRepository.getBoard("board_b") } coAnswers { deferredB.await() }
val vm = createViewModel()
// User selects board B (deferred response)
vm.selectBoard(boardB)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
"board_b",
vm.uiState.value.selectedBoard
?.id,
)
// User quickly selects board A before B responds
vm.selectBoard(boardA)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
"board_a",
vm.uiState.value.selectedBoard
?.id,
)
assertEquals(
"Task A",
vm.uiState.value.tasks
.first()
.title,
)
// Now B finally resolves late
deferredB.complete(
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", listOf(taskB))))),
)
testDispatcher.scheduler.advanceUntilIdle()
// State MUST remain Board A and Task A!
assertEquals(
"board_a",
vm.uiState.value.selectedBoard
?.id,
)
assertEquals(
"Task A",
vm.uiState.value.tasks
.first()
.title,
)
}
@Test
fun testBoardListRefreshPreservesUserSelection() =
runTest(testDispatcher) {
val boardA = KanbanBoard(id = "board_a", name = "Board A")
val boardB = KanbanBoard(id = "board_b", name = "Board B")
coEvery { mockRepository.getBoards() } returns
NetworkResult.Success(KanbanBoardsResponse(boards = listOf(boardA, boardB), current = "board_a"))
coEvery { mockRepository.getBoard("board_b") } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
val vm = createViewModel()
vm.selectBoard(boardB)
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
"board_b",
vm.uiState.value.selectedBoard
?.id,
)
// Refresh boards — server says current is "board_a"
vm.loadBoards()
testDispatcher.scheduler.advanceUntilIdle()
// Local user selection "board_b" must be preserved!
assertEquals(
"board_b",
vm.uiState.value.selectedBoard
?.id,
)
}
@Test
fun testEmptyBoardListHandledGracefully() =
runTest(testDispatcher) {
coEvery { mockRepository.getBoards() } returns
NetworkResult.Success(KanbanBoardsResponse(boards = emptyList(), current = null))
val vm = createViewModel()
vm.loadBoards()
testDispatcher.scheduler.advanceUntilIdle()
assertNull(vm.uiState.value.selectedBoard)
assertTrue(
vm.uiState.value.tasks
.isEmpty(),
)
assertTrue(
vm.uiState.value.columns
.isEmpty(),
)
}
@Test
fun testZeroSwitchCallsDuringNavigation() =
runTest(testDispatcher) {
val boardA = KanbanBoard(id = "board_a", name = "Board A")
val boardB = KanbanBoard(id = "board_b", name = "Board B")
coEvery { mockRepository.getBoards() } returns
NetworkResult.Success(KanbanBoardsResponse(boards = listOf(boardA, boardB), current = "board_a"))
coEvery { mockRepository.getBoard(any()) } returns
NetworkResult.Success(KanbanBoardResponse())
val vm = createViewModel()
vm.loadBoards()
testDispatcher.scheduler.advanceUntilIdle()
vm.selectBoard(boardB)
testDispatcher.scheduler.advanceUntilIdle()
// Zero calls to switchBoard anywhere!
coVerify(exactly = 0) { mockRepository.updateBoard(any(), any()) }
}
}
@@ -0,0 +1,200 @@
package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.KanbanTaskFull
import com.m57.hermescontrol.data.model.ReassignTaskResponse
import com.m57.hermescontrol.data.model.ReclaimTaskResponse
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 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.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class KanbanTaskViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
private fun createViewModel(): KanbanTaskViewModel =
KanbanTaskViewModel(
repository = mockRepository,
)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testLoadTaskSuccess() =
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))
val vm = createViewModel()
vm.loadTask("dev", "t_1")
testDispatcher.scheduler.advanceUntilIdle()
assertNotNull(vm.uiState.value.detail)
assertEquals(
"Task 1",
vm.uiState.value.detail
?.task
?.title,
)
}
@Test
fun testLoadRunningTaskLoadsLog() =
runTest(testDispatcher) {
val task = KanbanTaskFull(id = "t_1", title = "Running task", status = "running")
coEvery { mockRepository.getTask("t_1", "dev") } returns
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
coEvery { mockRepository.getTaskLog("t_1", "dev", any()) } returns
NetworkResult.Success(WorkerLog(taskId = "t_1", content = "Log line 1", exists = true))
val vm = createViewModel()
vm.loadTask("dev", "t_1")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
"Log line 1",
vm.uiState.value.workerLog
?.content,
)
coVerify { mockRepository.getTaskLog("t_1", "dev", any()) }
}
@Test
fun testUpdateDescription() =
runTest(testDispatcher) {
val task = KanbanTaskFull(id = "t_1", title = "Task 1", body = "Old body", 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()
vm.updateDescription("dev", "t_1", "New description")
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(
"New description",
vm.uiState.value.detail
?.task
?.body,
)
assertEquals("Description saved", vm.uiState.value.toastMessage)
}
@Test
fun testAddComment() =
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.addComment("t_1", "dev", "Steering note") } returns
NetworkResult.Success(Unit)
val vm = createViewModel()
vm.loadTask("dev", "t_1")
testDispatcher.scheduler.advanceUntilIdle()
vm.addComment("dev", "t_1", "Steering note")
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockRepository.addComment("t_1", "dev", "Steering note") }
assertEquals("Comment added", vm.uiState.value.toastMessage)
}
@Test
fun testPostNoteAndRequeue() =
runTest(testDispatcher) {
val task = KanbanTaskFull(id = "t_1", title = "Running task", status = "running")
coEvery { mockRepository.getTask("t_1", "dev") } returns
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
coEvery { mockRepository.addComment("t_1", "dev", "Stop now") } returns
NetworkResult.Success(Unit)
coEvery { mockRepository.reclaimTask("t_1", "dev", any()) } returns
NetworkResult.Success(ReclaimTaskResponse(taskId = "t_1"))
val vm = createViewModel()
vm.loadTask("dev", "t_1")
testDispatcher.scheduler.advanceUntilIdle()
vm.postNoteAndRequeue("dev", "t_1", "Stop now")
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockRepository.addComment("t_1", "dev", "Stop now") }
coVerify { mockRepository.reclaimTask("t_1", "dev", match { it.contains("Requeued") }) }
assertTrue(
vm.uiState.value.toastMessage
?.contains("requeued") == true,
)
}
@Test
fun testReassign() =
runTest(testDispatcher) {
coEvery { mockRepository.reassignTask("t_1", "dev", "coder", true) } returns
NetworkResult.Success(ReassignTaskResponse(taskId = "t_1", assignee = "coder"))
val vm = createViewModel()
vm.reassign("dev", "t_1", "coder")
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockRepository.reassignTask("t_1", "dev", "coder", true) }
assertTrue(
vm.uiState.value.toastMessage
?.contains("coder") == true,
)
}
@Test
fun testUploadAttachmentSuccess() =
runTest(testDispatcher) {
val task = KanbanTaskFull(id = "t_1", title = "Task with file", status = "todo")
coEvery { mockRepository.getTask("t_1", "dev") } returns
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
coEvery { mockRepository.uploadAttachment("t_1", "dev", any()) } returns
NetworkResult.Success(
com.m57.hermescontrol.data.model.AttachmentUploadResponse(
attachment =
com.m57.hermescontrol.data.model.KanbanAttachment(
id = 101L,
filename = "test.png",
),
),
)
val vm = createViewModel()
vm.uploadAttachment("dev", "t_1", "test.png", "image/png", byteArrayOf(1, 2, 3))
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockRepository.uploadAttachment("t_1", "dev", any()) }
assertEquals("Attachment uploaded", vm.uiState.value.toastMessage)
}
}
@@ -5,8 +5,9 @@ import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.remote.KanbanApiService
import com.m57.hermescontrol.data.ws.KanbanEvent
import com.m57.hermescontrol.data.ws.KanbanEventsClient
import com.m57.hermescontrol.data.ws.KanbanEventsEnvelope
@@ -23,7 +24,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.ResponseBody.Companion.toResponseBody
@@ -38,7 +38,7 @@ import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class KanbanViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val mockApi = mockk<HermesApiService>(relaxed = true)
private val mockApi = mockk<KanbanApiService>(relaxed = true)
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
private fun createViewModel(): KanbanViewModel {
@@ -60,23 +60,24 @@ class KanbanViewModelTest {
}
private fun stubBoard(tasks: List<KanbanTask> = listOf(KanbanTask(id = "t1", title = "Task 1", status = "todo"))) {
coEvery { mockApi.getKanbanBoards() } returns
coEvery { mockApi.getBoards(any()) } returns
Response.success(
KanbanBoardsResponse(
boards = listOf(KanbanBoard(id = "work", name = "Work")),
current = "work",
),
)
coEvery { mockApi.switchKanbanBoard("work") } returns Response.success(Unit)
coEvery { mockApi.getKanbanBoard() } returns
coEvery { mockApi.getBoard(board = "work", any(), any()) } returns
Response.success(KanbanBoardResponse(columns = listOf(KanbanColumn(name = "todo", tasks = tasks))))
coEvery { mockApi.getBoard(board = "ops", any(), any()) } returns
Response.success(KanbanBoardResponse(columns = listOf(KanbanColumn(name = "todo", tasks = emptyList()))))
}
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockkObject(ApiClient)
every { ApiClient.hermesApi } returns mockApi
every { ApiClient.kanbanApi } returns mockApi
stubBoard()
}
@@ -115,7 +116,7 @@ class KanbanViewModelTest {
val eventsSlot = slot<(KanbanEventsEnvelope) -> Unit>()
verify { mockEventsClient.connect(any(), any(), any(), capture(eventsSlot), any()) }
coEvery { mockApi.getKanbanBoard() } returns
coEvery { mockApi.getBoard(board = "work", any(), any()) } returns
Response.success(
KanbanBoardResponse(
columns =
@@ -138,7 +139,7 @@ class KanbanViewModelTest {
testDispatcher.scheduler.advanceTimeBy(1)
settle()
assertEquals(2, vm.uiState.value.tasks.size)
coVerify(exactly = 2) { mockApi.getKanbanBoard() }
coVerify(atLeast = 2) { mockApi.getBoard(board = "work", any(), any()) }
}
@Test
@@ -146,9 +147,6 @@ class KanbanViewModelTest {
val vm = createViewModel()
vm.loadBoards()
settle()
coEvery { mockApi.switchKanbanBoard("ops") } returns Response.success(Unit)
coEvery { mockApi.getKanbanBoard() } returns
Response.success(KanbanBoardResponse(columns = listOf(KanbanColumn(name = "todo", tasks = emptyList()))))
vm.selectBoard(KanbanBoard(id = "ops", name = "Ops"))
settle()
verify { mockEventsClient.connect(any(), eq("ops"), any(), any(), any()) }
@@ -219,7 +217,7 @@ class KanbanViewModelTest {
val vm = createViewModel()
vm.loadBoards()
settle()
coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.success(Unit)
coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.success(UpdateTaskResponse())
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
settle()
assertEquals(
@@ -228,7 +226,7 @@ class KanbanViewModelTest {
.first { it.id == "t1" }
.status,
)
coVerify { mockApi.updateKanbanTask("t1", mapOf("status" to "ready")) }
coVerify { mockApi.updateTask("t1", "work", match { it.status == "ready" }) }
}
@Test
@@ -236,7 +234,7 @@ class KanbanViewModelTest {
val vm = createViewModel()
vm.loadBoards()
settle()
coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.error(409, "".toResponseBody())
coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.error(409, "".toResponseBody())
vm.moveTask(KanbanTask(id = "t1", title = "Task 1", status = "todo"), KanbanTaskAction.READY)
settle()
assertEquals(
@@ -256,7 +254,7 @@ class KanbanViewModelTest {
val vm = createViewModel()
vm.loadBoards()
settle()
coEvery { mockApi.updateKanbanTask(any(), any()) } returns Response.success(Unit)
coEvery { mockApi.updateTask(any(), any(), any()) } returns Response.success(UpdateTaskResponse())
vm.moveTask(
KanbanTask(id = "t1", title = "Task 1", status = "ready"),
KanbanTaskAction.COMPLETE,
@@ -264,9 +262,10 @@ class KanbanViewModelTest {
)
settle()
coVerify {
mockApi.updateKanbanTask(
mockApi.updateTask(
"t1",
mapOf("status" to "done", "result" to "Shipped it", "summary" to "Shipped it"),
"work",
match { it.status == "done" && it.summary == "Shipped it" && it.result == "Shipped it" },
)
}
}