diff --git a/app/src/main/java/com/m57/hermescontrol/Navigation.kt b/app/src/main/java/com/m57/hermescontrol/Navigation.kt index 0f7d575d..8ec9ed90 100644 --- a/app/src/main/java/com/m57/hermescontrol/Navigation.kt +++ b/app/src/main/java/com/m57/hermescontrol/Navigation.kt @@ -174,6 +174,15 @@ private fun appEntryProvider( onBack = { NavigationController.goBack() }, ) } + + // ── Kanban Task Detail ───────────────────────────────────────────── + entry { key -> + com.m57.hermescontrol.ui.kanban.KanbanTaskScreen( + boardSlug = key.boardSlug, + taskId = key.taskId, + onBack = { NavigationController.goBack() }, + ) + } } @Composable diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt index 776be2da..7f863c92 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationKeys.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/data/local/KanbanPreferencesStore.kt b/app/src/main/java/com/m57/hermescontrol/data/local/KanbanPreferencesStore.kt new file mode 100644 index 00000000..9f42707e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/local/KanbanPreferencesStore.kt @@ -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() + 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 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/Kanban.kt b/app/src/main/java/com/m57/hermescontrol/data/model/Kanban.kt deleted file mode 100644 index 592c4b50..00000000 --- a/app/src/main/java/com/m57/hermescontrol/data/model/Kanban.kt +++ /dev/null @@ -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, -) - -@Serializable -data class KanbanBoardResponse( - val columns: List, - val assignees: List? = null, - val tenants: List? = null, -) - -@Serializable -data class KanbanBoardsResponse( - val boards: List, - val current: String? = null, -) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanActivityModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanActivityModels.kt new file mode 100644 index 00000000..0b6a8974 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanActivityModels.kt @@ -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, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanBoardModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanBoardModels.kt new file mode 100644 index 00000000..22276588 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanBoardModels.kt @@ -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 = 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 = emptyList(), +) + +@Serializable +data class KanbanBoardResponse( + val columns: List = emptyList(), + val assignees: List? = null, + val tenants: List? = null, + val latestEventId: Long = 0L, + val now: Long = 0L, +) + +@Serializable +data class KanbanBoardsResponse( + val boards: List = 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 = 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, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanSettingsModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanSettingsModels.kt new file mode 100644 index 00000000..e53f2914 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanSettingsModels.kt @@ -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 = 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 = emptyMap(), + val warnings: List = emptyList(), + val current: String? = null, +) + +@Serializable +data class DispatchResult( + val spawned: List = emptyList(), +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt new file mode 100644 index 00000000..b93d3c27 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt @@ -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 = emptyMap(), + val suggested: Boolean = false, +) + +@Serializable +data class TaskDiagnostic( + val kind: String, + val severity: String, + val title: String, + val detail: String, + val actions: List = emptyList(), + val count: Int = 1, + val lastSeenAt: Long = 0L, + val data: Map = emptyMap(), +) + +@Serializable +data class TaskLinks( + val parents: List = emptyList(), + val children: List = 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 = emptyList(), +) { + val description: String? get() = body + val assignedTo: String? get() = assignee +} + +@Serializable +data class KanbanTaskDetailResponse( + val task: KanbanTaskFull, + val comments: List = emptyList(), + val events: List = emptyList(), + val attachments: List? = null, + val links: TaskLinks = TaskLinks(), + val childResults: List = emptyList(), + val runs: List = 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 = emptyList(), + val triage: Boolean = false, + val maxRuntimeSeconds: Int? = null, + val skills: List? = null, + val goalMode: Boolean = false, + val goalMaxTurns: Int? = null, + val modelOverride: String? = null, + val providerOverride: String? = null, + val reasoningEffort: String? = null, + val projectId: String? = null, +) + +@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? = 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, + 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? = 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 = 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, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt index 94d32674..e413181b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/ApiClient.kt @@ -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 } } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 88abd60a..f6a29028 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt new file mode 100644 index 00000000..fc85ec16 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt @@ -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 + + @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 + + @POST("api/plugins/kanban/boards") + suspend fun createBoard( + @Body body: CreateBoardBody, + ): Response + + @PATCH("api/plugins/kanban/boards/{slug}") + suspend fun updateBoard( + @Path("slug") slug: String, + @Body body: RenameBoardBody, + ): Response + + @DELETE("api/plugins/kanban/boards/{slug}") + suspend fun deleteBoard( + @Path("slug") slug: String, + @Query("delete") delete: Boolean = false, + ): Response + + @POST("api/plugins/kanban/boards/{slug}/switch") + suspend fun switchBoard( + @Path("slug") slug: String, + ): Response + + @GET("api/plugins/kanban/tasks/{id}") + suspend fun getTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + ): Response + + @POST("api/plugins/kanban/tasks") + suspend fun createTask( + @Query("board") board: String? = null, + @Body body: CreateTaskBody, + ): Response + + @PATCH("api/plugins/kanban/tasks/{id}") + suspend fun updateTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + @Body body: UpdateTaskBody, + ): Response + + @DELETE("api/plugins/kanban/tasks/{id}") + suspend fun deleteTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + ): Response + + @POST("api/plugins/kanban/tasks/bulk") + suspend fun bulkTasks( + @Query("board") board: String? = null, + @Body body: BulkTasksBody, + ): Response + + @POST("api/plugins/kanban/tasks/{id}/comments") + suspend fun addComment( + @Path("id") taskId: String, + @Query("board") board: String? = null, + @Body body: Map, + ): Response + + @POST("api/plugins/kanban/tasks/{id}/reassign") + suspend fun reassignTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + @Body body: ReassignTaskBody, + ): Response + + @POST("api/plugins/kanban/tasks/{id}/reclaim") + suspend fun reclaimTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + @Body body: ReclaimTaskBody = ReclaimTaskBody(), + ): Response + + @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 + + @POST("api/plugins/kanban/tasks/{id}/estimate") + suspend fun estimateTask( + @Path("id") taskId: String, + @Query("board") board: String? = null, + ): Response + + @POST("api/plugins/kanban/estimate") + suspend fun estimateNew( + @Body body: Map, + ): Response + + @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 + + @Streaming + @GET("api/plugins/kanban/attachments/{id}") + suspend fun downloadAttachment( + @Path("id") attachmentId: Long, + @Query("board") board: String? = null, + ): Response + + @GET("api/plugins/kanban/profiles") + suspend fun getKanbanProfiles(): Response + + @PATCH("api/plugins/kanban/profiles/{name}") + suspend fun updateProfileDescription( + @Path("name") name: String, + @Body body: Map, + ): Response + + @POST("api/plugins/kanban/profiles/{name}/describe-auto") + suspend fun autoDescribeProfile( + @Path("name") name: String, + @Body body: Map = mapOf("overwrite" to true), + ): Response + + @GET("api/plugins/kanban/orchestration") + suspend fun getOrchestration(): Response + + @PUT("api/plugins/kanban/orchestration") + suspend fun updateOrchestration( + @Body body: OrchestrationSettingsUpdate, + ): Response + + @GET("api/plugins/kanban/projects") + suspend fun getProjects(): Response + + @POST("api/plugins/kanban/dispatch") + suspend fun nudgeDispatcher( + @Query("board") board: String? = null, + ): Response +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt new file mode 100644 index 00000000..efe3c419 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt @@ -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 + + suspend fun getBoard( + board: String, + includeArchived: Boolean = false, + tenant: String? = null, + ): NetworkResult + + suspend fun createBoard(body: CreateBoardBody): NetworkResult + + suspend fun updateBoard( + slug: String, + body: RenameBoardBody, + ): NetworkResult + + suspend fun deleteBoard( + slug: String, + delete: Boolean = false, + ): NetworkResult + + suspend fun getTask( + taskId: String, + board: String?, + ): NetworkResult + + suspend fun createTask( + board: String?, + body: CreateTaskBody, + ): NetworkResult + + suspend fun updateTask( + taskId: String, + board: String?, + body: UpdateTaskBody, + ): NetworkResult + + suspend fun deleteTask( + taskId: String, + board: String?, + ): NetworkResult + + suspend fun bulkTasks( + board: String?, + body: BulkTasksBody, + ): NetworkResult + + suspend fun addComment( + taskId: String, + board: String?, + body: String, + ): NetworkResult + + suspend fun reassignTask( + taskId: String, + board: String?, + profile: String?, + reclaimFirst: Boolean = true, + ): NetworkResult + + suspend fun reclaimTask( + taskId: String, + board: String?, + reason: String? = null, + ): NetworkResult + + suspend fun getTaskLog( + taskId: String, + board: String?, + tail: Int = 16384, + ): NetworkResult + + suspend fun estimateTask( + taskId: String, + board: String?, + ): NetworkResult + + suspend fun estimateNew( + title: String, + body: String?, + ): NetworkResult + + suspend fun uploadAttachment( + taskId: String, + board: String?, + file: MultipartBody.Part, + ): NetworkResult + + suspend fun downloadAttachment( + attachmentId: Long, + board: String?, + ): NetworkResult + + suspend fun getProfiles(): NetworkResult + + suspend fun updateProfileDescription( + name: String, + description: String, + ): NetworkResult + + suspend fun autoDescribeProfile(name: String): NetworkResult + + suspend fun getOrchestration(): NetworkResult + + suspend fun updateOrchestration(body: OrchestrationSettingsUpdate): NetworkResult + + suspend fun getProjects(): NetworkResult + + suspend fun nudgeDispatcher(board: String?): NetworkResult +} + +class KanbanRepositoryImpl( + private val apiProvider: () -> KanbanApiService = { ApiClient.kanbanApi }, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : KanbanRepository { + override suspend fun getBoards(includeArchived: Boolean): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getBoards(includeArchived) } + } + + override suspend fun getBoard( + board: String, + includeArchived: Boolean, + tenant: String?, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getBoard(board = board, includeArchived = includeArchived, tenant = tenant) } + } + + override suspend fun createBoard(body: CreateBoardBody): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().createBoard(body) } + } + + override suspend fun updateBoard( + slug: String, + body: RenameBoardBody, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().updateBoard(slug, body) } + } + + override suspend fun deleteBoard( + slug: String, + delete: Boolean, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().deleteBoard(slug, delete) } + } + + override suspend fun getTask( + taskId: String, + board: String?, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getTask(taskId = taskId, board = board) } + } + + override suspend fun createTask( + board: String?, + body: CreateTaskBody, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().createTask(board = board, body = body) } + } + + override suspend fun updateTask( + taskId: String, + board: String?, + body: UpdateTaskBody, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().updateTask(taskId = taskId, board = board, body = body) } + } + + override suspend fun deleteTask( + taskId: String, + board: String?, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().deleteTask(taskId = taskId, board = board) } + } + + override suspend fun bulkTasks( + board: String?, + body: BulkTasksBody, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().bulkTasks(board = board, body = body) } + } + + override suspend fun addComment( + taskId: String, + board: String?, + body: String, + ): NetworkResult = + 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 = + 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 = + withContext(ioDispatcher) { + safeApiCall { + apiProvider().reclaimTask( + taskId = taskId, + board = board, + body = ReclaimTaskBody(reason = reason), + ) + } + } + + override suspend fun getTaskLog( + taskId: String, + board: String?, + tail: Int, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getTaskLog(taskId = taskId, tail = tail, board = board) } + } + + override suspend fun estimateTask( + taskId: String, + board: String?, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().estimateTask(taskId = taskId, board = board) } + } + + override suspend fun estimateNew( + title: String, + body: String?, + ): NetworkResult = + 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 = + withContext(ioDispatcher) { + safeApiCall { apiProvider().uploadAttachment(taskId = taskId, board = board, file = file) } + } + + override suspend fun downloadAttachment( + attachmentId: Long, + board: String?, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().downloadAttachment(attachmentId = attachmentId, board = board) } + } + + override suspend fun getProfiles(): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getKanbanProfiles() } + } + + override suspend fun updateProfileDescription( + name: String, + description: String, + ): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { + apiProvider().updateProfileDescription( + name = name, + body = mapOf("description" to description), + ) + } + } + + override suspend fun autoDescribeProfile(name: String): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().autoDescribeProfile(name) } + } + + override suspend fun getOrchestration(): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getOrchestration() } + } + + override suspend fun updateOrchestration(body: OrchestrationSettingsUpdate): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().updateOrchestration(body) } + } + + override suspend fun getProjects(): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().getProjects() } + } + + override suspend fun nudgeDispatcher(board: String?): NetworkResult = + withContext(ioDispatcher) { + safeApiCall { apiProvider().nudgeDispatcher(board) } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanSyncCoordinator.kt b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanSyncCoordinator.kt new file mode 100644 index 00000000..6f9c56f5 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanSyncCoordinator.kt @@ -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.Disconnected) + val syncStatus: StateFlow = _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 + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt index d09fd10d..2551a208 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/KanbanEventsClient.kt @@ -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 { + 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. */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt index cfb5b7b1..34a09bef 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt @@ -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(null) } + var selectedTenant by remember { mutableStateOf(null) } + var includeArchived by remember { mutableStateOf(false) } + var groupRunning by remember { mutableStateOf(false) } + var showFilterSheet by remember { mutableStateOf(false) } val filteredTasks = - 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?>(null) } var summaryTarget by remember { mutableStateOf?>(null) } + // Multi-select & Bulk operations state + var isMultiSelectMode by remember { mutableStateOf(false) } + var selectedTaskIds by remember { mutableStateOf(setOf()) } + 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 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt new file mode 100644 index 00000000..87d796ae --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt @@ -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, + 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, + 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, + 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, + 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, + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt new file mode 100644 index 00000000..8fe7cc1c --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt @@ -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 = _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) } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt index 4bb7293d..5c9499f4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt @@ -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 = emptySet(), val boards: List = emptyList(), val selectedBoard: KanbanBoard? = null, val columns: List = emptyList(), val tasks: List = emptyList(), + val profiles: List = emptyList(), val isLive: Boolean = false, val errorMessage: String? = null, val toastMessage: String? = null, @@ -113,7 +123,12 @@ fun kanbanActionsForStatus(status: String): List = } 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, + 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) { + 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, + 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 { - 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}", + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt new file mode 100644 index 00000000..cfe5d865 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt @@ -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, + defaultColumn: String, + profiles: List, + existingTasks: List, + 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(null) } + var priority by remember { mutableIntStateOf(0) } + var goalMode by remember { mutableStateOf(false) } + + // Estimation state + var estimate by remember { mutableStateOf(null) } + var isEstimating by remember { mutableStateOf(false) } + val coroutineScope = rememberCoroutineScope() + + // Advanced options + var showAdvanced by remember { mutableStateOf(false) } + var parentTaskId by remember { mutableStateOf(null) } + var modelOverride by remember { mutableStateOf("") } + var providerOverride by remember { mutableStateOf("") } + var reasoningEffort by remember { mutableStateOf(null) } + var workspaceKind by remember { mutableStateOf(null) } + var 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)) + } + }, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt new file mode 100644 index 00000000..4dabbdfd --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt @@ -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, + tenants: List, + 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)) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt new file mode 100644 index 00000000..959f6ab3 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt @@ -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, + ) + } + } + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9e0a66b6..338bf909 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -342,6 +342,25 @@ Live Offline No tasks + Priority + 0 (Normal) + 1 (Med) + 2 (High) + 3 (Urgent) + Assignee + Default (Unassigned) + Parked (No worker) + Autonomous Goal Mode + Judge validates task completion across multi-turn iterations + Advanced Options + Parent Task + None + Model Override + Provider Override + Reasoning Effort + Workspace Kind + Skills + Column Add All Statuses diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index b6cb56e0..8590fa48 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -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(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) diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/KanbanSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/KanbanSerializationTest.kt new file mode 100644 index 00000000..cefde4ed --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/model/KanbanSerializationTest.kt @@ -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(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(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(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(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(estRaw) + assertTrue(estimate.ok) + assertEquals(15000, estimate.estTokens) + assertEquals("M", estimate.complexity) + assertEquals("Multi-file changes needed", estimate.rationale) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/KanbanApiServiceTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/KanbanApiServiceTest.kt new file mode 100644 index 00000000..22f62c43 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/KanbanApiServiceTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/repository/KanbanRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/repository/KanbanRepositoryTest.kt new file mode 100644 index 00000000..393fffc2 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/repository/KanbanRepositoryTest.kt @@ -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(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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/repository/KanbanSyncCoordinatorTest.kt b/app/src/test/java/com/m57/hermescontrol/data/repository/KanbanSyncCoordinatorTest.kt new file mode 100644 index 00000000..ec8a3d3c --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/repository/KanbanSyncCoordinatorTest.kt @@ -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(relaxed = true) + private val mockEventsClient = mockk(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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/KanbanEventsClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/KanbanEventsClientTest.kt index 6e21802a..7dea6fb6 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/KanbanEventsClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/KanbanEventsClientTest.kt @@ -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}""") diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt new file mode 100644 index 00000000..fa9d51ab --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt @@ -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(relaxed = true) + private val mockEventsClient = mockk(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>() + + 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>() + 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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanScopeTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanScopeTest.kt new file mode 100644 index 00000000..36e16be9 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanScopeTest.kt @@ -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(relaxed = true) + private val mockEventsClient = mockk(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>() + + 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()) } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt new file mode 100644 index 00000000..fd6c612a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt @@ -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(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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt index 5660cd3c..d60587ff 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanViewModelTest.kt @@ -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(relaxed = true) + private val mockApi = mockk(relaxed = true) private val mockEventsClient = mockk(relaxed = true) private fun createViewModel(): KanbanViewModel { @@ -60,23 +60,24 @@ class KanbanViewModelTest { } private fun stubBoard(tasks: List = 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" }, ) } }