From a2c9a025c6d2ca71d1f56912444c940c4cbfd193 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 06:36:02 +0000 Subject: [PATCH 01/29] chore: bump com.google.devtools.ksp from 2.3.11 to 2.3.12 Bumps [com.google.devtools.ksp](https://github.com/google/ksp) from 2.3.11 to 2.3.12. - [Release notes](https://github.com/google/ksp/releases) - [Commits](https://github.com/google/ksp/compare/2.3.11...2.3.12) --- updated-dependencies: - dependency-name: com.google.devtools.ksp dependency-version: 2.3.12 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 157958c4..872d5eee 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ junitPlatform = "6.1.3" kotlin = "2.4.10" # standalone per KSP 2.0+ guidance — NOT kotlinVersion-kspVersion; easy # to miss during Kotlin upgrades since it no longer tracks the Kotlin version. -ksp = "2.3.11" +ksp = "2.3.12" nav3Core = "1.1.7" lifecycleViewmodelNav3 = "2.11.0" leakcanary = "2.14" From 5a414bacbb9f78a880cda6f97a96f9c3ce1f78b7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Sep 2026 06:36:13 +0000 Subject: [PATCH 02/29] chore: bump net.zetetic:sqlcipher-android from 4.18.0 to 4.19.0 Bumps [net.zetetic:sqlcipher-android](https://github.com/sqlcipher/sqlcipher-android) from 4.18.0 to 4.19.0. - [Release notes](https://github.com/sqlcipher/sqlcipher-android/releases) - [Commits](https://github.com/sqlcipher/sqlcipher-android/compare/v4.18.0...v4.19.0) --- updated-dependencies: - dependency-name: net.zetetic:sqlcipher-android dependency-version: 4.19.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 872d5eee..0c8735f4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,7 +27,7 @@ mockk = "1.14.11" turbine = "1.2.1" coil = "3.6.2" room = "3.0.2" -sqlcipher = "4.18.0" +sqlcipher = "4.19.0" datastore = "1.1.2" latex = "1.5.4" From a486191d9dffb65952f1880b1223a20b124532e5 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:12:30 +0300 Subject: [PATCH 03/29] feat(kanban): add typed models and KanbanApiService for desktop parity --- .../m57/hermescontrol/data/model/Kanban.kt | 44 --- .../data/model/KanbanActivityModels.kt | 61 ++++ .../data/model/KanbanBoardModels.kt | 94 ++++++ .../data/model/KanbanSettingsModels.kt | 70 ++++ .../data/model/KanbanTaskModels.kt | 255 ++++++++++++++ .../hermescontrol/data/remote/ApiClient.kt | 39 ++- .../data/remote/KanbanApiService.kt | 195 +++++++++++ .../hermescontrol/ui/kanban/KanbanScreen.kt | 2 +- .../data/model/KanbanSerializationTest.kt | 292 ++++++++++++++++ .../data/remote/KanbanApiServiceTest.kt | 311 ++++++++++++++++++ 10 files changed, 1307 insertions(+), 56 deletions(-) delete mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/Kanban.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/KanbanActivityModels.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/KanbanBoardModels.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/KanbanSettingsModels.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/KanbanTaskModels.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/model/KanbanSerializationTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/remote/KanbanApiServiceTest.kt 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..04ad2a34 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 @@ -22,6 +22,9 @@ object ApiClient { @Volatile private var service: HermesApiService? = null + @Volatile + private var kanbanService: KanbanApiService? = null + /** The current [HermesApiService] instance. Lazily created on first access. */ val hermesApi: HermesApiService get() { @@ -30,11 +33,20 @@ object ApiClient { } } + /** The current [KanbanApiService] instance. Lazily created on first access. */ + val kanbanApi: KanbanApiService + get() { + return kanbanService ?: synchronized(this) { + kanbanService ?: buildRetrofit().create(KanbanApiService::class.java).also { kanbanService = it } + } + } + /** Force-rebuild the Retrofit client (e.g. after settings change). */ fun rebuild() { synchronized(this) { retrofit = null service = null + kanbanService = null } } @@ -79,7 +91,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 +149,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/KanbanApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt new file mode 100644 index 00000000..ca1a09ea --- /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 getProfiles(): 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/ui/kanban/KanbanScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt index cfb5b7b1..f0476e22 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 @@ -142,7 +142,7 @@ fun KanbanScreen( Tab( selected = board == state.selectedBoard, onClick = { viewModel.selectBoard(board) }, - text = { Text(board.name) }, + text = { Text(board.displayName) }, ) } } 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) + } +} From c57e056d71049df4b619874ff5756a54a6d841af Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:23:01 +0300 Subject: [PATCH 04/29] feat(kanban): enforce board scoping and local selection in KanbanViewModel --- .../data/local/KanbanPreferencesStore.kt | 65 ++++ .../data/repository/KanbanRepository.kt | 366 ++++++++++++++++++ .../ui/kanban/KanbanViewModel.kt | 192 ++++----- .../data/repository/KanbanRepositoryTest.kt | 110 ++++++ .../ui/kanban/KanbanScopeTest.kt | 244 ++++++++++++ .../ui/kanban/KanbanViewModelTest.kt | 35 +- 6 files changed, 900 insertions(+), 112 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/local/KanbanPreferencesStore.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/repository/KanbanRepositoryTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanScopeTest.kt 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/repository/KanbanRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt new file mode 100644 index 00000000..456bfd0c --- /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().getProfiles() } + } + + 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/ui/kanban/KanbanViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt index 4bb7293d..974aa3e0 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,20 @@ 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.CreateTaskBody import com.m57.hermescontrol.data.model.KanbanBoard import com.m57.hermescontrol.data.model.KanbanColumn import com.m57.hermescontrol.data.model.KanbanTask -import com.m57.hermescontrol.data.remote.ApiClient +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,7 +23,6 @@ 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, @@ -113,7 +115,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,58 +129,50 @@ 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) } + 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) } } @@ -186,22 +185,18 @@ class KanbanViewModel( 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, - ), - ) - } - } + repository.createTask( + board = board.id, + body = + CreateTaskBody( + title = title, + body = description, + ), + ) when (result) { is NetworkResult.Success -> { _uiState.update { it.copy(toastMessage = "Task created successfully") } - selectBoard(board) + reloadBoardSilently() } is NetworkResult.Failure -> { @@ -216,6 +211,7 @@ class KanbanViewModel( action: KanbanTaskAction, summary: String? = null, ) { + val board = _uiState.value.selectedBoard ?: return val originalStatus = task.status // Optimistically update, desktop-style _uiState.update { state -> @@ -229,17 +225,12 @@ class KanbanViewModel( viewModelScope.launch { val body = - buildMap { - put("status", action.targetStatus) - if (action.needsSummary && !summary.isNullOrBlank()) { - put("result", summary) - put("summary", summary) - } - } - val result = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.updateKanbanTask(task.id, body) } - } + 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) if (result is NetworkResult.Failure) { revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}") } @@ -300,47 +291,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/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/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/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" }, ) } } From 6fd697f2b00aa5c70cd59c667ba3caf9de39c8b3 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:47:50 +0300 Subject: [PATCH 05/29] feat(kanban): synchronize task mutations across columns and guard concurrent moves --- .../ui/kanban/KanbanViewModel.kt | 106 +++++++- .../ui/kanban/KanbanMutationTest.kt | 247 ++++++++++++++++++ 2 files changed, 339 insertions(+), 14 deletions(-) create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt 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 974aa3e0..d63c67fd 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 @@ -26,6 +26,8 @@ import kotlinx.coroutines.launch 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(), @@ -180,9 +182,11 @@ class KanbanViewModel( fun createTask( title: String, description: String?, - status: String, + status: 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( @@ -195,12 +199,24 @@ class KanbanViewModel( ) when (result) { is NetworkResult.Success -> { - _uiState.update { it.copy(toastMessage = "Task created successfully") } + 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(toastMessage = "Failed to create task: ${result.error.message}") } + _uiState.update { + it.copy( + isCreatingTask = false, + toastMessage = "Failed to create task: ${result.error.message}", + ) + } } } } @@ -212,14 +228,31 @@ class KanbanViewModel( 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, ) } @@ -231,8 +264,38 @@ class KanbanViewModel( result = if (action.needsSummary && !summary.isNullOrBlank()) summary else null, ) val result = repository.updateTask(task.id, board = board.id, body = body) - if (result is NetworkResult.Failure) { - revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}") + 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, + ) + } + } + + is NetworkResult.Failure -> { + revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}") + } } } } @@ -243,11 +306,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, ) } 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..10b75c0b --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt @@ -0,0 +1,247 @@ +package com.m57.hermescontrol.ui.kanban + +import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore +import com.m57.hermescontrol.data.model.CreateTaskBody +import com.m57.hermescontrol.data.model.CreateTaskResponse +import com.m57.hermescontrol.data.model.KanbanBoard +import com.m57.hermescontrol.data.model.KanbanBoardResponse +import com.m57.hermescontrol.data.model.KanbanColumn +import com.m57.hermescontrol.data.model.KanbanTask +import com.m57.hermescontrol.data.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()) } + } +} From da03500b3058a313781559f776268533cca1c2a8 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:51:20 +0300 Subject: [PATCH 06/29] feat(kanban): add KanbanSyncCoordinator and harden WebSocket event decoding --- .../data/repository/KanbanSyncCoordinator.kt | 125 ++++++++++++++++++ .../data/ws/KanbanEventsClient.kt | 34 +++++ .../repository/KanbanSyncCoordinatorTest.kt | 104 +++++++++++++++ .../data/ws/KanbanEventsClientTest.kt | 10 ++ 4 files changed, 273 insertions(+) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/repository/KanbanSyncCoordinator.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/repository/KanbanSyncCoordinatorTest.kt 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/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}""") From dd5ce1079654a7bd0d9e7c703930d9398ada274c Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:58:48 +0300 Subject: [PATCH 07/29] feat(kanban): add rich task card, filter sheet, and task detail screen --- .../java/com/m57/hermescontrol/Navigation.kt | 9 + .../com/m57/hermescontrol/NavigationKeys.kt | 5 + .../hermescontrol/ui/kanban/KanbanScreen.kt | 89 ++- .../ui/kanban/KanbanTaskScreen.kt | 603 ++++++++++++++++++ .../ui/kanban/KanbanTaskViewModel.kt | 287 +++++++++ .../ui/kanban/components/KanbanFilterSheet.kt | 189 ++++++ .../ui/kanban/components/KanbanTaskCard.kt | 264 ++++++++ .../ui/kanban/KanbanTaskViewModelTest.kt | 175 +++++ 8 files changed, 1613 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanFilterSheet.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt 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/ui/kanban/KanbanScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt index f0476e22..6a8edecb 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 @@ -19,6 +19,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.filled.Person import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -46,6 +47,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 +57,8 @@ 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.KanbanFilterSheet +import com.m57.hermescontrol.ui.kanban.components.KanbanTaskCard private const val DEFAULT_COLUMN = "todo" @@ -67,14 +72,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 } } @@ -167,6 +184,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 +253,18 @@ fun KanbanScreen( verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(colTasks, key = { it.id }) { task -> - TaskCard( + KanbanTaskCard( task = task, - onTaskClick = { taskForActions = it }, + onTaskClick = { clickedTask -> + state.selectedBoard?.let { board -> + NavigationController.navigateTo( + KanbanTaskDetailKey( + boardSlug = board.id, + taskId = clickedTask.id, + ), + ) + } + }, ) } } @@ -288,10 +322,49 @@ 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 }, ) } + + 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 }, + ) + } } } } 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..52e7c4d3 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt @@ -0,0 +1,603 @@ +package com.m57.hermescontrol.ui.kanban + +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.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.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.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") + + 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, + ) + } + } + } + } + } + } +} + +@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 { + Text( + text = task.body?.ifBlank { "No description provided." } ?: "No description provided.", + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + // 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)) + Text( + text = summary, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + 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)) + Text(text = comment.body, style = MaterialTheme.typography.bodySmall) + } + } + } + } + } + + 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) + } +} 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..9ac1a067 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt @@ -0,0 +1,287 @@ +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.MultipartBody + +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, + 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/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..a60ac957 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt @@ -0,0 +1,264 @@ +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.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.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, +) { + 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, + ) + } + Text( + text = "#${task.id.removePrefix("t_").take(6)}", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = 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/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..c9f19908 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt @@ -0,0 +1,175 @@ +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, + ) + } +} From 3cc5cc65747901c255a7d9baf196f6ac93141526 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:11:55 +0300 Subject: [PATCH 08/29] refactor(kanban): unify KanbanApiService with HermesApiService and update E2E integration test --- .../hermescontrol/data/remote/ApiClient.kt | 12 ++------ .../data/remote/HermesApiService.kt | 2 +- .../data/remote/KanbanApiService.kt | 2 +- .../data/repository/KanbanRepository.kt | 2 +- .../m57/hermescontrol/E2eIntegrationTest.kt | 28 ++++++++++++------- 5 files changed, 23 insertions(+), 23 deletions(-) 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 04ad2a34..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 @@ -22,9 +22,6 @@ object ApiClient { @Volatile private var service: HermesApiService? = null - @Volatile - private var kanbanService: KanbanApiService? = null - /** The current [HermesApiService] instance. Lazily created on first access. */ val hermesApi: HermesApiService get() { @@ -33,20 +30,15 @@ object ApiClient { } } - /** The current [KanbanApiService] instance. Lazily created on first access. */ + /** The current [KanbanApiService] instance. Reuses [hermesApi]. */ val kanbanApi: KanbanApiService - get() { - return kanbanService ?: synchronized(this) { - kanbanService ?: buildRetrofit().create(KanbanApiService::class.java).also { kanbanService = it } - } - } + get() = hermesApi /** Force-rebuild the Retrofit client (e.g. after settings change). */ fun rebuild() { synchronized(this) { retrofit = null service = null - kanbanService = null } } 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 index ca1a09ea..fc85ec16 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/KanbanApiService.kt @@ -163,7 +163,7 @@ interface KanbanApiService { ): Response @GET("api/plugins/kanban/profiles") - suspend fun getProfiles(): Response + suspend fun getKanbanProfiles(): Response @PATCH("api/plugins/kanban/profiles/{name}") suspend fun updateProfileDescription( diff --git a/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt index 456bfd0c..efe3c419 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/repository/KanbanRepository.kt @@ -323,7 +323,7 @@ class KanbanRepositoryImpl( override suspend fun getProfiles(): NetworkResult = withContext(ioDispatcher) { - safeApiCall { apiProvider().getProfiles() } + safeApiCall { apiProvider().getKanbanProfiles() } } override suspend fun updateProfileDescription( 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) From 11512aeed46bbccdea0b8f9734d7d43590d4d0b4 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:34:26 +0300 Subject: [PATCH 09/29] feat(kanban): add rich create task dialog and quick card action menu --- .../hermescontrol/ui/kanban/KanbanScreen.kt | 60 +-- .../ui/kanban/KanbanViewModel.kt | 54 +- .../components/KanbanCreateTaskDialog.kt | 489 ++++++++++++++++++ .../ui/kanban/components/KanbanTaskCard.kt | 31 +- app/src/main/res/values/strings.xml | 19 + .../ui/kanban/KanbanMutationTest.kt | 97 ++++ 6 files changed, 688 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt index 6a8edecb..46397afc 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 @@ -57,6 +57,7 @@ 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 @@ -265,6 +266,9 @@ fun KanbanScreen( ) } }, + onActionClick = { clickedTask -> + taskForActions = clickedTask + }, ) } } @@ -277,10 +281,15 @@ 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, 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 }, ) @@ -433,51 +442,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/KanbanViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt index d63c67fd..a79b3db8 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 @@ -8,6 +8,7 @@ import com.m57.hermescontrol.data.local.KanbanPreferencesStore 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.model.UpdateTaskBody import com.m57.hermescontrol.data.remote.NetworkResult @@ -32,6 +33,7 @@ data class KanbanUiState( 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, @@ -139,6 +141,7 @@ class KanbanViewModel( 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 -> { @@ -179,10 +182,23 @@ class KanbanViewModel( } } + 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( - title: String, - description: String?, - status: String = "todo", + body: CreateTaskBody, + targetStatus: String = "todo", ) { val board = _uiState.value.selectedBoard ?: return if (_uiState.value.isCreatingTask) return @@ -191,14 +207,21 @@ class KanbanViewModel( val result = repository.createTask( board = board.id, - body = - CreateTaskBody( - title = title, - body = description, - ), + 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()) { @@ -222,6 +245,21 @@ class KanbanViewModel( } } + fun createTask( + title: String, + description: String?, + status: String = "todo", + ) { + createTask( + body = + CreateTaskBody( + title = title, + body = description, + ), + targetStatus = status, + ) + } + fun moveTask( task: KanbanTask, action: KanbanTaskAction, 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..4b57f9c5 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt @@ -0,0 +1,489 @@ +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.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 + +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, +) { + 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) } + + // 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, + ) + } + } + } + }, + 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/KanbanTaskCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt index a60ac957..959f6ab3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt @@ -18,11 +18,13 @@ 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 @@ -42,6 +44,7 @@ fun KanbanTaskCard( onTaskClick: (KanbanTask) -> Unit, modifier: Modifier = Modifier, isSelected: Boolean = false, + onActionClick: ((KanbanTask) -> Unit)? = null, ) { val statusColors = LocalHermesStatusColors.current val statusTone = @@ -90,12 +93,28 @@ fun KanbanTaskCard( color = statusTone, ) } - Text( - text = "#${task.id.removePrefix("t_").take(6)}", - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + 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)) 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/ui/kanban/KanbanMutationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt index 10b75c0b..6d4d96f2 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt @@ -244,4 +244,101 @@ class KanbanMutationTest { // 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, + ) + } } From 6e94fa0be2e2bd2770aa43912de7a46e8b789b7b Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:40:16 +0300 Subject: [PATCH 10/29] feat(kanban): render markdown in task description, summary, and comments --- .../ui/kanban/KanbanTaskScreen.kt | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt index 52e7c4d3..8b6a1f08 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt @@ -16,6 +16,7 @@ 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 @@ -56,6 +57,7 @@ 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 @@ -322,10 +324,12 @@ private fun TaskOverviewTab( } } } else { - Text( - text = task.body?.ifBlank { "No description provided." } ?: "No description provided.", - style = MaterialTheme.typography.bodyMedium, - ) + SelectionContainer { + MarkdownText( + text = task.body?.ifBlank { "No description provided." } ?: "No description provided.", + textColor = MaterialTheme.colorScheme.onSurface, + ) + } } } } @@ -348,10 +352,12 @@ private fun TaskOverviewTab( color = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.height(6.dp)) - Text( - text = summary, - style = MaterialTheme.typography.bodyMedium, - ) + SelectionContainer { + MarkdownText( + text = summary, + textColor = MaterialTheme.colorScheme.onSurface, + ) + } } } } @@ -406,7 +412,12 @@ private fun TaskDiscussionTab( ) } Spacer(modifier = Modifier.height(4.dp)) - Text(text = comment.body, style = MaterialTheme.typography.bodySmall) + SelectionContainer { + MarkdownText( + text = comment.body, + textColor = MaterialTheme.colorScheme.onSurface, + ) + } } } } From 05e78ba13585c88b424aa261aa6dc105f9f5237c Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 15 Sep 2026 00:01:33 +0300 Subject: [PATCH 11/29] feat(kanban): add bulk operations, board management, and file attachments --- .../hermescontrol/ui/kanban/KanbanScreen.kt | 378 +++++++++++++++--- .../ui/kanban/KanbanTaskScreen.kt | 138 ++++++- .../ui/kanban/KanbanTaskViewModel.kt | 14 + .../ui/kanban/KanbanViewModel.kt | 166 ++++++++ .../components/KanbanCreateTaskDialog.kt | 46 +++ .../ui/kanban/KanbanMutationTest.kt | 149 +++++++ .../ui/kanban/KanbanTaskViewModelTest.kt | 25 ++ 7 files changed, 870 insertions(+), 46 deletions(-) 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 46397afc..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,14 +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 @@ -33,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 @@ -105,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() } @@ -114,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 -> @@ -256,18 +329,38 @@ fun KanbanScreen( items(colTasks, key = { it.id }) { task -> KanbanTaskCard( task = task, + isSelected = + isMultiSelectMode && task.id in selectedTaskIds, onTaskClick = { clickedTask -> - state.selectedBoard?.let { board -> - NavigationController.navigateTo( - KanbanTaskDetailKey( - boardSlug = board.id, - taskId = clickedTask.id, - ), - ) + 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 -> - taskForActions = clickedTask + if (isMultiSelectMode) { + selectedTaskIds = + if (clickedTask.id in selectedTaskIds) { + selectedTaskIds - clickedTask.id + } else { + selectedTaskIds + clickedTask.id + } + } else { + taskForActions = clickedTask + } }, ) } @@ -287,6 +380,7 @@ fun KanbanScreen( profiles = state.profiles, existingTasks = state.tasks, isCreating = state.isCreatingTask, + onEstimate = viewModel::estimateNewTask, onDismiss = { showAddTaskDialog = false }, onConfirm = { body, targetStatus -> viewModel.createTask(body, targetStatus) @@ -374,44 +468,238 @@ fun KanbanScreen( onDismiss = { showFilterSheet = false }, ) } - } - } - } - } -} -@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 (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") + } + }, + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt index 8b6a1f08..87d796ae 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt @@ -1,5 +1,9 @@ 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 @@ -51,6 +55,7 @@ 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 @@ -75,7 +80,7 @@ fun KanbanTaskScreen( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() var selectedTabIndex by remember { mutableIntStateOf(0) } - val tabs = listOf("Overview", "Discussion", "Runs", "Activity") + val tabs = listOf("Overview", "Discussion", "Runs", "Activity", "Files") LaunchedEffect(boardSlug, taskId) { viewModel.loadTask(boardSlug, taskId) @@ -171,6 +176,16 @@ fun KanbanTaskScreen( events = detail.events, ) } + + 4 -> { + TaskFilesTab( + attachments = detail.attachments ?: emptyList(), + isUploading = state.isUploadingAttachment, + onUpload = { filename, mimeType, bytes -> + viewModel.uploadAttachment(boardSlug, taskId, filename, mimeType, bytes) + }, + ) + } } } } @@ -612,3 +627,124 @@ private fun MetaRow( 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 index 9ac1a067..8fe7cc1c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt @@ -16,7 +16,9 @@ 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, @@ -251,6 +253,18 @@ class KanbanTaskViewModel( } } + fun uploadAttachment( + board: String, + taskId: String, + filename: String, + mimeType: String, + bytes: ByteArray, + ) { + val requestBody = bytes.toRequestBody(mimeType.toMediaTypeOrNull()) + val part = MultipartBody.Part.createFormData("file", filename, requestBody) + uploadAttachment(board, taskId, part) + } + fun uploadAttachment( board: String, taskId: String, 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 a79b3db8..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 @@ -5,11 +5,15 @@ 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.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.repository.KanbanRepository @@ -260,6 +264,168 @@ class KanbanViewModel( ) } + 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, + ) { + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + val body = + CreateBoardBody( + slug = slug, + name = name, + description = description, + ) + when (val res = repository.createBoard(body)) { + is NetworkResult.Success -> { + val boardName = res.data.board?.name ?: slug + _uiState.update { it.copy(toastMessage = "Board created: $boardName") } + loadBoards() + } + + is NetworkResult.Failure -> { + _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}", + ) + } + } + } + } + } + fun moveTask( task: KanbanTask, action: KanbanTaskAction, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt index 4b57f9c5..cfe5d865 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt @@ -37,6 +37,7 @@ 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 @@ -47,6 +48,8 @@ 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__" @@ -61,6 +64,7 @@ fun KanbanCreateTaskDialog( 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("") } @@ -69,6 +73,11 @@ fun KanbanCreateTaskDialog( 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) } @@ -437,6 +446,43 @@ fun KanbanCreateTaskDialog( ) } } + + 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 = { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt index 6d4d96f2..fa9d51ab 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt @@ -1,12 +1,18 @@ 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 @@ -341,4 +347,147 @@ class KanbanMutationTest { .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/KanbanTaskViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt index c9f19908..fd6c612a 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModelTest.kt @@ -172,4 +172,29 @@ class KanbanTaskViewModelTest { ?.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) + } } From 65c8e07162e143c7ad2a942a6937b94757a2f0a9 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 15 Sep 2026 05:13:56 +0300 Subject: [PATCH 12/29] fix(ui): keep scroll position when focusing scrolled editors (#1116) --- .../components/GroupChatSettingsDialog.kt | 32 +++++++++--- .../ui/common/SyncedTextFieldState.kt | 50 ++++++++++++++++++ .../hermescontrol/ui/config/ConfigScreen.kt | 51 +++++++++++-------- .../hermescontrol/ui/cron/CronJobsScreen.kt | 12 +++-- .../components/KanbanCreateTaskDialog.kt | 9 ++-- .../ui/mcp/components/McpDialogs.kt | 10 ++-- .../ui/profiles/ProfilesScreen.kt | 19 +++++-- .../ui/skills/components/SkillEditorDialog.kt | 22 +++++--- 8 files changed, 155 insertions(+), 50 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/common/SyncedTextFieldState.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt index 5d1d26a2..d1305be1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/bots/group/components/GroupChatSettingsDialog.kt @@ -16,6 +16,9 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.clearText +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog @@ -29,6 +32,7 @@ import androidx.compose.material3.Surface 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 import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -58,9 +62,18 @@ fun GroupChatSettingsDialog( ) { var maxMessages by remember(currentMaxMessages) { mutableStateOf(currentMaxMessages.toFloat()) } var maxPasses by remember(currentMaxPasses) { mutableStateOf(currentMaxPasses.toFloat()) } - var systemPrompt by remember(currentSystemPrompt) { mutableStateOf(currentSystemPrompt.orEmpty()) } + val systemPromptTextFieldState = rememberTextFieldState(currentSystemPrompt.orEmpty()) var showHighLimitsConfirmation by remember { mutableStateOf(false) } + LaunchedEffect(currentSystemPrompt) { + val loaded = currentSystemPrompt.orEmpty() + if (systemPromptTextFieldState.text.toString() != loaded) { + systemPromptTextFieldState.edit { + replace(0, length, loaded) + } + } + } + val isHighLimit = maxMessages.roundToInt() > WARN_MAX_BOT_MESSAGES || maxPasses.roundToInt() > WARN_MAX_CONTINUATION_PASSES @@ -217,8 +230,7 @@ fun GroupChatSettingsDialog( ) Spacer(modifier = Modifier.height(6.dp)) OutlinedTextField( - value = systemPrompt, - onValueChange = { systemPrompt = it }, + state = systemPromptTextFieldState, placeholder = { Text( text = stringResource(R.string.group_chat_settings_system_prompt_hint), @@ -231,7 +243,7 @@ fun GroupChatSettingsDialog( .heightIn(min = 80.dp, max = 160.dp) .testTag("room_system_prompt_input"), textStyle = MaterialTheme.typography.bodyMedium, - maxLines = 4, + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 4), ) } } @@ -245,7 +257,10 @@ fun GroupChatSettingsDialog( onSave( maxMessages.roundToInt(), maxPasses.roundToInt(), - systemPrompt.trim().ifBlank { null }, + systemPromptTextFieldState.text + .toString() + .trim() + .ifBlank { null }, ) } }, @@ -260,7 +275,7 @@ fun GroupChatSettingsDialog( onClick = { maxMessages = DEFAULT_MAX_BOT_MESSAGES.toFloat() maxPasses = DEFAULT_MAX_CONTINUATION_PASSES.toFloat() - systemPrompt = "" + systemPromptTextFieldState.clearText() }, ) { Text(stringResource(R.string.group_chat_settings_reset_default)) @@ -307,7 +322,10 @@ fun GroupChatSettingsDialog( onSave( maxMessages.roundToInt(), maxPasses.roundToInt(), - systemPrompt.trim().ifBlank { null }, + systemPromptTextFieldState.text + .toString() + .trim() + .ifBlank { null }, ) }, colors = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/common/SyncedTextFieldState.kt b/app/src/main/java/com/m57/hermescontrol/ui/common/SyncedTextFieldState.kt new file mode 100644 index 00000000..47d7f069 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/common/SyncedTextFieldState.kt @@ -0,0 +1,50 @@ +package com.m57.hermescontrol.ui.common + +import androidx.compose.foundation.text.input.TextFieldState +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow + +/** + * Two-way bridge between an external [String] holder (ViewModel state, a parent + * callback, …) and a [TextFieldState]-backed text field. + * + * Why not `OutlinedTextField(value:, onValueChange:)`? The value-based overload + * keeps its selection inside the legacy `BasicTextField` implementation, so when + * a scrolled field gains focus it scrolls the cursor into view while the cursor + * is still at index 0 — the viewport jumps back to the top instead of staying + * where the user tapped. `TextFieldState`-backed fields only follow the cursor + * while the user is actively editing, so the scroll position survives the tap. + * + * Inbound (external → field) updates are applied only when the text actually + * differs, which keeps ViewModel round-trips from clobbering in-progress typing; + * outbound (field → external) updates skip the identity case so the two + * directions can never ping-pong. + */ +@Composable +fun rememberSyncedTextFieldState( + value: String, + onValueChange: (String) -> Unit, +): TextFieldState { + val state = rememberTextFieldState(value) + val currentValue by rememberUpdatedState(value) + val currentOnValueChange by rememberUpdatedState(onValueChange) + + LaunchedEffect(state, value) { + if (state.text.toString() != value) { + state.edit { replace(0, length, value) } + } + } + + LaunchedEffect(state) { + snapshotFlow { state.text.toString() } + .collect { newText -> + if (newText != currentValue) currentOnValueChange(newText) + } + } + + return state +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt index 014b844e..3291ef4a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt @@ -18,6 +18,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Code @@ -51,6 +53,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.focus.onFocusChanged @@ -71,6 +74,7 @@ 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.common.rememberSyncedTextFieldState import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive @@ -290,6 +294,8 @@ private fun YAMLEditor( return } + val textFieldState = rememberSyncedTextFieldState(yamlText, onYamlTextChange) + Column( modifier = Modifier @@ -297,14 +303,12 @@ private fun YAMLEditor( .padding(horizontal = 16.dp), ) { OutlinedTextField( - value = yamlText, - onValueChange = onYamlTextChange, + state = textFieldState, modifier = Modifier .fillMaxWidth() .weight(1f), textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), - maxLines = Int.MAX_VALUE, ) Spacer(modifier = Modifier.height(12.dp)) @@ -936,34 +940,41 @@ private fun JsonField( label: String, onChange: (JsonElement) -> Unit, ) { - var text by remember(key) { mutableStateOf(value?.let(::jsonText) ?: "") } + val textFieldState = remember(key) { TextFieldState(value?.let(::jsonText) ?: "") } var hasFocus by remember { mutableStateOf(false) } var invalid by remember { mutableStateOf(false) } - LaunchedEffect(value) { - if (!hasFocus) text = value?.let(::jsonText) ?: "" + LaunchedEffect(value, hasFocus) { + if (!hasFocus) { + val canonical = value?.let(::jsonText) ?: "" + if (textFieldState.text.toString() != canonical) { + textFieldState.edit { + replace(0, length, canonical) + } + } + } + } + + LaunchedEffect(textFieldState) { + snapshotFlow { textFieldState.text.toString() } + .collect { newText -> + val parsed = runCatching { Json.parseToJsonElement(newText) }.getOrNull() + invalid = newText.isNotBlank() && parsed == null + if (parsed != null && newText.isNotBlank() && parsed != value) { + onChange(parsed) + } + } } OutlinedTextField( - value = text, - onValueChange = { newText -> - text = newText - val parsed = runCatching { Json.parseToJsonElement(newText) }.getOrNull() - invalid = newText.isNotBlank() && parsed == null - if (parsed != null && newText.isNotBlank()) { - onChange(parsed) - } - }, + state = textFieldState, modifier = Modifier .fillMaxWidth() .heightIn(min = 96.dp) .onFocusChanged { hasFocus = it.isFocused - if (!it.isFocused) { - invalid = false - text = value?.let(::jsonText) ?: "" - } + if (!it.isFocused) invalid = false }, isError = invalid, supportingText = @@ -974,6 +985,6 @@ private fun JsonField( }, textStyle = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), label = { Text(label) }, - maxLines = Int.MAX_VALUE, + lineLimits = TextFieldLineLimits.MultiLine(minHeightInLines = 4), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt index b463b804..7e408b72 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/cron/CronJobsScreen.kt @@ -72,6 +72,7 @@ import com.m57.hermescontrol.ui.common.StatusBadgeType import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.listContentPadding import com.m57.hermescontrol.ui.common.listItemSpacing +import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState import com.m57.hermescontrol.util.CronExpressionFormatter @Composable @@ -377,6 +378,11 @@ fun CronJobEditorDialog( state.monitor_script.isNotEmpty() || state.monitor_url.isNotEmpty() || state.monitorMode != "off" + val promptTextFieldState = + rememberSyncedTextFieldState(state.prompt) { onFieldChange("prompt", it) } + val skillsTextFieldState = + rememberSyncedTextFieldState(state.skills) { onFieldChange("skills", it) } + ToastEffect(toastMessage = state.toastMessage, onClearToast = onClearToast) Dialog( @@ -506,8 +512,7 @@ fun CronJobEditorDialog( // Prompt OutlinedTextField( - value = state.prompt, - onValueChange = { onFieldChange("prompt", it) }, + state = promptTextFieldState, label = { Text(stringResource(R.string.cron_edit_field_prompt)) }, modifier = Modifier @@ -531,8 +536,7 @@ fun CronJobEditorDialog( // Skills OutlinedTextField( - value = state.skills, - onValueChange = { onFieldChange("skills", it) }, + state = skillsTextFieldState, label = { Text(stringResource(R.string.cron_edit_field_skills)) }, placeholder = { Text(stringResource(R.string.cron_edit_hint_skills)) }, modifier = diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt index cfe5d865..f2a3ed35 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt @@ -13,6 +13,7 @@ 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.text.input.TextFieldLineLimits import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ExpandLess @@ -49,6 +50,7 @@ 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 com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState import kotlinx.coroutines.launch private const val PARKED_VALUE = "__parked__" @@ -68,6 +70,7 @@ fun KanbanCreateTaskDialog( ) { var title by remember { mutableStateOf("") } var desc by remember { mutableStateOf("") } + val descTextFieldState = rememberSyncedTextFieldState(desc) { desc = it } var selectedColumn by remember(defaultColumn) { mutableStateOf(defaultColumn) } var selectedAssignee by remember { mutableStateOf(null) } var priority by remember { mutableIntStateOf(0) } @@ -114,12 +117,10 @@ fun KanbanCreateTaskDialog( Spacer(modifier = Modifier.height(8.dp)) OutlinedTextField( - value = desc, - onValueChange = { desc = it }, + state = descTextFieldState, label = { Text(stringResource(R.string.kanban_task_desc)) }, modifier = Modifier.fillMaxWidth(), - minLines = 3, - maxLines = 5, + lineLimits = TextFieldLineLimits.MultiLine(minHeightInLines = 3, maxHeightInLines = 5), enabled = !isCreating, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt index 039c5ac0..b968520f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/components/McpDialogs.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.input.TextFieldLineLimits import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.UploadFile @@ -37,6 +38,7 @@ import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.McpServer import com.m57.hermescontrol.theme.Spacing import com.m57.hermescontrol.ui.common.DetailDialog +import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState import com.m57.hermescontrol.ui.common.toDetailRows import com.m57.hermescontrol.ui.mcp.McpServersUiState import com.m57.hermescontrol.ui.mcp.McpServersViewModel @@ -112,6 +114,9 @@ fun McpDialogs( } if (state.showImportDialog) { + val importTextFieldState = + rememberSyncedTextFieldState(state.importJsonInput, viewModel::updateImportJsonInput) + AlertDialog( onDismissRequest = { viewModel.toggleImportDialog() }, title = { @@ -157,8 +162,7 @@ fun McpDialogs( } Spacer(modifier = Modifier.height(spacing.xs)) OutlinedTextField( - value = state.importJsonInput, - onValueChange = viewModel::updateImportJsonInput, + state = importTextFieldState, placeholder = { Text( "{\n \"mcpServers\": {\n \"name\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@mcp/srv\"]\n }\n }\n}", @@ -170,7 +174,7 @@ fun McpDialogs( Modifier .fillMaxWidth() .height(180.dp), - maxLines = 10, + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10), ) } }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt index cada9a40..2b362b23 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt @@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check @@ -586,7 +588,15 @@ fun ProfilesScreen( if (soulEditProfileName != null) { val initialText = state.selectedSoulContent ?: "" - var soulText by remember(initialText) { mutableStateOf(initialText) } + val soulTextFieldState = rememberTextFieldState(initialText) + + LaunchedEffect(initialText) { + if (soulTextFieldState.text.toString() != initialText) { + soulTextFieldState.edit { + replace(0, length, initialText) + } + } + } AlertDialog( onDismissRequest = { @@ -615,10 +625,9 @@ fun ProfilesScreen( } } else { OutlinedTextField( - value = soulText, - onValueChange = { soulText = it }, + state = soulTextFieldState, modifier = Modifier.fillMaxWidth(), - maxLines = 10, + lineLimits = TextFieldLineLimits.MultiLine(maxHeightInLines = 10), ) } }, @@ -627,7 +636,7 @@ fun ProfilesScreen( onClick = { val profileName = soulEditProfileName if (profileName != null) { - viewModel.saveSoul(profileName, soulText) + viewModel.saveSoul(profileName, soulTextFieldState.text.toString()) } soulEditProfileName = null }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillEditorDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillEditorDialog.kt index f1109a0c..c27f80b7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillEditorDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/skills/components/SkillEditorDialog.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.skills.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.rememberTextFieldState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Save import androidx.compose.material3.AlertDialog @@ -44,13 +45,21 @@ internal fun SkillEditorDialog( onDismiss: () -> Unit, onClearSaveSuccess: () -> Unit, ) { - var contentText by remember(initialContent) { mutableStateOf(initialContent.orEmpty()) } + val textFieldState = rememberTextFieldState(initialContent.orEmpty()) var showDiscardConfirm by remember { mutableStateOf(false) } + LaunchedEffect(initialContent) { + val newContent = initialContent.orEmpty() + if (textFieldState.text.toString() != newContent) { + textFieldState.edit { + replace(0, length, newContent) + } + } + } + val hasChanges = - remember(initialContent, contentText) { - val original = initialContent.orEmpty() - original != contentText + remember(initialContent, textFieldState.text) { + initialContent.orEmpty() != textFieldState.text.toString() } LaunchedEffect(saveSuccess) { @@ -95,7 +104,7 @@ internal fun SkillEditorDialog( actions = { if (!isLoading) { IconButton( - onClick = { onSave(contentText) }, + onClick = { onSave(textFieldState.text.toString()) }, enabled = !isSaving, ) { if (isSaving) { @@ -126,8 +135,7 @@ internal fun SkillEditorDialog( else -> { OutlinedTextField( - value = contentText, - onValueChange = { contentText = it }, + state = textFieldState, modifier = Modifier .fillMaxSize() From 66ca470880a594487f4fc8eb01a1005c72961e38 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:21:15 +0300 Subject: [PATCH 13/29] fix(#1117): reconcile stale running subagent cards on roster snapshot (#1122) --- .../ui/chat/ChatSubagentsDelegate.kt | 20 +- .../ui/chat/ChatSubagentsDelegateTest.kt | 187 +++++++++++++++++- 2 files changed, 202 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt index e8801097..eaa56386 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegate.kt @@ -213,9 +213,16 @@ class ChatSubagentsDelegate( * Rules: * - `subagent_id` is the primary identity. * - Protects against races: if an indicator received a push event with timestamp > [requestTime], - * its push-event status/type is preserved. + * its push-event status/type is preserved and it is not pruned. * - Preserves local details (logs, taskIndex, taskCount, summary) not provided in the snapshot. - * - Does not discard locally known indicators absent from the snapshot. + * - Reconciles stale running indicators (issue #1117): an identified ([SubagentIndicator.subagentId] != null), + * running ([SubagentIndicator.isRunning]) indicator omitted from an authoritative snapshot is removed + * if its [SubagentIndicator.lastEventTimestamp] <= [requestTime]. This clears indicators that completed + * while disconnected where `subagent.complete` was missed. + * - Protects against races for new subagents: a running indicator spawned or updated by a push event + * newer than [requestTime] is preserved even if omitted from an older in-flight snapshot response. + * - Preserves already-terminal indicators (completed, failed, cancelled) and unassigned placeholders + * ([SubagentIndicator.subagentId] == null) even if omitted from the snapshot. */ internal fun mergeSubagentList( current: List, @@ -300,6 +307,15 @@ class ChatSubagentsDelegate( } } + val snapshotIds = listItems.mapNotNull { it.subagentId.trim().takeIf { id -> id.isNotEmpty() } }.toSet() + merged.removeAll { indicator -> + val id = indicator.subagentId?.trim() + !id.isNullOrEmpty() && + indicator.isRunning && + id !in snapshotIds && + indicator.lastEventTimestamp <= requestTime + } + return merged } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegateTest.kt index 3ceb8aa4..a3179f5d 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatSubagentsDelegateTest.kt @@ -218,7 +218,7 @@ class ChatSubagentsDelegateTest { } @Test - fun testHydrateSubagents_preservesIndicatorsOmittedFromSnapshot() = + fun testHydrateSubagents_removesStaleRunningIndicatorOmittedFromSnapshot() = testScope.runTest { val requestTime = 1000L val current = @@ -228,12 +228,14 @@ class ChatSubagentsDelegateTest { subagentId = "sub-1", goal = "Active worker 1", status = "running", + lastEventTimestamp = 900L, ), SubagentIndicator( type = "subagent.progress", subagentId = "sub-2", goal = "Active worker 2", status = "running", + lastEventTimestamp = 900L, ), ) @@ -247,11 +249,190 @@ class ChatSubagentsDelegateTest { ) val result = delegate.mergeSubagentList(current, listItems, requestTime) - assertEquals(2, result.size) - assertTrue(result.any { it.subagentId == "sub-1" }) + assertEquals(1, result.size) + assertFalse(result.any { it.subagentId == "sub-1" }) assertTrue(result.any { it.subagentId == "sub-2" }) } + @Test + fun testHydrateSubagents_newerPushEventIndicatorOmittedFromSnapshotIsNotRemoved() = + testScope.runTest { + val requestTime = 1000L + val current = + listOf( + SubagentIndicator( + type = "subagent.start", + subagentId = "sub-1", + goal = "Spawned during request", + status = "running", + lastEventTimestamp = 1500L, // Newer than requestTime (1000L) + ), + ) + + // Older snapshot in-flight returns without sub-1 + val listItems = emptyList() + + val result = delegate.mergeSubagentList(current, listItems, requestTime) + assertEquals(1, result.size) + assertEquals("sub-1", result.first().subagentId) + assertEquals("running", result.first().status) + } + + @Test + fun testHydrateSubagents_placeholderWithoutSubagentIdIsPreserved() = + testScope.runTest { + val requestTime = 1000L + val current = + listOf( + SubagentIndicator( + type = "subagent.spawn_requested", + subagentId = null, + goal = "Plan decomposition", + status = "running", + lastEventTimestamp = 800L, + ), + ) + + val listItems = emptyList() + + val result = delegate.mergeSubagentList(current, listItems, requestTime) + assertEquals(1, result.size) + assertNull(result.first().subagentId) + assertEquals("Plan decomposition", result.first().goal) + } + + @Test + fun testHydrateSubagents_terminalIndicatorsOmittedFromSnapshotArePreserved() = + testScope.runTest { + val requestTime = 2000L + val current = + listOf( + SubagentIndicator( + type = "subagent.complete", + subagentId = "sub-completed", + goal = "Finished task", + status = "completed", + lastEventTimestamp = 1000L, + ), + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-failed", + goal = "Failed task", + status = "failed", + lastEventTimestamp = 1000L, + ), + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-cancelled", + goal = "Cancelled task", + status = "cancelled", + lastEventTimestamp = 1000L, + ), + ) + + // Snapshot active roster omits all terminal subagents + val listItems = emptyList() + + val result = delegate.mergeSubagentList(current, listItems, requestTime) + assertEquals(3, result.size) + assertTrue(result.any { it.subagentId == "sub-completed" && it.isComplete }) + assertTrue(result.any { it.subagentId == "sub-failed" && it.isFailed }) + assertTrue(result.any { it.subagentId == "sub-cancelled" && it.isCancelled }) + } + + @Test + fun testHydrateSubagents_nullResponseDoesNotClearIndicators() = + testScope.runTest { + val initial = + listOf( + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-1", + goal = "Active worker", + status = "running", + lastEventTimestamp = 500L, + ), + ) + uiState.value = uiState.value.copy(subagentIndicators = initial) + + coEvery { mockRepository.listSubagents("session-123") } returns null + + delegate.hydrateSubagents("session-123") + advanceUntilIdle() + + assertEquals(initial, uiState.value.subagentIndicators) + } + + @Test + fun testHydrateSubagents_exceptionDoesNotClearIndicators() = + testScope.runTest { + val initial = + listOf( + SubagentIndicator( + type = "subagent.progress", + subagentId = "sub-1", + goal = "Active worker", + status = "running", + lastEventTimestamp = 500L, + ), + ) + uiState.value = uiState.value.copy(subagentIndicators = initial) + + coEvery { mockRepository.listSubagents("session-123") } throws RuntimeException("Network timeout") + + delegate.hydrateSubagents("session-123") + advanceUntilIdle() + + assertEquals(initial, uiState.value.subagentIndicators) + } + + @Test + fun testHydrateSubagents_reconnectReconciliationRemovesStaleRunningCard() = + testScope.runTest { + uiState.value = + uiState.value.copy( + subagentIndicators = + listOf( + SubagentIndicator( + type = "subagent.start", + subagentId = "sub-1", + goal = "Download files", + status = "running", + lastEventTimestamp = 500L, + ), + SubagentIndicator( + type = "subagent.start", + subagentId = "sub-2", + goal = "Process files", + status = "running", + lastEventTimestamp = 500L, + ), + ), + ) + + // sub-1 finished while disconnected, sub-2 is still running + coEvery { mockRepository.listSubagents("session-123") } returns + SubagentListResponse( + subagents = + listOf( + SubagentListItem( + subagentId = "sub-2", + goal = "Process files", + status = "running", + elapsedSeconds = 15.0, + ), + ), + ) + + delegate.hydrateSubagents("session-123") + advanceUntilIdle() + + val indicators = uiState.value.subagentIndicators + assertEquals(1, indicators.size) + assertEquals("sub-2", indicators.first().subagentId) + assertFalse(indicators.any { it.subagentId == "sub-1" }) + } + @Test fun testInspectSubagentTranscript_replacesTailWithoutDuplicateAppending() = testScope.runTest { From 71463a5a9a9f2e0d079ba3b8ec0da22b85cfcd45 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:06:24 +0300 Subject: [PATCH 14/29] feat(ui): add New Chat to History (#1124) --- .../m57/hermescontrol/NavigationController.kt | 18 ++++++++++++++++++ .../ui/chat/components/ChatLifecycleEffects.kt | 8 +++++++- .../ui/sessions/SessionsScreen.kt | 10 ++++++++++ .../hermescontrol/NavigationControllerTest.kt | 15 +++++++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt index 16ddc35f..466067a2 100644 --- a/app/src/main/java/com/m57/hermescontrol/NavigationController.kt +++ b/app/src/main/java/com/m57/hermescontrol/NavigationController.kt @@ -12,6 +12,10 @@ data class PendingChatNavigation( val requestId: Long, ) +data class PendingNewChatNavigation( + val requestId: Long, +) + /** * Central navigation controller with deduplication guard. * @@ -27,9 +31,12 @@ object NavigationController { var backStack: NavBackStack? = null var pendingChatNavigation: PendingChatNavigation? by mutableStateOf(null) private set + var pendingNewChatNavigation: PendingNewChatNavigation? by mutableStateOf(null) + private set val pendingSessionId: String? get() = pendingChatNavigation?.sessionId private var nextChatNavigationRequestId = 0L + private var nextNewChatNavigationRequestId = 0L /** * Top-level primary screens (all drawer-accessible screens). @@ -73,6 +80,14 @@ object NavigationController { queueChatNavigation(sessionId, scrollToBottom = true) } + fun openNewChat() { + pendingNewChatNavigation = + PendingNewChatNavigation( + requestId = ++nextNewChatNavigationRequestId, + ) + navigateTo(ChatScreen) + } + private fun queueChatNavigation( sessionId: String, scrollToBottom: Boolean, @@ -90,6 +105,9 @@ object NavigationController { fun consumePendingChatNavigation(): PendingChatNavigation? = pendingChatNavigation.also { pendingChatNavigation = null } + fun consumePendingNewChatNavigation(): PendingNewChatNavigation? = + pendingNewChatNavigation.also { pendingNewChatNavigation = null } + fun consumePendingSessionId(): String? = consumePendingChatNavigation()?.sessionId /** Clear the stack and navigate to the given screen atomically. */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt index 7af3e313..a45f6b12 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatLifecycleEffects.kt @@ -45,9 +45,15 @@ fun ChatLifecycleEffects( // Switch to session from notification/history val pendingNavigation = NavigationController.pendingChatNavigation - LaunchedEffect(sessionId, pendingNavigation, connectionStatus) { + val pendingNewChatNavigation = NavigationController.pendingNewChatNavigation + LaunchedEffect(sessionId, pendingNavigation, pendingNewChatNavigation, connectionStatus) { if (connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect + val newChatRequest = NavigationController.consumePendingNewChatNavigation() val request = NavigationController.consumePendingChatNavigation() + if (newChatRequest != null) { + viewModel.createNewSession() + return@LaunchedEffect + } val target = request?.sessionId ?: sessionId if (!target.isNullOrBlank()) { viewModel.switchSession(target) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index edfc8465..6d45a04f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.CallSplit import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Code @@ -466,6 +467,15 @@ fun SessionsScreen( isRefreshing = state.isLoading, onRefresh = { viewModel.loadSessions() }, actions = { + IconButton( + onClick = { NavigationController.openNewChat() }, + modifier = Modifier.testTag("sessions_action_new_chat"), + ) { + Icon( + imageVector = Icons.Filled.Add, + contentDescription = stringResource(R.string.content_desc_new_chat), + ) + } if (state.hasHiddenSessions) { IconButton( onClick = { viewModel.toggleShowHidden() }, diff --git a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt index 4013dd96..9579944e 100644 --- a/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/NavigationControllerTest.kt @@ -5,6 +5,7 @@ import androidx.navigation3.runtime.NavKey import org.junit.After 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.Before @@ -21,12 +22,14 @@ class NavigationControllerTest { // Start fresh — no pinned back stack from a previous test NavigationController.backStack = null NavigationController.consumePendingSessionId() + NavigationController.consumePendingNewChatNavigation() } @After fun tearDown() { NavigationController.backStack = null NavigationController.consumePendingSessionId() + NavigationController.consumePendingNewChatNavigation() } // ── Dedup guard: navigateTo with same key ────────────────────────────── @@ -119,6 +122,18 @@ class NavigationControllerTest { assertNull(NavigationController.consumePendingSessionId()) } + @Test + fun `openNewChat navigates to chat and exposes a consumable request`() { + val backStack = NavBackStack(HistoryScreen) + NavigationController.backStack = backStack + + NavigationController.openNewChat() + + assertEquals(ChatScreen, backStack.lastOrNull()) + assertNotNull(NavigationController.consumePendingNewChatNavigation()) + assertNull(NavigationController.consumePendingNewChatNavigation()) + } + @Test fun `openChatSession ignores blank ids`() { val backStack = NavBackStack(HistoryScreen) From fb0f93c000bafc76be0382434a9101941a2ed37b Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:16:20 +0300 Subject: [PATCH 15/29] fix(#1118): use supported replay params (#1123) --- .../hermescontrol/data/ws/HermesWsClient.kt | 2 +- .../data/ws/HermesWsClientTest.kt | 32 +++++++++++++++++-- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index 4a2c7515..c694ed03 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -980,7 +980,7 @@ object HermesWsClient { val deferred = request( method = WsMethods.SESSION_EVENTS_SINCE, - params = mapOf("session_id" to sid, "last_seen" to lastSeen, "since_seq" to lastSeen), + params = mapOf("session_id" to sid, "last_seen" to lastSeen), timeoutMs = 10_000L, ) val result = deferred.await() diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt index bfd30a21..72945a3e 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt @@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.remote.CleartextPolicy import com.m57.hermescontrol.data.remote.CookieManager import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher import com.m57.hermescontrol.data.remote.NetworkMonitor +import com.m57.hermescontrol.data.remote.OkHttpProvider import com.m57.hermescontrol.data.remote.ServerEndpoint import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar import com.m57.hermescontrol.data.session.ActiveSessionHolder @@ -23,6 +24,8 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener @@ -1559,12 +1562,18 @@ class HermesWsClientTest { val ws = serverWebSocket assertNotNull(ws) - val receivedTokens = mutableListOf() + val receivedTokens = Collections.synchronizedList(mutableListOf()) + val tokenALatch = CountDownLatch(1) + val tokenBLatch = CountDownLatch(1) val collectorJob = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { HermesWsClient.events.collect { event -> if (event is WsEvent.MessageToken) { receivedTokens.add(event.token) + when (event.token) { + "A" -> tokenALatch.countDown() + "B" -> tokenBLatch.countDown() + } } } } @@ -1573,7 +1582,7 @@ class HermesWsClientTest { ws!!.send( """{"method":"event","params":{"type":"message.token","session_id":"s1","seq":1,"payload":{"text":"A"}}}""", ) - Thread.sleep(100) + assertTrue(tokenALatch.await(5, TimeUnit.SECONDS)) assertEquals(1, HermesWsClient.getSeqWatermarks()["s1"]) assertEquals(listOf("A"), receivedTokens) @@ -1590,7 +1599,7 @@ class HermesWsClientTest { ws.send( """{"method":"event","params":{"type":"message.token","session_id":"s1","seq":2,"payload":{"text":"B"}}}""", ) - Thread.sleep(100) + assertTrue(tokenBLatch.await(5, TimeUnit.SECONDS)) assertEquals(2, HermesWsClient.getSeqWatermarks()["s1"]) assertEquals(listOf("A", "B"), receivedTokens) @@ -1647,6 +1656,14 @@ class HermesWsClientTest { val serverLatch = CountDownLatch(1) val requestLatch = CountDownLatch(1) var receivedMethod: String? = null + var receivedRequest: JsonObject? = null + val receivedTokens = Collections.synchronizedList(mutableListOf()) + val collectorJob = + CoroutineScope(Dispatchers.IO).launch { + HermesWsClient.events.collect { event -> + if (event is WsEvent.MessageToken) receivedTokens.add(event.token) + } + } mockWebServer.enqueue( MockResponse().withWebSocketUpgrade( @@ -1664,6 +1681,7 @@ class HermesWsClientTest { text: String, ) { if (text.contains(WsMethods.SESSION_EVENTS_SINCE)) { + receivedRequest = OkHttpProvider.json.parseToJsonElement(text) as JsonObject receivedMethod = WsMethods.SESSION_EVENTS_SINCE // Extract ID from JSON-RPC request val id = Regex(""""id":"([^"]+)"""").find(text)?.groupValues?.get(1) ?: "1" @@ -1689,10 +1707,18 @@ class HermesWsClientTest { assertTrue(serverLatch.await(5, TimeUnit.SECONDS)) assertTrue(requestLatch.await(5, TimeUnit.SECONDS)) assertEquals(WsMethods.SESSION_EVENTS_SINCE, receivedMethod) + val request = requireNotNull(receivedRequest) + assertEquals(WsMethods.SESSION_EVENTS_SINCE, request["method"]?.jsonPrimitive?.content) + val params = requireNotNull(request["params"] as? JsonObject) + assertEquals("s1", params["session_id"]?.jsonPrimitive?.content) + assertEquals("5", params["last_seen"]?.jsonPrimitive?.content) + assertFalse(params.containsKey("since_seq")) // Wait for replay processing Thread.sleep(200) assertEquals(6, HermesWsClient.getSeqWatermarks()["s1"]) + assertEquals(listOf("replayed"), receivedTokens) + collectorJob.cancel() } @Test From e4be8b146e693879985f977a6679a12316cb7db1 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:09:11 +0300 Subject: [PATCH 16/29] fix(#1119): handle gateway server requests (#1125) --- .../m57/hermescontrol/data/ws/EventParser.kt | 19 +- .../hermescontrol/data/ws/HermesWsClient.kt | 66 ++++++ .../hermescontrol/data/ws/JsonRpcModels.kt | 4 +- .../com/m57/hermescontrol/data/ws/WsEvent.kt | 41 +++- .../ui/chat/ChatApprovalsDelegate.kt | 74 ++++++- .../ui/chat/ChatClarifyDelegate.kt | 39 +++- .../ui/chat/ChatCredentialPromptsDelegate.kt | 127 +++++++---- .../m57/hermescontrol/ui/chat/ChatMessage.kt | 2 + .../hermescontrol/ui/chat/ChatViewModel.kt | 206 +++++++++++++++++- .../ui/chat/ChatWsEventReducer.kt | 8 + .../ui/chat/components/MessageCards.kt | 161 ++++++++------ .../hermescontrol/data/ws/EventParserTest.kt | 51 +++++ .../data/ws/HermesWsClientTest.kt | 105 +++++++++ .../ui/chat/ChatApprovalsDelegateTest.kt | 111 ++++++++++ .../ui/chat/ChatPromptProtocolTest.kt | 176 +++++++++++++++ .../ui/chat/ChatViewModelTest.kt | 98 +++++++++ 16 files changed, 1169 insertions(+), 119 deletions(-) create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPromptProtocolTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt index 22bdd752..3306955b 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/EventParser.kt @@ -19,9 +19,17 @@ object EventParser { response: JsonRpcResponse, rawJson: String = "", ): WsEvent { - // ── RPC response (has id) ──────────────────────────────────────── + // JSON-RPC is peer-to-peer: an inbound frame with both `id` and `method` + // is a request from the gateway, not a response to one of our calls. val id = response.id - if (id != null) { + if (response.method != null && id != null) { + @Suppress("UNCHECKED_CAST") + val requestParams = response.params?.toAny() as? Map ?: emptyMap() + return WsEvent.ServerRequest(id, response.method, requestParams) + } + + // A response has no method and carries a result or error member. + if (response.method == null && id != null && (response.result != null || response.error != null)) { return if (response.error != null) { WsEvent.RpcError(id, response.error) } else { @@ -220,6 +228,13 @@ object EventParser { WsEvent.ClarifyExpire(clarifyId, sessionId) } + "request.cancel" -> { + val requestId = payload?.get("id") as? String ?: "" + val requestMethod = payload?.get("method") as? String ?: "" + val reason = payload?.get("reason") as? String ?: "" + WsEvent.ServerRequestCancelled(requestId, requestMethod, reason, sessionId) + } + "status.update" -> { val status = payload?.get("status") as? String WsEvent.StatusUpdate(status, payload) diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt index c694ed03..65d3ecc8 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/HermesWsClient.kt @@ -33,6 +33,8 @@ import kotlinx.serialization.encodeToString import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.Response @@ -808,6 +810,55 @@ object HermesWsClient { } } + /** + * Answer a gateway-originated JSON-RPC request. This deliberately bypasses + * [send]: server requests already have an id allocated by the gateway and + * responses must not contain a method or receive a new client id. + */ + fun respondToServerRequest( + id: String, + result: JsonElement, + ): Boolean { + if (id.isBlank()) return false + val json = + buildJsonObject { + put("jsonrpc", "2.0") + put("id", id) + put("result", result) + }.toString() + synchronized(outboundLock) { + val ws = webSocket + if (ws == null || !connected.get()) return false + return ws.send(json) + } + } + + /** Answer a gateway-originated request with a standard JSON-RPC error. */ + fun respondToServerRequestError( + id: String, + code: Int, + message: String, + ): Boolean { + if (id.isBlank()) return false + val json = + buildJsonObject { + put("jsonrpc", "2.0") + put("id", id) + put( + "error", + buildJsonObject { + put("code", code) + put("message", message) + }, + ) + }.toString() + synchronized(outboundLock) { + val ws = webSocket + if (ws == null || !connected.get()) return false + return ws.send(json) + } + } + // ── Send helpers ───────────────────────────────────────────────────── /** @@ -1249,6 +1300,21 @@ object HermesWsClient { resolvePending(rpcId, rpc.result, null) } + // Resume/replay responses carry still-open server requests + // separately from the event ring. Re-deliver them through the + // same generic path before exposing the RPC result. + @Suppress("UNCHECKED_CAST") + val openRequests = + (rpc.result?.toAny() as? Map<*, *>)?.get("open_requests") as? List> + openRequests?.forEach { openRequest -> + val openId = openRequest["id"] as? String ?: return@forEach + val method = openRequest["method"] as? String ?: return@forEach + + @Suppress("UNCHECKED_CAST") + val params = openRequest["params"] as? Map ?: emptyMap() + parsedEvents.tryEmit(WsEvent.ServerRequest(openId, method, params, replayed = true)) + } + if (rpc.id == null) { @Suppress("UNCHECKED_CAST") val params = rpc.params?.toAny() as? Map diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt index b8e91c84..796df16c 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/JsonRpcModels.kt @@ -23,7 +23,9 @@ data class JsonRpcRequest( ) /** - * Incoming JSON-RPC 2.0 response **or** server-pushed notification. + * Incoming JSON-RPC 2.0 response, server-to-client request, or notification. + * Frames carrying both [id] and [method] are classified as server requests by + * [EventParser], never as responses to a client RPC. */ @Serializable data class JsonRpcResponse( diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt index 94dd2b25..237e4558 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsEvent.kt @@ -152,6 +152,8 @@ sealed class WsEvent { val questionId: String? = null, val multiSelect: Boolean = false, val questions: List = emptyList(), + val serverRequestId: String? = null, + val lockedAnswers: Map = emptyMap(), ) : WsEvent() data class ClarifyExpire( @@ -213,6 +215,25 @@ sealed class WsEvent { val sessionId: String? = null, ) : WsEvent() + /** + * Generic JSON-RPC server-to-client request. The gateway keeps the request + * open until the client sends a response frame with this exact [id]. + */ + data class ServerRequest( + val id: String, + val method: String, + val params: Map = emptyMap(), + val replayed: Boolean = false, + ) : WsEvent() + + /** The gateway withdrew a still-open server request (timeout/interruption). */ + data class ServerRequestCancelled( + val id: String, + val method: String, + val reason: String, + val sessionId: String? = null, + ) : WsEvent() + // ── RPC responses ──────────────────────────────────────────────────── data class RpcResult( @@ -256,18 +277,21 @@ sealed class WsEvent { * restricts to once/deny only (desktop `smartDenied` parity). */ val smartDenied: Boolean? = null, + /** JSON-RPC server-request id (`srq-*`) used for the response frame. */ + val serverRequestId: String? = null, ) : WsEvent() // ── Sudo / secret requests ───────────────────────────────────────── /** * Backend needs the user's sudo password to continue a turn - * (desktop: `sudo.request` → `sudo.respond {request_id, password}`). - * Mobile previously dropped this and the agent hung forever. + * New gateways use a same-ID JSON-RPC result frame; legacy gateways fall + * back to `sudo.respond`. */ data class SudoRequest( val requestId: String?, val sessionId: String?, + val serverRequestId: String? = null, ) : WsEvent() /** @@ -278,12 +302,13 @@ sealed class WsEvent { data class SudoExpire( val requestId: String?, val sessionId: String?, + val serverRequestId: String? = null, ) : WsEvent() /** * Backend needs a secret value (password / token) to continue a turn - * (desktop: `secret.request` → `secret.respond {request_id, value}`). - * Mobile previously dropped this and the agent hung forever. + * New gateways use a same-ID JSON-RPC result frame; legacy gateways fall + * back to `secret.respond`. * `envVar`/`prompt` mirror desktop: title = envVar ?: secretTitle, * body = prompt ?: secretDesc. */ @@ -292,6 +317,7 @@ sealed class WsEvent { val sessionId: String?, val envVar: String? = null, val prompt: String? = null, + val serverRequestId: String? = null, ) : WsEvent() /** @@ -301,6 +327,7 @@ sealed class WsEvent { data class SecretExpire( val requestId: String?, val sessionId: String?, + val serverRequestId: String? = null, ) : WsEvent() // ── Credential Vault prompts (issue #1090) ────────────────────────── @@ -310,11 +337,13 @@ sealed class WsEvent { val sessionId: String? = null, val backend: String? = null, val displayName: String? = null, + val serverRequestId: String? = null, ) : WsEvent() data class VaultUnlockExpire( val requestId: String?, val sessionId: String? = null, + val serverRequestId: String? = null, ) : WsEvent() data class VaultSaveLoginRequest( @@ -322,11 +351,13 @@ sealed class WsEvent { val sessionId: String? = null, val origin: String? = null, val site: String? = null, + val serverRequestId: String? = null, ) : WsEvent() data class VaultSaveLoginExpire( val requestId: String?, val sessionId: String? = null, + val serverRequestId: String? = null, ) : WsEvent() data class VaultCodeRequest( @@ -334,11 +365,13 @@ sealed class WsEvent { val sessionId: String? = null, val site: String? = null, val hint: String? = null, + val serverRequestId: String? = null, ) : WsEvent() data class VaultCodeExpire( val requestId: String?, val sessionId: String? = null, + val serverRequestId: String? = null, ) : WsEvent() // ── Gateway-level errors ─────────────────────────────────────────── diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt index 0144d71b..32d68d66 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegate.kt @@ -7,6 +7,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put /** * Owns approval rendering, deduplication, user response submission, and @@ -22,8 +25,49 @@ class ChatApprovalsDelegate( private val wsSend: (method: String, params: Map, onSent: ((String) -> Unit)?) -> Unit, private val trackRequest: (id: String, method: String) -> Unit, private val addSystemMessage: (text: String) -> Unit, + private val respondToServerRequest: ((String, JsonElement) -> Unit)? = null, ) { fun handleApprovalRequest(event: WsEvent.ApprovalRequest) { + val existingIndex = + uiState.value.messages.indexOfLast { message -> + val approval = message.approvalInfo ?: return@indexOfLast false + (event.serverRequestId != null && approval.serverRequestId == event.serverRequestId) || + (event.requestId != null && approval.requestId == event.requestId) + } + if (existingIndex >= 0) { + // `approval.pending` can arrive before the live server request during + // reconnect. Upgrade that legacy card with the direct server id so + // the user's next action answers the same JSON-RPC request instead of + // creating a duplicate prompt. + if (event.serverRequestId != null) { + uiState.update { state -> + state.copy( + messages = + state.messages.mapIndexed { index, message -> + if (index != existingIndex) { + message + } else { + val approval = message.approvalInfo + message.copy( + approvalInfo = + approval?.copy( + command = event.command ?: approval.command, + description = event.description ?: approval.description, + patternKeys = event.patternKeys ?: approval.patternKeys, + requestId = event.requestId ?: approval.requestId, + serverRequestId = event.serverRequestId, + choices = event.choices ?: approval.choices, + allowPermanent = event.allowPermanent ?: approval.allowPermanent, + smartDenied = event.smartDenied ?: approval.smartDenied, + ), + ) + } + }, + ) + } + } + return + } val description = event.description ?: event.command ?: "Unknown command" val content = "**Approval Required**\n$description" val msg = @@ -36,6 +80,7 @@ class ChatApprovalsDelegate( description = event.description, patternKeys = event.patternKeys, requestId = event.requestId, + serverRequestId = event.serverRequestId, choices = event.choices, allowPermanent = event.allowPermanent, smartDenied = event.smartDenied, @@ -51,7 +96,7 @@ class ChatApprovalsDelegate( // so the backend knows this client holds the prompt. Fire-and-forget. val requestId = event.requestId val sessionId = runtimeSessionId() ?: event.sessionId ?: uiState.value.currentSessionId - if (requestId != null && sessionId != null) { + if (event.serverRequestId == null && requestId != null && sessionId != null) { scope.launch(ioDispatcher) { runCatching { wsSend( @@ -118,6 +163,7 @@ class ChatApprovalsDelegate( // (any non-deny still unblocks, but stay on-spec going forward). val choice = if (action == "approve") "once" else action val requestId = approvalMsg.approvalInfo?.requestId + val serverRequestId = approvalMsg.approvalInfo?.serverRequestId // Clear buttons immediately uiState.update { s -> @@ -134,6 +180,16 @@ class ChatApprovalsDelegate( } scope.launch(ioDispatcher) { + if (serverRequestId != null && respondToServerRequest != null) { + respondToServerRequest.invoke( + serverRequestId, + buildJsonObject { + put("choice", choice) + put("all", false) + }, + ) + return@launch + } val params = mutableMapOf( "session_id" to sessionId, @@ -149,6 +205,21 @@ class ChatApprovalsDelegate( replayPendingApproval(sessionId) } } + + fun cancelServerRequest(requestId: String) { + uiState.update { state -> + state.copy( + messages = + state.messages.map { message -> + if (message.approvalInfo?.serverRequestId == requestId) { + message.copy(approvalInfo = null) + } else { + message + } + }, + ) + } + } } /** @@ -189,6 +260,7 @@ internal fun parseApprovalMap( patternKeys = patternKeys, sessionId = sessionId, requestId = map["request_id"] as? String, + serverRequestId = null, choices = choices, allowPermanent = allowPermanent, smartDenied = smartDenied, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt index e24f6b6b..1c284fcf 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatClarifyDelegate.kt @@ -7,6 +7,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put class ChatClarifyDelegate( private val uiState: MutableStateFlow, @@ -15,6 +18,7 @@ class ChatClarifyDelegate( private val persistMessage: suspend (ChatMessage, String) -> Unit, private val wsClient: HermesWsClient = HermesWsClient, private val trackRequest: (String, String) -> Unit = { _, _ -> }, + private val respondToServerRequest: ((String, JsonElement) -> Unit)? = null, ) { fun respondToClarify(option: String) { val clarify = uiState.value.clarifyRequest @@ -41,6 +45,8 @@ class ChatClarifyDelegate( val sessionId = uiState.value.currentSessionId ?: return val clarify = uiState.value.clarifyRequest val clarifyId = clarify?.clarifyId + val serverRequestId = clarify?.serverRequestId + val lockedAnswers = clarify?.lockedAnswers.orEmpty() val isBatch = !clarify?.questions.isNullOrEmpty() val questions = clarify?.resolvedQuestions.orEmpty() uiState.update { it.copy(clarifyRequest = null) } @@ -49,7 +55,9 @@ class ChatClarifyDelegate( if (questions.size > 1) { questions .mapIndexed { index, q -> - val ans = answers[q.qid]?.trim().orEmpty() + val ans = + answers[q.qid]?.trim()?.takeIf { it.isNotEmpty() } + ?: lockedAnswers[q.qid].orEmpty() "${index + 1}. ${ans.ifEmpty { "(Skipped)" }}" }.joinToString("\n") } else { @@ -75,6 +83,35 @@ class ChatClarifyDelegate( } scope.launch(ioDispatcher) { + if (serverRequestId != null && respondToServerRequest != null) { + val finalAnswers = + buildMap { + putAll(lockedAnswers) + answers.forEach { (qid, answer) -> + answer.trim().takeIf { it.isNotEmpty() }?.let { put(qid, it) } + } + } + val result = + if (isBatch) { + buildJsonObject { + put( + "answers", + buildJsonObject { + for ((qid, answer) in finalAnswers) { + put(qid, answer) + } + }, + ) + } + } else { + buildJsonObject { + put("answer", answers.values.firstOrNull()?.trim() ?: singleFallbackAnswer.orEmpty()) + } + } + respondToServerRequest.invoke(serverRequestId, result) + return@launch + } + if (isBatch) { for (q in questions) { val ans = answers[q.qid]?.trim().orEmpty() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt index fdaa51c0..c6e03cf1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatCredentialPromptsDelegate.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -21,15 +22,29 @@ class ChatCredentialPromptsDelegate( private val uiState: MutableStateFlow, private val wsSend: (method: String, params: Map, onSent: ((String) -> Unit)?) -> Unit, private val trackRequest: (id: String, method: String) -> Unit, + private val respondToServerRequest: ((String, JsonElement) -> Unit)? = null, ) { + private fun sendResponse( + serverRequestId: String?, + result: JsonElement, + legacyMethod: String, + legacyParams: Map, + ) { + if (serverRequestId != null && respondToServerRequest != null) { + respondToServerRequest.invoke(serverRequestId, result) + } else { + wsSend(legacyMethod, legacyParams) { id -> trackRequest(id, legacyMethod) } + } + } + /** - * The agent needs the user's sudo password. Surface a secure dialog and - * reply via sudo.respond. + * New gateways use a same-ID JSON-RPC response; legacy notifications fall + * back to sudo.respond. */ fun handleSudoRequest(event: WsEvent.SudoRequest) { uiState.update { it.copy( - sudoPrompt = SudoPromptUi(event.requestId, event.sessionId), + sudoPrompt = SudoPromptUi(event.requestId, event.sessionId, event.serverRequestId), isAgentTyping = false, ) } @@ -42,9 +57,10 @@ class ChatCredentialPromptsDelegate( fun handleSudoExpire(event: WsEvent.SudoExpire) { uiState.update { state -> val current = state.sudoPrompt ?: return@update state - if (event.requestId != null && current.requestId != null && - event.requestId != current.requestId - ) { + if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) { + return@update state + } + if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) { return@update state } state.copy(sudoPrompt = null) @@ -52,8 +68,8 @@ class ChatCredentialPromptsDelegate( } /** - * The agent needs a secret value (token/password). Surface a secure dialog - * and reply via secret.respond. + * New gateways use a same-ID JSON-RPC response; legacy notifications fall + * back to secret.respond. */ fun handleSecretRequest(event: WsEvent.SecretRequest) { uiState.update { @@ -64,6 +80,7 @@ class ChatCredentialPromptsDelegate( event.sessionId, event.envVar, event.prompt, + event.serverRequestId, ), isAgentTyping = false, ) @@ -76,9 +93,10 @@ class ChatCredentialPromptsDelegate( fun handleSecretExpire(event: WsEvent.SecretExpire) { uiState.update { state -> val current = state.secretPrompt ?: return@update state - if (event.requestId != null && current.requestId != null && - event.requestId != current.requestId - ) { + if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) { + return@update state + } + if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) { return@update state } state.copy(secretPrompt = null) @@ -101,10 +119,12 @@ class ChatCredentialPromptsDelegate( "password" to "", ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", "") }, WsMethods.SUDO_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.SUDO_RESPOND) } + ) } } @@ -124,10 +144,12 @@ class ChatCredentialPromptsDelegate( "value" to "", ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", "") }, WsMethods.SECRET_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) } + ) } } @@ -149,10 +171,12 @@ class ChatCredentialPromptsDelegate( "password" to password, ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", password) }, WsMethods.SUDO_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.SUDO_RESPOND) } + ) } } @@ -173,10 +197,12 @@ class ChatCredentialPromptsDelegate( "value" to value, ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", value) }, WsMethods.SECRET_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.SECRET_RESPOND) } + ) } } @@ -184,7 +210,8 @@ class ChatCredentialPromptsDelegate( /** * The agent needs to unlock a password manager (1Password, Bitwarden). - * Surface an interactive prompt card in chat and reply via vault.unlock.respond. + * New gateways use a same-ID JSON-RPC response; legacy notifications fall + * back to vault.unlock.respond. */ fun handleVaultUnlockRequest(event: WsEvent.VaultUnlockRequest) { uiState.update { @@ -195,6 +222,7 @@ class ChatCredentialPromptsDelegate( sessionId = event.sessionId, backend = event.backend, displayName = event.displayName, + serverRequestId = event.serverRequestId, ), isAgentTyping = false, ) @@ -204,9 +232,10 @@ class ChatCredentialPromptsDelegate( fun handleVaultUnlockExpire(event: WsEvent.VaultUnlockExpire) { uiState.update { state -> val current = state.vaultUnlockPrompt ?: return@update state - if (event.requestId != null && current.requestId != null && - event.requestId != current.requestId - ) { + if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) { + return@update state + } + if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) { return@update state } state.copy(vaultUnlockPrompt = null) @@ -228,10 +257,12 @@ class ChatCredentialPromptsDelegate( "password" to "", ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", "") }, WsMethods.VAULT_UNLOCK_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) } + ) } } @@ -249,10 +280,12 @@ class ChatCredentialPromptsDelegate( "password" to password, ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", password) }, WsMethods.VAULT_UNLOCK_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.VAULT_UNLOCK_RESPOND) } + ) } } @@ -268,6 +301,7 @@ class ChatCredentialPromptsDelegate( sessionId = event.sessionId, origin = event.origin, site = event.site, + serverRequestId = event.serverRequestId, ), isAgentTyping = false, ) @@ -277,9 +311,10 @@ class ChatCredentialPromptsDelegate( fun handleVaultSaveLoginExpire(event: WsEvent.VaultSaveLoginExpire) { uiState.update { state -> val current = state.vaultSaveLoginPrompt ?: return@update state - if (event.requestId != null && current.requestId != null && - event.requestId != current.requestId - ) { + if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) { + return@update state + } + if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) { return@update state } state.copy(vaultSaveLoginPrompt = null) @@ -301,10 +336,12 @@ class ChatCredentialPromptsDelegate( "login" to "", ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", "") }, WsMethods.VAULT_SAVE_LOGIN_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) } + ) } } @@ -330,10 +367,12 @@ class ChatCredentialPromptsDelegate( "login" to loginJson, ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", loginJson) }, WsMethods.VAULT_SAVE_LOGIN_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.VAULT_SAVE_LOGIN_RESPOND) } + ) } } @@ -349,6 +388,7 @@ class ChatCredentialPromptsDelegate( sessionId = event.sessionId, site = event.site, hint = event.hint, + serverRequestId = event.serverRequestId, ), isAgentTyping = false, ) @@ -358,9 +398,10 @@ class ChatCredentialPromptsDelegate( fun handleVaultCodeExpire(event: WsEvent.VaultCodeExpire) { uiState.update { state -> val current = state.vaultCodePrompt ?: return@update state - if (event.requestId != null && current.requestId != null && - event.requestId != current.requestId - ) { + if (event.serverRequestId != null && current.serverRequestId != event.serverRequestId) { + return@update state + } + if (event.serverRequestId == null && event.requestId != null && current.requestId != event.requestId) { return@update state } state.copy(vaultCodePrompt = null) @@ -382,10 +423,12 @@ class ChatCredentialPromptsDelegate( "code" to "", ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", "") }, WsMethods.VAULT_CODE_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) } + ) } } @@ -403,10 +446,12 @@ class ChatCredentialPromptsDelegate( "code" to code, ) prompt.requestId?.let { id -> params["request_id"] = id } - wsSend( + sendResponse( + prompt.serverRequestId, + buildJsonObject { put("value", code) }, WsMethods.VAULT_CODE_RESPOND, params, - ) { id -> trackRequest(id, WsMethods.VAULT_CODE_RESPOND) } + ) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt index 8239f426..783ddba0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatMessage.kt @@ -14,6 +14,8 @@ data class ApprovalInfo( val patternKeys: List?, /** Backend `request_id` — sent back in `approval.respond` to pin the exact pending. */ val requestId: String? = null, + /** JSON-RPC server-request id (`srq-*`) used for the response frame. */ + val serverRequestId: String? = null, /** Backend-advertised choices (`once`/`session`/`always`/`deny`). Null = legacy Run/Deny. */ val choices: List? = null, /** False → hide "Always allow" (tirith warning, desktop parity). */ diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 3cf0199e..75824201 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -43,6 +43,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonPrimitive @@ -204,6 +205,8 @@ data class ClarifyUi( val questionId: String? = null, val multiSelect: Boolean = false, val questions: List = emptyList(), + val serverRequestId: String? = null, + val lockedAnswers: Map = emptyMap(), ) { /** * Normalized list of questions to display. Guarantees at least one question @@ -251,6 +254,7 @@ private const val CLARIFY_DISMISS_RESPONSE = "The user cancelled — no answer p data class SudoPromptUi( val requestId: String?, val sessionId: String?, + val serverRequestId: String? = null, ) /** Transient — not persisted. Holds a pending secret (token/password) request. */ @@ -259,6 +263,7 @@ data class SecretPromptUi( val sessionId: String?, val envVar: String? = null, val prompt: String? = null, + val serverRequestId: String? = null, ) /** Transient — not persisted. Holds a pending vault unlock request (issue #1090). */ @@ -267,6 +272,7 @@ data class VaultUnlockPromptUi( val sessionId: String?, val backend: String? = null, val displayName: String? = null, + val serverRequestId: String? = null, ) /** Transient — not persisted. Holds a pending vault save login request (issue #1090). */ @@ -275,6 +281,7 @@ data class VaultSaveLoginPromptUi( val sessionId: String?, val origin: String? = null, val site: String? = null, + val serverRequestId: String? = null, ) /** Transient — not persisted. Holds a pending vault 2FA/MFA code request (issue #1090). */ @@ -283,6 +290,7 @@ data class VaultCodePromptUi( val sessionId: String?, val site: String? = null, val hint: String? = null, + val serverRequestId: String? = null, ) /** @@ -469,6 +477,7 @@ class ChatViewModel( uiState = _uiState, wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) }, trackRequest = { id, method -> trackRequest(id, method) }, + respondToServerRequest = { id, result -> wsClient.respondToServerRequest(id, result) }, ) private val approvalsDelegate = @@ -480,6 +489,7 @@ class ChatViewModel( wsSend = { method, params, onSent -> wsClient.send(method, params, onSent) }, trackRequest = { id, method -> trackRequest(id, method) }, addSystemMessage = { text -> addSystemMessage(text) }, + respondToServerRequest = { id, result -> wsClient.respondToServerRequest(id, result) }, ) private val clarifyDelegate = @@ -490,6 +500,7 @@ class ChatViewModel( persistMessage = { msg, sid -> repo.persistMessage(msg, sid) }, wsClient = wsClient, trackRequest = { id, method -> trackRequest(id, method) }, + respondToServerRequest = { id, result -> wsClient.respondToServerRequest(id, result) }, ) private val subagentsDelegate = @@ -941,6 +952,14 @@ class ChatViewModel( streamingController.resetStreaming() } + is WsEvent.ServerRequest -> { + handleServerRequest(event) + } + + is WsEvent.ServerRequestCancelled -> { + handleServerRequestCancelled(event) + } + is WsEvent.ApprovalRequest -> { approvalsDelegate.handleApprovalRequest(event) } @@ -1015,6 +1034,184 @@ class ChatViewModel( } } + /** Route the generic transport event into the existing prompt delegates. */ + private fun handleServerRequest(request: WsEvent.ServerRequest) { + val params = request.params + val sessionId = params["session_id"] as? String + when (request.method) { + "clarify" -> { + @Suppress("UNCHECKED_CAST") + val rawQuestions = params["questions"] as? List<*> + + @Suppress("UNCHECKED_CAST") + val lockedAnswers = + (params["answers"] as? Map<*, *>) + ?.entries + ?.mapNotNull { (key, value) -> + if (key is String && value is String) key to value else null + }?.toMap() + .orEmpty() + val questions = + rawQuestions.orEmpty().mapIndexedNotNull { index, item -> + val map = item as? Map<*, *> ?: return@mapIndexedNotNull null + val question = map["question"] as? String ?: return@mapIndexedNotNull null + + @Suppress("UNCHECKED_CAST") + val choices = (map["choices"] as? List<*>)?.filterIsInstance() ?: emptyList() + WsEvent.ClarifyQuestion( + qid = map["qid"] as? String ?: "q$index", + question = question, + choices = choices, + multiSelect = map["multi_select"] as? Boolean ?: false, + ) + } + val first = questions.firstOrNull() + handleWsEvent( + WsEvent.ClarifyRequest( + text = first?.question ?: params["question"] as? String, + options = first?.choices ?: (params["choices"] as? List<*>)?.filterIsInstance(), + clarifyId = params["clarify_id"] as? String ?: params["request_id"] as? String, + sessionId = sessionId, + questionId = first?.qid, + multiSelect = first?.multiSelect ?: (params["multi_select"] as? Boolean ?: false), + questions = questions, + serverRequestId = request.id, + lockedAnswers = lockedAnswers, + ), + ) + } + + "approval" -> { + @Suppress("UNCHECKED_CAST") + val choices = (params["choices"] as? List<*>)?.filterIsInstance() + handleWsEvent( + WsEvent.ApprovalRequest( + command = params["command"] as? String, + description = params["description"] as? String, + patternKeys = (params["pattern_keys"] as? List<*>)?.filterIsInstance(), + sessionId = sessionId, + requestId = params["request_id"] as? String, + serverRequestId = request.id, + choices = choices, + allowPermanent = params["allow_permanent"] as? Boolean, + smartDenied = params["smart_denied"] as? Boolean, + ), + ) + } + + "sudo" -> { + handleWsEvent( + WsEvent.SudoRequest( + requestId = params["request_id"] as? String, + sessionId = sessionId, + serverRequestId = request.id, + ), + ) + } + + "secret" -> { + handleWsEvent( + WsEvent.SecretRequest( + requestId = params["request_id"] as? String, + sessionId = sessionId, + envVar = params["env_var"] as? String, + prompt = params["prompt"] as? String, + serverRequestId = request.id, + ), + ) + } + + "vault.code" -> { + handleWsEvent( + WsEvent.VaultCodeRequest( + requestId = params["request_id"] as? String, + sessionId = sessionId, + site = params["site"] as? String, + hint = params["hint"] as? String, + serverRequestId = request.id, + ), + ) + } + + "vault.save_login" -> { + handleWsEvent( + WsEvent.VaultSaveLoginRequest( + requestId = params["request_id"] as? String, + sessionId = sessionId, + origin = params["origin"] as? String, + site = params["site"] as? String, + serverRequestId = request.id, + ), + ) + } + + "vault.unlock_prompt" -> { + handleWsEvent( + WsEvent.VaultUnlockRequest( + requestId = params["request_id"] as? String, + sessionId = sessionId, + backend = params["backend"] as? String, + displayName = params["display_name"] as? String, + serverRequestId = request.id, + ), + ) + } + + else -> { + wsClient.respondToServerRequestError( + request.id, + -32601, + "no handler for server request: ${request.method}", + ) + } + } + } + + private fun handleServerRequestCancelled(event: WsEvent.ServerRequestCancelled) { + when (event.method) { + "clarify" -> { + if (_uiState.value.clarifyRequest?.serverRequestId == event.id) { + _uiState.update { it.copy(clarifyRequest = null) } + streamingController.resetStreaming() + } + } + + "approval" -> { + approvalsDelegate.cancelServerRequest(event.id) + } + + "sudo" -> { + credentialPromptsDelegate.handleSudoExpire( + WsEvent.SudoExpire(null, event.sessionId, serverRequestId = event.id), + ) + } + + "secret" -> { + credentialPromptsDelegate.handleSecretExpire( + WsEvent.SecretExpire(null, event.sessionId, serverRequestId = event.id), + ) + } + + "vault.code" -> { + credentialPromptsDelegate.handleVaultCodeExpire( + WsEvent.VaultCodeExpire(null, event.sessionId, serverRequestId = event.id), + ) + } + + "vault.save_login" -> { + credentialPromptsDelegate.handleVaultSaveLoginExpire( + WsEvent.VaultSaveLoginExpire(null, event.sessionId, serverRequestId = event.id), + ) + } + + "vault.unlock_prompt" -> { + credentialPromptsDelegate.handleVaultUnlockExpire( + WsEvent.VaultUnlockExpire(null, event.sessionId, serverRequestId = event.id), + ) + } + } + } + // ── Message streaming ──────────────────────────────────────────────── /** @@ -3452,8 +3649,8 @@ class ChatViewModel( * * The backend's clarify tool blocks the agent thread waiting for a response * (CLI timeout is 120s). A silent dismiss would leave the agent hanging - * until that timeout, so we send a cancel sentinel - * ([CLARIFY_DISMISS_RESPONSE]) over `clarify.respond` to unblock it. + * until that timeout, so we send an empty result frame for new gateways or + * the legacy cancel sentinel for old notification-based gateways. * * This is a *reject*, not an instruction to proceed — the agent is told no * answer was provided and should re-ask or back off, NOT charge ahead. @@ -3467,6 +3664,7 @@ class ChatViewModel( val sessionId = _uiState.value.currentSessionId ?: return val clarify = _uiState.value.clarifyRequest val clarifyId = clarify?.clarifyId + val serverRequestId = clarify?.serverRequestId // Raw batch questions (non-empty only for true batch payloads). // Legacy singles keep questions empty and rely on questionId (nullable). val isBatch = !clarify?.questions.isNullOrEmpty() @@ -3476,6 +3674,10 @@ class ChatViewModel( addSystemMessage("Clarify dismissed — no answer sent", persist = true) viewModelScope.launch(ioDispatcher) { + if (serverRequestId != null) { + wsClient.respondToServerRequest(serverRequestId, buildJsonObject {}) + return@launch + } if (isBatch) { // Send dismissal for every question in the batch for (q in displayQuestions) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index 068e4b61..f79882f0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -164,6 +164,12 @@ object ChatWsEventReducer { // RpcResult is handled by the ViewModel (needs pending request context) is WsEvent.RpcResult -> ReducerResult(state = state, streamingState = streamingState) + // Server requests are dispatched by ChatViewModel to the existing + // prompt delegates; the reducer must remain side-effect free. + is WsEvent.ServerRequest -> ReducerResult(state = state, streamingState = streamingState) + + is WsEvent.ServerRequestCancelled -> ReducerResult(state = state, streamingState = streamingState) + // ApprovalRequest is handled by the ViewModel (needs active session + WS client) is WsEvent.ApprovalRequest -> ReducerResult(state = state, streamingState = streamingState) @@ -706,6 +712,8 @@ object ChatWsEventReducer { questionId = event.questionId, multiSelect = event.multiSelect, questions = questions, + serverRequestId = event.serverRequestId, + lockedAnswers = event.lockedAnswers, ), isAgentTyping = false, ), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt index 8ec0a493..dd0477ab 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt @@ -473,6 +473,7 @@ fun ClarifyBubble( horizontalAlignment = Alignment.CenterHorizontally, ) { questions.forEachIndexed { index, q -> + val lockedAnswer = clarifyRequest.lockedAnswers[q.qid] if (index > 0) { HorizontalDivider( modifier = Modifier.padding(vertical = 12.dp), @@ -497,79 +498,105 @@ fun ClarifyBubble( ) } - if (q.multiSelect) { - Spacer(Modifier.height(2.dp)) - Text( - text = "Select all that apply", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth(), - ) - } - - if (q.choices.isNotEmpty()) { - Spacer(Modifier.height(8.dp)) - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), + if (lockedAnswer != null) { + Surface( + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(8.dp), ) { - q.choices.forEach { choice -> - val selectedSet = selectedChoicesByQid[q.qid] ?: emptySet() - val isSelected = selectedSet.contains(choice) - FilterChip( - selected = isSelected, - onClick = { - if (q.multiSelect) { - val updated = if (isSelected) selectedSet - choice else selectedSet + choice - selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated) - } else { - if (!isBatch && customTextByQid[q.qid].isNullOrBlank()) { - // Fast 1-tap respond for lone single-select question when no custom text is entered - onRespondSingle(choice) - } else { - val updated = if (isSelected) emptySet() else setOf(choice) - selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated) - } - } - }, - label = { Text(choice) }, - leadingIcon = - if (isSelected) { - { - Icon( - imageVector = Icons.Default.Check, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - } - } else { - null - }, + Row( + modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = "Answered: $lockedAnswer", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, ) } } - } - - Spacer(Modifier.height(8.dp)) - val typed = customTextByQid[q.qid].orEmpty() - OutlinedTextField( - value = typed, - onValueChange = { newText -> - customTextByQid = customTextByQid + (q.qid to newText) - }, - label = { + } else { + if (q.multiSelect) { + Spacer(Modifier.height(2.dp)) Text( - if (q.choices.isEmpty()) { - stringResource(R.string.message_your_response) - } else { - "Other (optional)" - }, + text = "Select all that apply", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), ) - }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) + } + + if (q.choices.isNotEmpty()) { + Spacer(Modifier.height(8.dp)) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + q.choices.forEach { choice -> + val selectedSet = selectedChoicesByQid[q.qid] ?: emptySet() + val isSelected = selectedSet.contains(choice) + FilterChip( + selected = isSelected, + onClick = { + if (q.multiSelect) { + val updated = if (isSelected) selectedSet - choice else selectedSet + choice + selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated) + } else { + if (!isBatch && customTextByQid[q.qid].isNullOrBlank()) { + // Fast 1-tap respond for lone single-select question when no custom text is entered + onRespondSingle(choice) + } else { + val updated = if (isSelected) emptySet() else setOf(choice) + selectedChoicesByQid = selectedChoicesByQid + (q.qid to updated) + } + } + }, + label = { Text(choice) }, + leadingIcon = + if (isSelected) { + { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) + } + } else { + null + }, + ) + } + } + } + + Spacer(Modifier.height(8.dp)) + val typed = customTextByQid[q.qid].orEmpty() + OutlinedTextField( + value = typed, + onValueChange = { newText -> + customTextByQid = customTextByQid + (q.qid to newText) + }, + label = { + Text( + if (q.choices.isEmpty()) { + stringResource(R.string.message_your_response) + } else { + "Other (optional)" + }, + ) + }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + } } Spacer(Modifier.height(12.dp)) diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt index 6f90080f..5f79c483 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/EventParserTest.kt @@ -59,6 +59,57 @@ class EventParserTest { assertEquals(mapOf("status" to "success"), rpcResult.result) } + @Test + fun testParseServerRequest_withIdAndMethod_isNotRpcResult() { + val event = + EventParser.parse( + createJsonRpcResponse( + jsonrpc = "2.0", + id = "srq-123", + method = "clarify", + params = mapOf("session_id" to "session-1", "question" to "Continue?"), + ), + ) + + assertTrue(event is WsEvent.ServerRequest) + assertFalse(event is WsEvent.RpcResult) + val request = event as WsEvent.ServerRequest + assertEquals("srq-123", request.id) + assertEquals("clarify", request.method) + assertEquals("session-1", request.params["session_id"]) + assertEquals("Continue?", request.params["question"]) + } + + @Test + fun testParseRequestCancel_notification_returnsCancellationEvent() { + val event = + EventParser.parse( + createJsonRpcResponse( + jsonrpc = "2.0", + id = null, + method = "event", + params = + mapOf( + "type" to "request.cancel", + "session_id" to "session-1", + "payload" to + mapOf( + "id" to "srq-123", + "method" to "clarify", + "reason" to "timeout", + ), + ), + ), + ) + + assertTrue(event is WsEvent.ServerRequestCancelled) + val cancelled = event as WsEvent.ServerRequestCancelled + assertEquals("srq-123", cancelled.id) + assertEquals("clarify", cancelled.method) + assertEquals("timeout", cancelled.reason) + assertEquals("session-1", cancelled.sessionId) + } + @Test fun testParseRpcError_returnsRpcErrorEvent() { val error = JsonRpcError(code = -32600, message = "Invalid Request") diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt index 72945a3e..dff70f1a 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/HermesWsClientTest.kt @@ -19,13 +19,18 @@ import io.mockk.verify import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put import okhttp3.Response import okhttp3.WebSocket import okhttp3.WebSocketListener @@ -164,6 +169,106 @@ class HermesWsClientTest { assertTrue(msg.contains(id)) } + @Test + fun testOpenRequestsReplayUsesTheLiveServerRequestDispatcher() = + runBlocking { + mockWebServer.enqueue( + MockResponse().withWebSocketUpgrade( + object : WebSocketListener() { + override fun onOpen( + webSocket: WebSocket, + response: okhttp3.Response, + ) { + webSocket.send( + """{"jsonrpc":"2.0","id":"42","result":{"open_requests":[{"id":"srq-secret","method":"secret","params":{"session_id":"session-1","env_var":"API_KEY","prompt":"Enter API key"}}]}}""", + ) + } + }, + ), + ) + + val received = + async { + withTimeout(5_000) { + HermesWsClient.events.first { + it is WsEvent.ServerRequest && it.id == "srq-secret" + } + } + } + HermesWsClient.connect() + val event = received.await() as WsEvent.ServerRequest + + assertEquals("secret", event.method) + assertEquals("session-1", event.params["session_id"]) + assertEquals("API_KEY", event.params["env_var"]) + } + + @Test + fun testRespondToServerRequest_preservesIdAndOmitsMethod() { + val socket = mockk() + every { socket.send(any()) } returns true + every { socket.close(any(), any()) } returns true + val socketField = HermesWsClient::class.java.getDeclaredField("webSocket").apply { isAccessible = true } + val connectedField = HermesWsClient::class.java.getDeclaredField("connected").apply { isAccessible = true } + socketField.set(HermesWsClient, socket) + (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) + + val sent = + HermesWsClient.respondToServerRequest( + "srq-abc123", + buildJsonObject { put("value", "hello") }, + ) + + assertTrue(sent) + verify { + socket.send( + match { raw -> + val frame = Json.parseToJsonElement(raw).jsonObject + frame["jsonrpc"]?.jsonPrimitive?.content == "2.0" && + frame["id"]?.jsonPrimitive?.content == "srq-abc123" && + frame["result"] + ?.jsonObject + ?.get("value") + ?.jsonPrimitive + ?.content == "hello" && + !frame.containsKey("method") + }, + ) + } + } + + @Test + fun testRespondToServerRequestError_preservesIdAndErrorShape() { + val socket = mockk() + every { socket.send(any()) } returns true + every { socket.close(any(), any()) } returns true + val socketField = HermesWsClient::class.java.getDeclaredField("webSocket").apply { isAccessible = true } + val connectedField = HermesWsClient::class.java.getDeclaredField("connected").apply { isAccessible = true } + socketField.set(HermesWsClient, socket) + (connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true) + + assertTrue(HermesWsClient.respondToServerRequestError("srq-abc123", -32601, "unsupported")) + verify { + socket.send( + match { raw -> + val frame = Json.parseToJsonElement(raw).jsonObject + frame["id"]?.jsonPrimitive?.content == "srq-abc123" && + frame["error"] + ?.jsonObject + ?.get("code") + ?.jsonPrimitive + ?.content == "-32601" && + frame["error"] + ?.jsonObject + ?.get("message") + ?.jsonPrimitive + ?.content == "unsupported" && + !frame.containsKey("method") + }, + ) + } + } + @Test fun testFailedSocketSendQueuesMessageForReconnect() { val staleSocket = mockk(relaxed = true) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt index 6623c4bf..bc797046 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatApprovalsDelegateTest.kt @@ -8,8 +8,12 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive 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 @@ -196,4 +200,111 @@ class ChatApprovalsDelegateTest { delegate.handleApprovalRespondResult(mapOf("resolved" to 1)) assertEquals(listOf("Approval submitted"), systemMessages) } + + @Test + fun cancelServerRequest_matchesServerRequestIdOnly() { + delegate.handleApprovalRequest( + WsEvent.ApprovalRequest( + command = "rm -rf /tmp/x", + description = "dangerous", + patternKeys = null, + sessionId = "session-1", + requestId = "approval-1", + serverRequestId = "srq-approval-1", + ), + ) + + delegate.cancelServerRequest("srq-other") + assertNotNull( + uiState.value.messages + .last() + .approvalInfo, + ) + + delegate.cancelServerRequest("srq-approval-1") + assertNull( + uiState.value.messages + .last() + .approvalInfo, + ) + } + + @Test + fun liveServerApproval_upgradesMatchingPendingApprovalWithoutDuplicating() { + delegate.handleApprovalRequest( + WsEvent.ApprovalRequest( + command = "git status", + description = "inspect", + patternKeys = null, + sessionId = "session-1", + requestId = "approval-business-id", + ), + ) + delegate.handleApprovalRequest( + WsEvent.ApprovalRequest( + command = "git status", + description = "inspect", + patternKeys = null, + sessionId = "session-1", + requestId = "approval-business-id", + serverRequestId = "srq-approval", + ), + ) + + assertEquals(1, uiState.value.messages.size) + assertEquals( + "srq-approval", + uiState.value.messages + .single() + .approvalInfo + ?.serverRequestId, + ) + } + + @Test + fun serverApproval_usesChoiceAndAllResultPayload() { + val responses = mutableListOf>() + val serverDelegate = + ChatApprovalsDelegate( + scope = testScope, + ioDispatcher = testDispatcher, + uiState = uiState, + runtimeSessionId = { "session-1" }, + wsSend = { _, _, _ -> error("new approval must not use legacy response") }, + trackRequest = { _, _ -> }, + addSystemMessage = { }, + respondToServerRequest = { id, result -> responses += id to result }, + ) + serverDelegate.handleApprovalRequest( + WsEvent.ApprovalRequest( + command = "rm -rf /tmp/x", + description = "dangerous", + patternKeys = null, + sessionId = "session-1", + requestId = "approval-1", + serverRequestId = "srq-approval", + ), + ) + + serverDelegate.respondToApproval("approve") + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals("srq-approval", responses.single().first) + assertEquals( + "once", + responses + .single() + .second.jsonObject["choice"] + ?.jsonPrimitive + ?.content, + ) + assertEquals( + "false", + responses + .single() + .second.jsonObject["all"] + ?.jsonPrimitive + ?.content, + ) + } } diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPromptProtocolTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPromptProtocolTest.kt new file mode 100644 index 00000000..55f61e9d --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatPromptProtocolTest.kt @@ -0,0 +1,176 @@ +package com.m57.hermescontrol.ui.chat + +import com.m57.hermescontrol.data.ws.WsEvent +import com.m57.hermescontrol.data.ws.WsMethods +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ChatPromptProtocolTest { + private val dispatcher = StandardTestDispatcher() + + @Test + fun clarifyServerResponse_mergesLockedAnswers_withoutEmptyOverwrite() { + val scope = TestScope(dispatcher) + val uiState = + MutableStateFlow( + ChatUiState( + currentSessionId = "session-1", + clarifyRequest = + ClarifyUi( + text = "", + questions = + listOf( + ClarifyQuestionUi(qid = "q0", question = "First?"), + ClarifyQuestionUi(qid = "q1", question = "Second?"), + ), + serverRequestId = "srq-clarify", + lockedAnswers = mapOf("q0" to "yes"), + ), + ), + ) + var responseId: String? = null + var response: JsonElement? = null + + val delegate = + ChatClarifyDelegate( + uiState = uiState, + scope = scope, + ioDispatcher = dispatcher, + persistMessage = { _, _ -> }, + respondToServerRequest = { id, result -> + responseId = id + response = result + }, + ) + + delegate.respondToClarifyBatch(mapOf("q0" to "", "q1" to "new answer")) + dispatcher.scheduler.advanceUntilIdle() + + assertEquals("srq-clarify", responseId) + val answers = response?.jsonObject?.get("answers")?.jsonObject + assertNotNull(answers) + assertEquals("yes", answers?.get("q0")?.jsonPrimitive?.content) + assertEquals("new answer", answers?.get("q1")?.jsonPrimitive?.content) + } + + @Test + fun credentialPrompt_serverRequestId_controls_response_and_cancellation() { + val scope = TestScope(dispatcher) + val uiState = MutableStateFlow(ChatUiState(currentSessionId = "session-1")) + val sentMethods = mutableListOf() + var responseId: String? = null + var response: JsonElement? = null + val delegate = + ChatCredentialPromptsDelegate( + scope = scope, + ioDispatcher = dispatcher, + uiState = uiState, + wsSend = { method, _, _ -> sentMethods += method }, + trackRequest = { _, _ -> }, + respondToServerRequest = { id, result -> + responseId = id + response = result + }, + ) + + delegate.handleSudoRequest( + WsEvent.SudoRequest( + requestId = "legacy-sudo-id", + sessionId = "session-1", + serverRequestId = "srq-sudo", + ), + ) + delegate.handleSudoExpire(WsEvent.SudoExpire(null, "session-1", serverRequestId = "srq-other")) + assertNotNull(uiState.value.sudoPrompt) + + delegate.respondToSudo("password") + dispatcher.scheduler.advanceUntilIdle() + assertEquals("srq-sudo", responseId) + assertEquals( + "password", + response + ?.jsonObject + ?.get("value") + ?.jsonPrimitive + ?.content, + ) + assertNull(uiState.value.sudoPrompt) + assertEquals(emptyList(), sentMethods) + + delegate.handleSudoRequest(WsEvent.SudoRequest("legacy-sudo-2", "session-1")) + delegate.respondToSudo("legacy-password") + dispatcher.scheduler.advanceUntilIdle() + assertEquals(listOf(WsMethods.SUDO_RESPOND), sentMethods) + } + + @Test + fun secretAndVaultServerRequests_useValueResultPayloads() { + val scope = TestScope(dispatcher) + val uiState = MutableStateFlow(ChatUiState(currentSessionId = "session-1")) + val responses = mutableListOf>() + val delegate = + ChatCredentialPromptsDelegate( + scope = scope, + ioDispatcher = dispatcher, + uiState = uiState, + wsSend = { _, _, _ -> error("new prompt must not use legacy response") }, + trackRequest = { _, _ -> }, + respondToServerRequest = { id, result -> responses += id to result }, + ) + + delegate.handleSecretRequest(WsEvent.SecretRequest(null, "session-1", serverRequestId = "srq-secret")) + delegate.respondToSecret("secret-value") + delegate.handleVaultCodeRequest(WsEvent.VaultCodeRequest(null, "session-1", serverRequestId = "srq-code")) + delegate.respondToVaultCode("123456") + delegate.handleVaultUnlockRequest( + WsEvent.VaultUnlockRequest(null, "session-1", serverRequestId = "srq-unlock"), + ) + delegate.respondToVaultUnlock("vault-password") + delegate.handleVaultSaveLoginRequest( + WsEvent.VaultSaveLoginRequest(null, "session-1", serverRequestId = "srq-save"), + ) + delegate.respondToVaultSaveLogin("alice", "login-password") + dispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf("srq-secret", "srq-code", "srq-unlock", "srq-save"), responses.map { it.first }) + assertEquals( + "secret-value", + responses[0] + .second.jsonObject["value"] + ?.jsonPrimitive + ?.content, + ) + assertEquals( + "123456", + responses[1] + .second.jsonObject["value"] + ?.jsonPrimitive + ?.content, + ) + assertEquals( + "vault-password", + responses[2] + .second.jsonObject["value"] + ?.jsonPrimitive + ?.content, + ) + assertEquals( + "{\"identifier\":\"alice\",\"password\":\"login-password\"}", + responses[3] + .second.jsonObject["value"] + ?.jsonPrimitive + ?.content, + ) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 14c82d19..7b2d7811 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -49,6 +49,8 @@ import kotlinx.coroutines.test.setMain import kotlinx.coroutines.withTimeout import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.put import org.junit.After import org.junit.Assert.assertArrayEquals @@ -188,6 +190,8 @@ class ChatViewModelTest { arg<((String) -> Unit)?>(2)?.invoke(id) id } + every { HermesWsClient.respondToServerRequest(any(), any()) } returns true + every { HermesWsClient.respondToServerRequestError(any(), any(), any()) } returns true // Stub model-options so preloadModelOptions() (fired at GatewayReady) is safe. val mockApi = mockk(relaxed = true) @@ -1494,6 +1498,100 @@ class ChatViewModelTest { } } + @Test + fun testServerRequestClarifyReplay_restoresLockedAnswersAndMergesRemainingAnswer() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + + mockEventsFlow.emit( + WsEvent.ServerRequest( + id = "srq-clarify-replay", + method = "clarify", + params = + mapOf( + "session_id" to sessionId, + "questions" to + listOf( + mapOf("qid" to "q0", "question" to "First?", "choices" to listOf("yes")), + mapOf("qid" to "q1", "question" to "Second?", "choices" to emptyList()), + ), + "answers" to mapOf("q0" to "yes", "invalid" to 42), + ), + replayed = true, + ), + ) + advanceUntilIdle() + + val clarify = viewModel.uiState.value.clarifyRequest + assertEquals("srq-clarify-replay", clarify?.serverRequestId) + assertEquals(mapOf("q0" to "yes"), clarify?.lockedAnswers) + assertEquals(2, clarify?.resolvedQuestions?.size) + + viewModel.respondToClarifyBatch(mapOf("q0" to "", "q1" to "new answer")) + advanceUntilIdle() + + verify { + HermesWsClient.respondToServerRequest( + "srq-clarify-replay", + withArg { result -> + val answers = result.jsonObject["answers"]?.jsonObject + assertEquals("yes", answers?.get("q0")?.jsonPrimitive?.content) + assertEquals("new answer", answers?.get("q1")?.jsonPrimitive?.content) + assertFalse(answers?.containsKey("invalid") == true) + }, + ) + } + } + + @Test + fun serverRequestCancel_clearsOnlyMatchingClarifyPrompt() = + runTest { + val (viewModel, sessionId) = createViewModelWithSession() + mockEventsFlow.emit( + WsEvent.ServerRequest( + id = "srq-clarify-cancel", + method = "clarify", + params = + mapOf( + "session_id" to sessionId, + "question" to "Continue?", + ), + ), + ) + advanceUntilIdle() + assertEquals( + "srq-clarify-cancel", + viewModel.uiState.value.clarifyRequest + ?.serverRequestId, + ) + + mockEventsFlow.emit( + WsEvent.ServerRequestCancelled( + id = "srq-other", + method = "clarify", + reason = "timeout", + sessionId = sessionId, + ), + ) + advanceUntilIdle() + assertEquals( + "srq-clarify-cancel", + viewModel.uiState.value.clarifyRequest + ?.serverRequestId, + ) + + mockEventsFlow.emit( + WsEvent.ServerRequestCancelled( + id = "srq-clarify-cancel", + method = "clarify", + reason = "timeout", + sessionId = sessionId, + ), + ) + advanceUntilIdle() + assertNull(viewModel.uiState.value.clarifyRequest) + } + @Test fun testClarifyRequestCustomResponse() = runTest { From fe465ad93cf6081088765e536bf8f0a522c3066a Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:10:13 +0300 Subject: [PATCH 17/29] fix(ui): collapse provider model pickers (#1126) --- .../hermescontrol/ui/model/ModelScreenTest.kt | 30 ++++ .../model/components/ModelPickerDialogTest.kt | 151 ++++++++++++++++++ .../m57/hermescontrol/ui/model/ModelScreen.kt | 38 ++++- .../ui/model/components/ModelPickerDialog.kt | 98 +++++++++--- app/src/main/res/values-ar/strings.xml | 10 ++ app/src/main/res/values-ko/strings.xml | 6 + app/src/main/res/values-zh/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + 8 files changed, 316 insertions(+), 29 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialogTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/model/ModelScreenTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/model/ModelScreenTest.kt index c2b9a66e..1ddf87c3 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/model/ModelScreenTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/model/ModelScreenTest.kt @@ -2,7 +2,9 @@ package com.m57.hermescontrol.ui.model import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.m57.hermescontrol.data.model.ModelProvider @@ -62,6 +64,34 @@ class ModelScreenTest { composeTestRule.onNodeWithText("Pinned Models").assertDoesNotExist() } + @Test + fun modelScreen_providerExpansionArrowMatchesState() { + uiStateFlow.value = + ModelUiState( + providers = + listOf( + ModelProvider( + slug = "openai", + name = "OpenAI", + models = listOf("gpt-4"), + ), + ), + ) + + composeTestRule.setContent { + ModelScreen( + onOpenDrawer = {}, + viewModel = mockViewModel, + ) + } + + composeTestRule.onNodeWithText("gpt-4").assertDoesNotExist() + composeTestRule.onNodeWithContentDescription("Expand OpenAI models").performClick() + composeTestRule.onNodeWithText("gpt-4").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Collapse OpenAI models").performClick() + composeTestRule.onNodeWithText("gpt-4").assertDoesNotExist() + } + @Test fun modelScreen_rendersPinnedModels_whenPresent() { uiStateFlow.value = diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialogTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialogTest.kt new file mode 100644 index 00000000..cfffcad7 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialogTest.kt @@ -0,0 +1,151 @@ +package com.m57.hermescontrol.ui.model.components + +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.ModelCapabilities +import com.m57.hermescontrol.data.model.ModelProvider +import com.m57.hermescontrol.data.model.PinnedModel +import com.m57.hermescontrol.theme.HermesControlTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class ModelPickerDialogTest { + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun providers_startCollapsed_andOnlyOneProviderExpands() { + composeTestRule.setContent { + HermesControlTheme { + ModelPickerDialog( + providers = + listOf( + ModelProvider(slug = "openai", name = "OpenAI", models = listOf("gpt-4")), + ModelProvider(slug = "anthropic", name = "Anthropic", models = listOf("claude-3")), + ), + title = "Choose model", + onSelect = { _, _ -> }, + onDismiss = {}, + ) + } + } + + composeTestRule.onNodeWithText("gpt-4").assertDoesNotExist() + composeTestRule.onNodeWithText("claude-3").assertDoesNotExist() + composeTestRule.onAllNodesWithText("1 model").assertCountEquals(2) + composeTestRule.onNodeWithContentDescription("Expand OpenAI models").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Expand Anthropic models").assertIsDisplayed() + + composeTestRule.onNodeWithContentDescription("Expand OpenAI models").performClick() + composeTestRule.onNodeWithText("gpt-4").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Collapse OpenAI models").assertIsDisplayed() + composeTestRule.onNodeWithText("claude-3").assertDoesNotExist() + + composeTestRule.onNodeWithContentDescription("Expand Anthropic models").performClick() + composeTestRule.onNodeWithText("gpt-4").assertDoesNotExist() + composeTestRule.onNodeWithText("claude-3").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Collapse Anthropic models").assertIsDisplayed() + + composeTestRule.onNodeWithContentDescription("Collapse Anthropic models").performClick() + composeTestRule.onNodeWithText("claude-3").assertDoesNotExist() + composeTestRule.onNodeWithContentDescription("Expand Anthropic models").assertIsDisplayed() + } + + @Test + fun search_filtersModelsAndCountsMatches_beforeSelection() { + var selectedProvider = "" + var selectedModel = "" + + composeTestRule.setContent { + HermesControlTheme { + ModelPickerDialog( + providers = + listOf( + ModelProvider( + slug = "openai", + name = "OpenAI", + models = listOf("gpt-4", "embedding-3"), + ), + ModelProvider(slug = "anthropic", name = "Anthropic", models = listOf("claude-3")), + ), + title = "Choose model", + onSelect = { provider, model -> + selectedProvider = provider + selectedModel = model + }, + onDismiss = {}, + ) + } + } + + composeTestRule.onNodeWithText("2 models").assertIsDisplayed() + composeTestRule.onNode(hasSetTextAction()).performTextInput("gpt-4") + composeTestRule.onNodeWithText("1 model").assertIsDisplayed() + composeTestRule.onNodeWithText("embedding-3").assertDoesNotExist() + composeTestRule.onAllNodesWithText("gpt-4").assertCountEquals(1) + + composeTestRule.onNodeWithContentDescription("Expand OpenAI models").performClick() + composeTestRule.onAllNodesWithText("gpt-4").get(1).performClick() + + assertEquals("openai", selectedProvider) + assertEquals("gpt-4", selectedModel) + } + + @Test + fun pinnedModels_remainVisibleAndPreserveCapabilitiesAndCallbacks() { + var toggledProvider = "" + var toggledModel = "" + var selectedProvider = "" + var selectedModel = "" + + composeTestRule.setContent { + HermesControlTheme { + ModelPickerDialog( + providers = + listOf( + ModelProvider( + slug = "openai", + name = "OpenAI", + models = listOf("gpt-4"), + capabilities = mapOf("gpt-4" to ModelCapabilities(reasoning = false)), + ), + ), + title = "Choose model", + pinnedModels = listOf(PinnedModel(providerSlug = "openai", modelName = "gpt-4")), + onPinToggle = { provider, model -> + toggledProvider = provider + toggledModel = model + }, + onSelect = { provider, model -> + selectedProvider = provider + selectedModel = model + }, + onDismiss = {}, + ) + } + } + + composeTestRule.onNodeWithText("gpt-4").assertIsDisplayed() + composeTestRule.onNodeWithText("no reasoning").assertIsDisplayed() + composeTestRule.onNodeWithContentDescription("Unpin model").performClick() + composeTestRule.onNodeWithText("gpt-4").performClick() + + assertEquals("openai", toggledProvider) + assertEquals("gpt-4", toggledModel) + assertEquals("openai", selectedProvider) + assertEquals("gpt-4", selectedModel) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt index a8baf000..b9790328 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt @@ -720,7 +720,7 @@ private fun ProviderCard( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Column { + Column(modifier = Modifier.weight(1f)) { Text( text = provider.name, style = MaterialTheme.typography.titleMedium, @@ -733,12 +733,36 @@ private fun ProviderCard( ) } - if (isCurrent) { - Text( - text = stringResource(R.string.model_status_current), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + if (isCurrent) { + Text( + text = stringResource(R.string.model_status_current), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + Icon( + imageVector = + if (isExpanded) { + Icons.Filled.KeyboardArrowUp + } else { + Icons.Filled.KeyboardArrowDown + }, + contentDescription = + stringResource( + if (isExpanded) { + R.string.content_desc_collapse_provider + } else { + R.string.content_desc_expand_provider + }, + provider.name, + ), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt index 942f056a..0b576cb1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt @@ -16,6 +16,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.outlined.PushPin import androidx.compose.material3.AlertDialog @@ -25,6 +27,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -32,6 +35,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -65,6 +69,7 @@ fun ModelPickerDialog( onDismiss: () -> Unit, ) { var pickerQuery by remember { mutableStateOf("") } + var expandedProviderSlug by remember { mutableStateOf(null) } AlertDialog( onDismissRequest = onDismiss, @@ -149,6 +154,17 @@ fun ModelPickerDialog( } } + LaunchedEffect(filteredProvidersWithModels) { + if ( + expandedProviderSlug != null && + filteredProvidersWithModels.none { (provider, _) -> + provider.slug == expandedProviderSlug + } + ) { + expandedProviderSlug = null + } + } + SearchBar( query = pickerQuery, onQueryChange = { pickerQuery = it }, @@ -205,45 +221,83 @@ fun ModelPickerDialog( // ── All providers / models (lazy item per model) ── filteredProvidersWithModels.forEach { (provider, models) -> + val isExpanded = expandedProviderSlug == provider.slug item(key = "header:${provider.slug}") { Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + expandedProviderSlug = + if (isExpanded) { + null + } else { + provider.slug + } + }.padding(top = 12.dp, bottom = 6.dp), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(top = 12.dp, bottom = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( text = provider.name, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), ) - Spacer(modifier = Modifier.weight(1f)) Text( - text = "${models.size} models", + text = + pluralStringResource( + R.plurals.model_provider_count, + models.size, + models.size, + ), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + Icon( + imageVector = + if (isExpanded) { + Icons.Filled.KeyboardArrowUp + } else { + Icons.Filled.KeyboardArrowDown + }, + contentDescription = + stringResource( + if (isExpanded) { + R.string.content_desc_collapse_provider + } else { + R.string.content_desc_expand_provider + }, + provider.name, + ), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(24.dp), + ) } } - items( - items = models, - key = { model -> "${provider.slug}:$model" }, - ) { model -> - val isPinned = "${provider.slug}:$model" in pinnedSet - val caps = provider.capabilities?.get(model) - ModelItemCard( - modelName = model, - isPinned = isPinned, - onPinToggle = - if (onPinToggle != null) { - { onPinToggle(provider.slug, model) } - } else { - null - }, - onClick = { onSelect(provider.slug, model) }, - canDisableReasoning = caps?.can_disable_reasoning, - supportsReasoning = caps?.reasoning, - ) + if (isExpanded) { + items( + items = models, + key = { model -> "${provider.slug}:$model" }, + ) { model -> + val isPinned = "${provider.slug}:$model" in pinnedSet + val caps = provider.capabilities?.get(model) + ModelItemCard( + modelName = model, + isPinned = isPinned, + onPinToggle = + if (onPinToggle != null) { + { onPinToggle(provider.slug, model) } + } else { + null + }, + onClick = { onSelect(provider.slug, model) }, + canDisableReasoning = caps?.can_disable_reasoning, + supportsReasoning = caps?.reasoning, + ) + } } } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index dc295d82..3b45c33a 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -503,9 +503,19 @@ النماذج المتاحة: لم يتم العثور على نماذج أو أن المزود يحتاج إلى مصادقة. النموذج النشط +توسيع نماذج %1$s +طي نماذج %1$s النماذج المثبتة إلغاء تثبيت النموذج تبديل ظهور قسم المثبتات + + لا توجد نماذج + %1$d نموذج + %1$d نموذجان + %1$d نماذج + %1$d نموذجًا + %1$d نموذج + لا يوجد سجل ستظهر جلسات المحادثة السابقة من Hermes هنا. بلا عنوان diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 40b7a9ba..2de852ca 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -393,10 +393,16 @@ 사용 가능한 모델: 보고된 모델이 없거나 인증이 필요합니다. 활성 모델 + %1$s 모델 펼치기 + %1$s 모델 접기 고정된 모델 계속 모델 고정 해제 고정 섹션 표시 전환 + + %1$d개 모델 + %1$d개 모델 + 히스토리 없음 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 9cc43ed8..fa1c1d3c 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -494,9 +494,15 @@ 可用模型: 没有可用的模型,或提供商需要认证。 当前模型 + 展开 %1$s 模型 + 折叠 %1$s 模型 固定模型 取消固定模型 切换固定区显示 + + %1$d 个模型 + %1$d 个模型 + 暂无历史 之前的 Hermes 对话会话将显示在这里。 未命名 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 338bf909..a1b0d0ca 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -586,9 +586,15 @@ Available Models: No models reported or provider needs authentication. Active Model + Expand %1$s models + Collapse %1$s models Pinned Models Unpin model Toggle pinned section visibility + + %1$d model + %1$d models + No history From 528ec4f28f08cd198e1552681496a7a3eec41c84 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:56:32 +0300 Subject: [PATCH 18/29] feat(#1121): add multiplexed gateway awareness (#1127) --- .../data/model/GatewayMigration.kt | 43 +++++ .../data/model/MessagingPlatform.kt | 8 + .../data/model/StatusResponse.kt | 3 + .../data/remote/HermesApiService.kt | 11 +- .../ui/channels/ChannelsViewModel.kt | 31 ++- .../ui/channels/components/PlatformCard.kt | 43 +++++ .../ui/profiles/ProfilesScreen.kt | 68 +++++++ .../ui/profiles/ProfilesViewModel.kt | 22 +++ .../hermescontrol/ui/system/SystemScreen.kt | 4 + .../ui/system/SystemViewModel.kt | 94 +++++++++ .../ui/system/components/GatewaySection.kt | 136 +++++++++++++ app/src/main/res/values-ar/strings.xml | 26 +++ app/src/main/res/values-ko/strings.xml | 22 +++ app/src/main/res/values-zh/strings.xml | 22 +++ app/src/main/res/values/strings.xml | 22 +++ .../m57/hermescontrol/E2eIntegrationTest.kt | 4 +- .../GatewayMultiplexSerializationTest.kt | 174 +++++++++++++++++ .../ui/channels/ChannelsViewModelTest.kt | 109 +++++++++++ .../ui/profiles/ProfilesViewModelTest.kt | 49 +++++ .../system/GatewayMigrationViewModelTest.kt | 182 ++++++++++++++++++ 20 files changed, 1065 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/data/model/GatewayMigration.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/model/GatewayMultiplexSerializationTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/channels/ChannelsViewModelTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/system/GatewayMigrationViewModelTest.kt diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/GatewayMigration.kt b/app/src/main/java/com/m57/hermescontrol/data/model/GatewayMigration.kt new file mode 100644 index 00000000..65f327b5 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/GatewayMigration.kt @@ -0,0 +1,43 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class GatewayMigrationPlan( + @SerialName("default_home") val defaultHome: String? = null, + val profiles: List = emptyList(), + @SerialName("multiplex_flag_on") val multiplexFlagOn: Boolean = false, + @SerialName("live_served") val liveServed: List? = null, + @SerialName("already_multiplexed") val alreadyMultiplexed: Boolean = false, + val interrupted: Boolean = false, + val blockers: List = emptyList(), + val notices: List = emptyList(), + val eligible: Boolean = false, + val command: String? = null, +) + +@Serializable +data class GatewayMigrationProfile( + val profile: String? = null, + val home: String? = null, + val pid: Int? = null, + val service: GatewayMigrationService? = null, + val services: List = emptyList(), + @SerialName("run_as_user") val runAsUser: String? = null, + val uid: Int? = null, + @SerialName("runtime_home") val runtimeHome: String? = null, +) + +@Serializable +data class GatewayMigrationService( + val kind: String? = null, + val system: Boolean? = null, +) + +@Serializable +data class GatewayMigrationStartResponse( + val ok: Boolean = false, + val pid: Int? = null, + val name: String? = null, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt b/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt index 465b5451..74e47ac5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/MessagingPlatform.kt @@ -17,6 +17,7 @@ data class MessagingPlatform( @SerialName("env_vars") val envVars: List? = null, @SerialName("updated_at") val updatedAt: String? = null, @SerialName("home_channel") val homeChannel: HomeChannelInfo? = null, + @SerialName("ingress_url") val ingressUrl: String? = null, ) @Serializable @@ -64,6 +65,13 @@ data class MessagingPlatformTestResult( val message: String, ) +@Serializable +data class MessagingPlatformUpdateResponse( + val ok: Boolean = false, + val platform: String? = null, + @SerialName("hot_served") val hotServed: Boolean = false, +) + // ── Telegram onboarding ───────────────────────────────────────────────── @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/StatusResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/StatusResponse.kt index 39a108fe..08b8d0c5 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/StatusResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/StatusResponse.kt @@ -1,4 +1,5 @@ package com.m57.hermescontrol.data.model +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable @@ -8,6 +9,8 @@ data class StatusResponse( val active_sessions: Int? = null, val auth_required: Boolean? = null, val gateway_platforms: Map? = null, + @SerialName("gateway_shared_with") + val gatewaySharedWith: List? = null, val memory: MemoryPressureStatus? = null, val disk: DiskPressureStatus? = null, ) 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 f6a29028..d8592605 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 @@ -33,6 +33,8 @@ import com.m57.hermescontrol.data.model.EnvVarDeleteRequest import com.m57.hermescontrol.data.model.EnvVarRevealRequest import com.m57.hermescontrol.data.model.EnvVarRevealResponse import com.m57.hermescontrol.data.model.EnvVarUpdate +import com.m57.hermescontrol.data.model.GatewayMigrationPlan +import com.m57.hermescontrol.data.model.GatewayMigrationStartResponse import com.m57.hermescontrol.data.model.HealthStatus import com.m57.hermescontrol.data.model.HookResponse import com.m57.hermescontrol.data.model.InstantiateBlueprintRequest @@ -65,6 +67,7 @@ import com.m57.hermescontrol.data.model.MemoryResponse import com.m57.hermescontrol.data.model.MessagingPlatformResponse import com.m57.hermescontrol.data.model.MessagingPlatformTestResult import com.m57.hermescontrol.data.model.MessagingPlatformUpdate +import com.m57.hermescontrol.data.model.MessagingPlatformUpdateResponse import com.m57.hermescontrol.data.model.MoaConfigResponse import com.m57.hermescontrol.data.model.ModelAssignmentRequest import com.m57.hermescontrol.data.model.ModelAssignmentResponse @@ -390,6 +393,12 @@ interface HermesApiService : KanbanApiService { @POST("api/gateway/restart") suspend fun restartGateway(): Response + @GET("api/gateway/migrate/plan") + suspend fun getGatewayMigrationPlan(): Response + + @POST("api/gateway/migrate") + suspend fun startGatewayMigration(): Response + @GET("api/profiles") suspend fun getProfiles(): Response @@ -677,7 +686,7 @@ interface HermesApiService : KanbanApiService { suspend fun configurePlatform( @Path("platform_id") platformId: String, @Body config: MessagingPlatformUpdate, - ): Response + ): Response @POST("api/messaging/platforms/{platform_id}/test") suspend fun testMessagingPlatform( diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt index e79c7b2d..3702fbfa 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsViewModel.kt @@ -125,7 +125,8 @@ class ChannelsViewModel : } }, onStart = {}, - onSuccess = { + onSuccess = { response -> + val hotServed = response.hotServed _uiState.update { state -> state.copy( platforms = @@ -133,16 +134,28 @@ class ChannelsViewModel : if (p.id == platformId) { p.copy( enabled = enabled, - state = if (enabled) "pending_restart" else "disabled", + state = + when { + !enabled -> "disabled" + hotServed -> "connected" + else -> "pending_restart" + }, ) } else { p } }, - restartNeeded = true, + restartNeeded = !hotServed, togglingId = null, + toastMessage = + if (hotServed) { + "$platformId updated live" + } else { + null + }, ) } + loadPlatforms() }, onError = { error -> _uiState.update { @@ -201,12 +214,18 @@ class ChannelsViewModel : it.copy(isLoading = true, errorMessage = null) } }, - onSuccess = { - val message = "$platformId configured successfully — restart the gateway for changes to take effect" + onSuccess = { response -> + val hotServed = response.hotServed + val message = + if (hotServed) { + "$platformId configured and reloaded live" + } else { + "$platformId configured successfully — restart the gateway for changes to take effect" + } _uiState.update { it.copy( isLoading = false, - restartNeeded = true, + restartNeeded = !hotServed, toastMessage = message, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/components/PlatformCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/components/PlatformCard.kt index 9f2941ec..b03a65ad 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/components/PlatformCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/components/PlatformCard.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.channels.components +import android.content.ClipData import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,6 +13,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.QrCode import androidx.compose.material.icons.filled.RadioButtonChecked @@ -34,18 +36,23 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.MessagingPlatform import com.m57.hermescontrol.data.model.MessagingPlatformUpdate import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.channels.ConfigureForm +import kotlinx.coroutines.launch private enum class StatusRole { SUCCESS, WARNING, ERROR, NEUTRAL } @@ -110,6 +117,8 @@ internal fun PlatformCard( ) { var showConfigureForm by remember { mutableStateOf(false) } var showRemoveConfirm by remember { mutableStateOf(false) } + val clipboard = LocalClipboard.current + val scope = rememberCoroutineScope() val style = platformStateStyle(platform.state) Card(modifier = Modifier.fillMaxWidth()) { @@ -166,6 +175,40 @@ internal fun PlatformCard( ) } + platform.ingressUrl?.takeIf { it.isNotBlank() }?.let { ingressUrl -> + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.channels_ingress_url), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = ingressUrl, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f), + ) + IconButton( + onClick = { + scope.launch { + clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(null, ingressUrl))) + } + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Filled.ContentCopy, + contentDescription = stringResource(R.string.channels_copy_ingress_url), + modifier = Modifier.size(18.dp), + ) + } + } + } + // ── Error message ── if (!platform.errorMessage.isNullOrBlank()) { Spacer(modifier = Modifier.height(4.dp)) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt index 2b362b23..468888b7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt @@ -54,6 +54,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontFamily @@ -232,8 +233,15 @@ fun ProfilesScreen( contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { + if (state.sharedGatewayProfiles.isNotEmpty()) { + item(key = "shared_gateway_summary") { + SharedGatewaySummary(state.sharedGatewayProfiles) + } + } + items(state.displayProfiles, key = { it.name }) { profile -> val isActive = profile.name == state.activeProfileName + val isProfileShared = profile.name in state.sharedGatewayProfiles Card( modifier = Modifier.fillMaxWidth(), colors = @@ -293,6 +301,29 @@ fun ProfilesScreen( ) } } + if (isProfileShared) { + val sharedGatewayContainer = + MaterialTheme.colorScheme.secondaryContainer + val onSharedGatewayContainer = + MaterialTheme.colorScheme.onSecondaryContainer + val sharedGatewayLabel = + stringResource(R.string.profiles_shared_gateway_badge) + Spacer(modifier = Modifier.width(8.dp)) + Box( + modifier = + Modifier + .clip(RoundedCornerShape(4.dp)) + .background(sharedGatewayContainer) + .padding(horizontal = 6.dp, vertical = 2.dp), + ) { + Text( + text = sharedGatewayLabel, + color = onSharedGatewayContainer, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + ) + } + } } val descriptionText = if (!profile.description.isNullOrBlank()) { @@ -971,6 +1002,43 @@ fun ProfilesScreen( } } +@Composable +private fun SharedGatewaySummary(profiles: List) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.profiles_shared_gateway_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = + pluralStringResource( + R.plurals.profiles_shared_gateway_count, + profiles.size, + profiles.size, + ), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = profiles.joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } +} + // --------------------------------------------------------------------- // Profile Builder Wizard // --------------------------------------------------------------------- diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt index 26e3cbd3..b48fc8ee 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModel.kt @@ -34,6 +34,7 @@ data class ProfilesUiState( val isLoading: Boolean = false, val profiles: List = emptyList(), val activeProfileName: String? = null, + val sharedGatewayProfiles: List = emptyList(), val selectedSoulContent: String? = null, val isLoadingSoul: Boolean = false, val errorMessage: String? = null, @@ -67,6 +68,15 @@ data class ProfilesUiState( } } +private fun normalizeSharedGatewayProfiles(names: List?): List = + names + .orEmpty() + .asSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .distinct() + .toList() + class ProfilesViewModel( private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : ViewModel(), @@ -110,6 +120,7 @@ class ProfilesViewModel( val profilesDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getProfiles() } } val activeDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getActiveProfile() } } + val statusDeferred = async(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getStatus() } } val profilesResult = profilesDeferred.await() val activeResult = activeDeferred.await() @@ -120,9 +131,20 @@ class ProfilesViewModel( isLoading = false, profiles = profilesResult.data.profiles.orEmpty(), activeProfileName = activeResult.data.active, + sharedGatewayProfiles = emptyList(), hiddenProfiles = AuthManager.getHiddenProfiles().toSet(), ) } + + val statusResult = statusDeferred.await() + if (statusResult is NetworkResult.Success) { + _uiState.update { + it.copy( + sharedGatewayProfiles = + normalizeSharedGatewayProfiles(statusResult.data.gatewaySharedWith), + ) + } + } } else { val profilesError = (profilesResult as? NetworkResult.Failure)?.error?.message ?: "Success" val activeError = (activeResult as? NetworkResult.Failure)?.error?.message ?: "Success" diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt index 9b287edb..c4ec9e55 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt @@ -133,6 +133,10 @@ fun SystemScreen( controller = resolvedViewModel.actionProgress, title = stringResource(R.string.system_update_progress_title), ) + ActionProgressDialog( + controller = resolvedViewModel.migrationProgress, + title = stringResource(R.string.system_migration_progress_title), + ) // Credential remove confirmation using ConfirmDialog var credToRemove by remember { mutableStateOf?>(null) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt index 53c373c6..205f4f84 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt @@ -15,6 +15,7 @@ import com.m57.hermescontrol.data.model.CredentialPoolProvider import com.m57.hermescontrol.data.model.CuratorResponse import com.m57.hermescontrol.data.model.DebugShareResponse import com.m57.hermescontrol.data.model.DoctorResponse +import com.m57.hermescontrol.data.model.GatewayMigrationPlan import com.m57.hermescontrol.data.model.HookResponse import com.m57.hermescontrol.data.model.PortalResponse import com.m57.hermescontrol.data.model.StatusResponse @@ -26,6 +27,7 @@ import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.ui.common.ActionProgressController import com.m57.hermescontrol.ui.common.ToastHost +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async @@ -53,6 +55,8 @@ data class SystemUiState( val updateInfo: UpdateCheckResponse? = null, val status: StatusResponse? = null, val doctorReport: DoctorResponse? = null, + val migrationPlan: GatewayMigrationPlan? = null, + val migrationSupported: Boolean? = null, val activeAction: String? = null, val actionLog: ActionStatusResponse? = null, val backupArchive: String? = null, @@ -88,6 +92,7 @@ data class SystemUiState( class SystemViewModel( application: Application, + private val migrationDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : AndroidViewModel(application), ToastHost { companion object { @@ -114,12 +119,22 @@ class SystemViewModel( onFinished = { status -> onActionFinished(status) }, ) + /** Progress popup for the optional gateway migration action. */ + val migrationProgress = + ActionProgressController( + scope = viewModelScope, + onFinished = { status -> onMigrationFinished(status) }, + ) + private var actionPollingJob: Job? = null private var downloadJob: Job? = null // ── Full parallel data load ──────────────────────────────────────── fun loadAll() { + // Migration support is optional; probe it independently so an older or + // slow backend cannot hold up the required System data. + loadMigrationPlan() viewModelScope.launch { _uiState.update { it.copy(isLoading = true, errorMessage = null) } @@ -138,6 +153,7 @@ class SystemViewModel( val statsResult = statsDeferred.await() val statusResult = statusDeferred.await() + val portalResult = portalDeferred.await() val curatorResult = curatorDeferred.await() @@ -224,6 +240,66 @@ class SystemViewModel( } } + /** Load optional migration capability state without affecting other System data. */ + fun loadMigrationPlan() { + viewModelScope.launch { + val result = + withContext(migrationDispatcher) { + safeApiCall { ApiClient.hermesApi.getGatewayMigrationPlan() } + } + _uiState.update { + it.copy( + migrationPlan = (result as? NetworkResult.Success)?.data, + migrationSupported = migrationSupport(result), + ) + } + } + } + + /** + * Start the backend migration only after the Gateway section's confirmation. + * The POST starts an asynchronous `gateway-migrate` action; completion is + * owned by [migrationProgress], not by the trigger response. + */ + fun startMigration() { + val plan = _uiState.value.migrationPlan ?: return + if (plan.alreadyMultiplexed) { + _uiState.update { it.copy(toastMessage = "Gateway is already multiplexed") } + return + } + if (plan.blockers.isNotEmpty()) { + _uiState.update { it.copy(toastMessage = "Migration is blocked by the preflight plan") } + return + } + + migrationProgress.open() + viewModelScope.launch { + val result = + withContext(migrationDispatcher) { + safeApiCall { ApiClient.hermesApi.startGatewayMigration() } + } + when (result) { + is NetworkResult.Success -> { + val response = result.data + val actionName = response.name + if (!response.ok) { + migrationProgress.fail("Migration was rejected by the backend") + } else if (actionName != null) { + migrationProgress.markStarted(actionName) + } else { + migrationProgress.fail( + "Migration was accepted but the backend did not report an action name", + ) + } + } + + is NetworkResult.Failure -> { + migrationProgress.fail("Failed to start migration: ${result.error.message}") + } + } + } + } + /** * Fired when [actionProgress] finishes tracking a backend action. For the * `hermes-update` action we additionally fetch the durable update receipt @@ -242,6 +318,12 @@ class SystemViewModel( loadAll() } + private fun onMigrationFinished(status: ActionStatusResponse?) { + if (status?.exit_code == 0) { + loadAll() + } + } + /** * Pull `GET /api/hermes/update/receipt` and render a compact summary block * for the popup. Returns an empty list when the endpoint is unavailable @@ -862,3 +944,15 @@ class SystemViewModel( _uiState.update { it.copy(toastMessage = null) } } } + +private fun migrationSupport(result: NetworkResult): Boolean? = + when (result) { + is NetworkResult.Success -> { + true + } + + is NetworkResult.Failure -> { + val code = (result.error as? NetworkError.Http)?.code + if (code == 404 || code == 405) false else null + } + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/components/GatewaySection.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/components/GatewaySection.kt index 35262e2d..43e16fe0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/components/GatewaySection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/components/GatewaySection.kt @@ -22,12 +22,19 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.GatewayMigrationPlan import com.m57.hermescontrol.theme.Spacing +import com.m57.hermescontrol.ui.common.ConfirmDialog import com.m57.hermescontrol.ui.common.InfoRow import com.m57.hermescontrol.ui.common.SectionHeader import com.m57.hermescontrol.ui.common.StatusBadge @@ -183,5 +190,134 @@ fun LazyListScope.gatewaySection( } } } + + state.migrationPlan?.let { plan -> + item { + GatewayMigrationCard( + plan = plan, + onStartMigration = viewModel::startMigration, + ) + } + } + } +} + +@Composable +private fun GatewayMigrationCard( + plan: GatewayMigrationPlan, + onStartMigration: () -> Unit, +) { + var showConfirmation by remember(plan) { mutableStateOf(false) } + val profileNames = + plan.profiles + .mapNotNull { it.profile?.takeIf(String::isNotBlank) } + .ifEmpty { plan.liveServed.orEmpty() } + + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = stringResource(R.string.system_migration_title), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource(R.string.system_migration_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + if (profileNames.isNotEmpty()) { + InfoRow( + label = stringResource(R.string.system_migration_profiles), + value = profileNames.joinToString(" · "), + ) + } + InfoRow( + label = stringResource(R.string.system_migration_automatic_eligibility), + value = + stringResource( + if (plan.eligible) { + R.string.system_migration_yes + } else { + R.string.system_migration_no + }, + ), + ) + + if (plan.alreadyMultiplexed) { + Spacer(modifier = Modifier.height(8.dp)) + StatusBadge( + text = stringResource(R.string.system_migration_already_multiplexed), + status = StatusBadgeType.SUCCESS, + ) + } + if (plan.interrupted) { + Spacer(modifier = Modifier.height(8.dp)) + StatusBadge( + text = stringResource(R.string.system_migration_interrupted), + status = StatusBadgeType.WARNING, + ) + } + + if (plan.blockers.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.system_migration_blockers), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.error, + ) + plan.blockers.forEach { blocker -> + Text( + text = "• $blocker", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + + if (plan.notices.isNotEmpty()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.system_migration_notices), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + plan.notices.forEach { notice -> + Text( + text = "• $notice", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (!plan.alreadyMultiplexed && plan.blockers.isEmpty()) { + Spacer(modifier = Modifier.height(12.dp)) + Button( + onClick = { showConfirmation = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.system_migration_start)) + } + } + } + } + + if (showConfirmation) { + ConfirmDialog( + title = stringResource(R.string.system_migration_confirm_title), + message = stringResource(R.string.system_migration_confirm_description), + onConfirm = { + showConfirmation = false + onStartMigration() + }, + onDismiss = { showConfirmation = false }, + confirmText = stringResource(R.string.system_migration_start), + ) } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 3b45c33a..57bcce94 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -422,6 +422,20 @@ بدء إعادة التشغيل إيقاف +ترحيل البوابة +راجع خطة الترحيل من الخادم قبل نقل بوابات الملفات الشخصية إلى مُضاعِف مشترك واحد. +الملفات الشخصية +الأهلية التلقائية +نعم +لا +تعمل بالمضاعفة مسبقًا +تمت مقاطعة عملية ترحيل سابقة. راجع الخطة قبل إعادة المحاولة. +العوائق +التنبيهات +بدء الترحيل +بدء ترحيل البوابة؟ +سيوقف هذا بوابات الملفات الشخصية المستقلة ويعيد تشغيل البوابة الافتراضية كمضاعِف مشترك. ستعمل العملية في الخلفية. +ترحيل البوابة النشط: %1$s الذاكرة المدمجة MEMORY.md: %1$s · USER.md: %2$s @@ -638,6 +652,16 @@ إظهار الملفات الشخصية المخفية إخفاء الملفات الشخصية المخفية مخفي +بوابة مشتركة +مشترك + + لا تُخدم أي ملفات شخصية معًا + يُخدم %1$d ملف شخصي معًا + يُخدم ملفان شخصيان معًا + تُخدم %1$d ملفات شخصية معًا + تُخدم %1$d ملفًا شخصيًا معًا + تُخدم %1$d ملفات شخصية معًا + جميع الملفات الشخصية مخفية حاليًا. انقر أدناه لإظهار الملفات الشخصية المخفية. الملف الشخصي النشط تعديل ملف الـ Soul: %1$s @@ -934,6 +958,8 @@ اختبار إعادة التشغيل الآن تم حفظ التغييرات. أعد تشغيل البوابة (Gateway) لتصبح سارية المفعول. +رابط الدخول +نسخ رابط الدخول الموافقات المعلقة المستخدمون المعتمدون مسح الكل diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 2de852ca..2e18e362 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -364,6 +364,20 @@ 지금 업데이트 Hermes 업데이트? Hermes 업데이트를 실행하고 게이트웨이를 재시작합니다. 다음 재시작 시 적용됩니다. + 게이트웨이 마이그레이션 + 프로필 게이트웨이를 하나의 공유 멀티플렉서로 옮기기 전에 서버의 마이그레이션 계획을 확인하세요. + 프로필 + 자동 마이그레이션 가능 여부 + 예 + 아니요 + 이미 멀티플렉싱됨 + 이전 마이그레이션이 중단되었습니다. 다시 시도하기 전에 계획을 확인하세요. + 차단 요인 + 알림 + 마이그레이션 시작 + 게이트웨이 마이그레이션을 시작할까요? + 독립 프로필 게이트웨이를 중지하고 기본 게이트웨이를 공유 멀티플렉서로 재시작합니다. 작업은 백그라운드에서 실행됩니다. + 게이트웨이 마이그레이션 새 셸 훅 이벤트 명령어 (절대 경로) @@ -490,6 +504,12 @@ 숨겨진 프로필 표시 숨겨진 프로필 숨기기 숨김 + 공유 게이트웨이 + 공유 + + 프로필 %1$d개가 함께 제공됨 + 프로필 %1$d개가 함께 제공됨 + 모든 프로필이 현재 숨겨져 있습니다. 아래를 탭하여 숨겨진 프로필을 표시하세요. 활성 프로필 소울 프로필 편집: %1$s @@ -749,6 +769,8 @@ 테스트 지금 재시작 변경사항이 저장되었습니다. 게이트웨이를 재시작하면 적용됩니다. + 수신 URL + 수신 URL 복사 승인 대기 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index fa1c1d3c..4dcf292c 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -413,6 +413,20 @@ 启动 重启 停止 + 网关迁移 + 将配置文件网关迁移到共享多路复用器前,先查看服务器返回的迁移计划。 + 配置文件 + 自动迁移资格 + 是 + 否 + 已使用多路复用 + 上一次迁移被中断。重试前请查看计划。 + 阻碍 + 注意事项 + 开始迁移 + 开始网关迁移? + 这会停止独立的配置文件网关,并将默认网关重启为共享多路复用器。操作将在后台运行。 + 网关迁移 当前:%1$s 内置记忆 MEMORY.md:%1$s · USER.md:%2$s @@ -625,6 +639,12 @@ 显示隐藏的配置文件 隐藏已隐藏的配置文件 已隐藏 + 共享网关 + 共享 + + %1$d 个配置文件共同服务 + %1$d 个配置文件共同服务 + 所有配置文件当前均已隐藏。点击下方以显示已隐藏的配置文件。 当前配置 编辑 Soul 配置:%1$s @@ -921,6 +941,8 @@ 测试 立即重启 更改已保存。重启网关后生效。 + 入口 URL + 复制入口 URL 待批准 已批准用户 全部清除 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a1b0d0ca..ecea621e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -487,6 +487,20 @@ Start Restart Stop + Gateway migration + Review the backend migration plan before moving profile gateways onto one shared multiplexer. + Profiles + Automatic eligibility + Yes + No + Already multiplexed + An earlier migration was interrupted. Review the plan before retrying. + Blockers + Notices + Start migration + Start gateway migration? + This stops standalone profile gateways and restarts the default gateway as a shared multiplexer. The operation runs in the background. + Gateway migration Active: %1$s @@ -739,6 +753,12 @@ Show hidden profiles Hide hidden profiles Hidden + Shared gateway + Shared + + %1$d profile served together + %1$d profiles served together + All profiles are currently hidden. Tap below to show hidden profiles. Active Profile Edit Soul Profile: %1$s @@ -1077,6 +1097,8 @@ Test Restart now Changes saved. Restart the gateway for them to take effect. + Ingress URL + Copy ingress URL Pending Approvals diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 8590fa48..20d62d60 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -27,6 +27,7 @@ import com.m57.hermescontrol.data.model.MemoryResponse import com.m57.hermescontrol.data.model.MemoryStats import com.m57.hermescontrol.data.model.MessagingPlatform import com.m57.hermescontrol.data.model.MessagingPlatformResponse +import com.m57.hermescontrol.data.model.MessagingPlatformUpdateResponse import com.m57.hermescontrol.data.model.MoaConfigResponse import com.m57.hermescontrol.data.model.MoaModelSlot import com.m57.hermescontrol.data.model.ModelOptionsResponse @@ -1118,7 +1119,8 @@ class E2eIntegrationTest { platforms = listOf(platform), ), ) - coEvery { mockApiService.configurePlatform("telegram", any()) } returns Response.success(Unit) + coEvery { mockApiService.configurePlatform("telegram", any()) } returns + Response.success(MessagingPlatformUpdateResponse(ok = true)) val viewModel = ChannelsViewModel() viewModel.loadPlatforms() diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/GatewayMultiplexSerializationTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/GatewayMultiplexSerializationTest.kt new file mode 100644 index 00000000..a023f151 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/model/GatewayMultiplexSerializationTest.kt @@ -0,0 +1,174 @@ +package com.m57.hermescontrol.data.model + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class GatewayMultiplexSerializationTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun `status decodes shared gateway profile names`() { + val response = + json.decodeFromString( + """ + { + "gateway_running": true, + "gateway_shared_with": ["default", "coding"] + } + """.trimIndent(), + ) + + assertEquals(listOf("default", "coding"), response.gatewaySharedWith) + } + + @Test + fun `status accepts null and absent shared gateway field`() { + val nullResponse = json.decodeFromString("""{"gateway_shared_with": null}""") + val oldResponse = json.decodeFromString("""{"gateway_running": true}""") + + assertNull(nullResponse.gatewaySharedWith) + assertNull(oldResponse.gatewaySharedWith) + } + + @Test + fun `messaging platform decodes backend ingress url`() { + val platform = + json.decodeFromString( + """ + { + "id": "telegram", + "name": "Telegram", + "enabled": true, + "configured": true, + "ingress_url": "/some/backend/value" + } + """.trimIndent(), + ) + + assertEquals("/some/backend/value", platform.ingressUrl) + } + + @Test + fun `messaging platform update decodes hot served and old response safely`() { + val hotServed = + json.decodeFromString( + """{"ok": true, "platform": "telegram", "hot_served": true}""", + ) + val oldResponse = + json.decodeFromString("""{"ok": true, "platform": "telegram"}""") + + assertTrue(hotServed.ok) + assertEquals("telegram", hotServed.platform) + assertTrue(hotServed.hotServed) + assertFalse(oldResponse.hotServed) + } + + @Test + fun `migration plan decodes operational states and ignores low level fields in ui`() { + val plan = + json.decodeFromString( + """ + { + "default_home": "/home/hermes", + "profiles": [ + { + "profile": "default", + "home": "/home/hermes", + "pid": 1234, + "service": {"kind": "systemd", "system": false}, + "services": [ + {"kind": "systemd", "system": false}, + {"kind": "systemd", "system": true} + ], + "uid": 1000, + "runtime_home": "/home/hermes" + }, + {"profile": "coding"} + ], + "multiplex_flag_on": false, + "live_served": null, + "already_multiplexed": false, + "interrupted": true, + "blockers": ["duplicate credential"], + "notices": ["callback URL changes"], + "eligible": false, + "command": "hermes gateway migrate --multiplex" + } + """.trimIndent(), + ) + + assertEquals(listOf("default", "coding"), plan.profiles.map { it.profile }) + assertEquals(1234, plan.profiles.first().pid) + assertEquals( + 2, + plan.profiles + .first() + .services.size, + ) + assertEquals( + "systemd", + plan.profiles + .first() + .services[0] + .kind, + ) + assertFalse( + plan.profiles + .first() + .services[0] + .system ?: true, + ) + assertTrue( + plan.profiles + .first() + .services[1] + .system == true, + ) + assertTrue(plan.interrupted) + assertEquals(listOf("duplicate credential"), plan.blockers) + assertEquals(listOf("callback URL changes"), plan.notices) + assertFalse(plan.eligible) + } + + @Test + fun `migration profile service fields remain optional`() { + val emptyServices = + json.decodeFromString( + """ + { + "profiles": [ + {"profile": "default", "service": null, "services": []} + ] + } + """.trimIndent(), + ) + val missingServices = + json.decodeFromString( + """ + { + "profiles": [ + {"profile": "default"} + ] + } + """.trimIndent(), + ) + + assertNull(emptyServices.profiles.first().service) + assertTrue( + emptyServices.profiles + .first() + .services + .isEmpty(), + ) + assertTrue( + missingServices.profiles + .first() + .services + .isEmpty(), + ) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/channels/ChannelsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/channels/ChannelsViewModelTest.kt new file mode 100644 index 00000000..d15d682f --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/channels/ChannelsViewModelTest.kt @@ -0,0 +1,109 @@ +package com.m57.hermescontrol.ui.channels + +import com.m57.hermescontrol.data.model.MessagingPlatform +import com.m57.hermescontrol.data.model.MessagingPlatformResponse +import com.m57.hermescontrol.data.model.MessagingPlatformUpdate +import com.m57.hermescontrol.data.model.MessagingPlatformUpdateResponse +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.HermesApiService +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import retrofit2.Response + +@OptIn(ExperimentalCoroutinesApi::class) +class ChannelsViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private lateinit var mockApi: HermesApiService + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockkObject(ApiClient) + mockApi = mockk(relaxed = true) + every { ApiClient.hermesApi } returns mockApi + coEvery { mockApi.getMessagingPlatforms() } returns Response.success(platformsResponse()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `hot served update clears restart requirement and refreshes platforms`() { + coEvery { mockApi.configurePlatform("telegram", any()) } returns + Response.success( + MessagingPlatformUpdateResponse( + ok = true, + platform = "telegram", + hotServed = true, + ), + ) + + val viewModel = ChannelsViewModel() + viewModel.loadPlatforms() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.configurePlatform("telegram", MessagingPlatformUpdate(env = mapOf("token" to "redacted"))) + testDispatcher.scheduler.advanceUntilIdle() + + assertFalse(viewModel.uiState.value.restartNeeded) + assertTrue( + viewModel.uiState.value.toastMessage + ?.contains("reloaded live") == true, + ) + coVerify { mockApi.configurePlatform("telegram", any()) } + coVerify(atLeast = 2) { mockApi.getMessagingPlatforms() } + } + + @Test + fun `update without hot served preserves restart required behavior`() { + coEvery { mockApi.configurePlatform("telegram", any()) } returns + Response.success(MessagingPlatformUpdateResponse(ok = true, platform = "telegram")) + + val viewModel = ChannelsViewModel() + viewModel.loadPlatforms() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.configurePlatform("telegram", MessagingPlatformUpdate(enabled = true)) + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue(viewModel.uiState.value.restartNeeded) + assertTrue( + viewModel.uiState.value.toastMessage + ?.contains("restart the gateway") == true, + ) + } + + private fun platformsResponse(): MessagingPlatformResponse = + MessagingPlatformResponse( + envPath = "/tmp/.env", + gatewayStartCommand = "hermes gateway start", + platforms = + listOf( + MessagingPlatform( + id = "telegram", + name = "Telegram", + enabled = true, + configured = true, + ), + ), + ) +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt index 517a786e..193d87ef 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/profiles/ProfilesViewModelTest.kt @@ -12,6 +12,7 @@ import com.m57.hermescontrol.data.model.ProfileInfo import com.m57.hermescontrol.data.model.ProfileSetupCommandResponse import com.m57.hermescontrol.data.model.ProfilesResponse import com.m57.hermescontrol.data.model.RenameProfileRequest +import com.m57.hermescontrol.data.model.StatusResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService import com.m57.hermescontrol.data.remote.NetworkError @@ -62,6 +63,7 @@ class ProfilesViewModelTest { coEvery { mockApi.getProfiles() } returns Response.success(ProfilesResponse(listOf(ProfileInfo(name = "default", is_default = true)))) coEvery { mockApi.getActiveProfile() } returns Response.success(ActiveProfileResponse(active = "default")) + coEvery { mockApi.getStatus() } returns Response.success(StatusResponse()) } @Before @@ -360,6 +362,53 @@ class ProfilesViewModelTest { ) } + @Test + fun `loadProfiles displays normalized shared gateway profiles`() { + coEvery { mockApi.getProfiles() } returns + Response.success( + ProfilesResponse( + listOf( + ProfileInfo(name = "default", is_default = true), + ProfileInfo(name = "coding"), + ), + ), + ) + coEvery { mockApi.getStatus() } returns + Response.success( + StatusResponse( + gatewaySharedWith = listOf("default", "coding", "coding", " ", ""), + ), + ) + + val vm = createViewModel() + vm.loadProfiles() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(listOf("default", "coding"), vm.uiState.value.sharedGatewayProfiles) + assertNull(vm.uiState.value.errorMessage) + } + + @Test + fun `loadProfiles keeps profiles usable when optional status request fails`() { + coEvery { mockApi.getStatus() } returns errorResponse(404) + + val vm = createViewModel() + vm.loadProfiles() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals( + listOf("default"), + vm.uiState.value.profiles + .map { it.name }, + ) + assertEquals("default", vm.uiState.value.activeProfileName) + assertTrue( + vm.uiState.value.sharedGatewayProfiles + .isEmpty(), + ) + assertNull(vm.uiState.value.errorMessage) + } + @Test fun `cloneProfile success calls createProfile with clone_from and reloads`() { coEvery { mockApi.createProfile(any()) } returns Response.success(Unit) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/system/GatewayMigrationViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/system/GatewayMigrationViewModelTest.kt new file mode 100644 index 00000000..ad53c7c7 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/system/GatewayMigrationViewModelTest.kt @@ -0,0 +1,182 @@ +package com.m57.hermescontrol.ui.system + +import android.app.Application +import com.m57.hermescontrol.data.model.GatewayMigrationPlan +import com.m57.hermescontrol.data.model.GatewayMigrationProfile +import com.m57.hermescontrol.data.model.GatewayMigrationStartResponse +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.HermesApiService +import com.m57.hermescontrol.ui.common.ActionProgressPhase +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkAll +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import retrofit2.Response + +@OptIn(ExperimentalCoroutinesApi::class) +class GatewayMigrationViewModelTest { + private val testDispatcher = StandardTestDispatcher() + private val mockApp = mockk(relaxed = true) + private lateinit var mockApi: HermesApiService + + @Before + fun setUp() { + Dispatchers.setMain(testDispatcher) + mockkObject(ApiClient) + mockApi = mockk(relaxed = true) + every { ApiClient.hermesApi } returns mockApi + } + + @After + fun tearDown() { + Dispatchers.resetMain() + unmockkAll() + } + + @Test + fun `unsupported migration plan is capability local`() { + coEvery { mockApi.getGatewayMigrationPlan() } returns + Response.error(404, "unsupported".toResponseBody(null)) + + val viewModel = SystemViewModel(mockApp, migrationDispatcher = testDispatcher) + viewModel.loadMigrationPlan() + testDispatcher.scheduler.advanceUntilIdle() + + assertFalse(viewModel.uiState.value.migrationSupported == true) + assertEquals(false, viewModel.uiState.value.migrationSupported) + assertNull(viewModel.uiState.value.migrationPlan) + } + + @Test + fun `migration plan exposes backend states without inventing eligibility rules`() { + val plan = + GatewayMigrationPlan( + profiles = listOf(GatewayMigrationProfile(profile = "default")), + eligible = false, + blockers = emptyList(), + notices = listOf("explicit migration is available"), + ) + coEvery { mockApi.getGatewayMigrationPlan() } returns Response.success(plan) + + val viewModel = SystemViewModel(mockApp, migrationDispatcher = testDispatcher) + viewModel.loadMigrationPlan() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(plan, viewModel.uiState.value.migrationPlan) + assertEquals( + false, + viewModel.uiState.value.migrationPlan + ?.eligible, + ) + assertTrue( + viewModel.uiState.value.migrationPlan + ?.blockers + ?.isEmpty() == true, + ) + } + + @Test + fun `migration POST only starts action tracking and does not report completion`() { + val plan = GatewayMigrationPlan(eligible = false) + coEvery { mockApi.getGatewayMigrationPlan() } returns Response.success(plan) + coEvery { mockApi.startGatewayMigration() } returns + Response.success( + GatewayMigrationStartResponse( + ok = true, + pid = 1234, + name = "gateway-migrate", + ), + ) + + val viewModel = SystemViewModel(mockApp, migrationDispatcher = testDispatcher) + viewModel.loadMigrationPlan() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.startMigration() + testDispatcher.scheduler.runCurrent() + + assertTrue(viewModel.migrationProgress.state.value.visible) + assertEquals(ActionProgressPhase.RUNNING, viewModel.migrationProgress.state.value.phase) + assertEquals("gateway-migrate", viewModel.migrationProgress.state.value.actionName) + coVerify { mockApi.startGatewayMigration() } + } + + @Test + fun `migration POST with an unsuccessful body is shown as rejected`() { + coEvery { mockApi.getGatewayMigrationPlan() } returns Response.success(GatewayMigrationPlan()) + coEvery { mockApi.startGatewayMigration() } returns + Response.success( + GatewayMigrationStartResponse( + ok = false, + name = "gateway-migrate", + ), + ) + + val viewModel = SystemViewModel(mockApp, migrationDispatcher = testDispatcher) + viewModel.loadMigrationPlan() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.startMigration() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(ActionProgressPhase.FAILED, viewModel.migrationProgress.state.value.phase) + assertEquals("Migration was rejected by the backend", viewModel.migrationProgress.state.value.error) + coVerify { mockApi.startGatewayMigration() } + } + + @Test + fun `already multiplexed plan never starts backend action`() { + coEvery { mockApi.getGatewayMigrationPlan() } returns + Response.success(GatewayMigrationPlan(alreadyMultiplexed = true)) + + val viewModel = SystemViewModel(mockApp, migrationDispatcher = testDispatcher) + viewModel.loadMigrationPlan() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.startMigration() + testDispatcher.scheduler.runCurrent() + + assertFalse(viewModel.migrationProgress.state.value.visible) + assertTrue( + viewModel.uiState.value.toastMessage + ?.contains("already multiplexed") == true, + ) + coVerify(exactly = 0) { mockApi.startGatewayMigration() } + } + + @Test + fun `migration with blockers never starts backend action`() { + coEvery { mockApi.getGatewayMigrationPlan() } returns + Response.success(GatewayMigrationPlan(blockers = listOf("duplicate credential"))) + + val viewModel = SystemViewModel(mockApp, migrationDispatcher = testDispatcher) + viewModel.loadMigrationPlan() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.startMigration() + testDispatcher.scheduler.runCurrent() + + assertFalse(viewModel.migrationProgress.state.value.visible) + assertTrue( + viewModel.uiState.value.toastMessage + ?.contains("blocked") == true, + ) + coVerify(exactly = 0) { mockApi.startGatewayMigration() } + } +} From e5adfcf57101bf03bde47484ead8e194cb932281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alfredo=20Orteg=C3=B3n=20Sep=C3=BAlveda?= Date: Tue, 15 Sep 2026 08:37:46 -0500 Subject: [PATCH 19/29] feat(chat): restyle the composer as a single card (#1114) --- .../components/ComposerInteractionTest.kt | 154 ++++++ .../ui/chat/components/ChatComposer.kt | 151 ++---- .../ui/chat/components/ComposerPalette.kt | 52 ++ .../ui/chat/components/ComposerToolbar.kt | 505 +++++++++++------- app/src/main/res/values/strings.xml | 1 + .../ui/chat/ComposerPaletteTest.kt | 76 +++ .../ui/chat/ComposerToolbarTest.kt | 35 ++ 7 files changed, 671 insertions(+), 303 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerPalette.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerPaletteTest.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerToolbarTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt new file mode 100644 index 00000000..751abfbb --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt @@ -0,0 +1,154 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.text.input.TextFieldValue +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.ws.CommandCatalog +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Interaction coverage for the chat composer controls: the morphing action + * button (dictate / send), stopping dictation from either mic position, and + * both halves of the combined model/reasoning pill. + */ +@RunWith(AndroidJUnit4::class) +@MediumTest +class ComposerInteractionTest { + @get:Rule + val composeTestRule = createComposeRule() + + private var micTaps = 0 + private var sends = 0 + private var modelTaps = 0 + private var selectedLevel: String? = null + + /** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */ + private fun setComposer(reasoningLevel: String? = "medium") { + composeTestRule.setContent { + var input by remember { mutableStateOf(TextFieldValue("")) } + var listening by remember { mutableStateOf(false) } + ChatInputBar( + inputFieldValue = input, + onInputChange = { input = it }, + onSend = { + sends++ + input = TextFieldValue("") + }, + onMicTap = { + micTaps++ + listening = !listening + }, + isListening = listening, + isAgentTyping = false, + isConnected = true, + commandCatalog = CommandCatalog(), + currentSessionModel = "openai/gpt-5.5", + reasoningLevel = reasoningLevel, + onModelTap = { modelTaps++ }, + onReasoningTap = { selectedLevel = it }, + ) + } + } + + @Test + fun emptyInput_actionButtonStartsDictation() { + setComposer() + + composeTestRule.onNodeWithTag("send_button").assertDoesNotExist() + composeTestRule.onNodeWithTag("mic_button").performClick() + + composeTestRule.runOnIdle { + assertEquals("action button must start dictation", 1, micTaps) + assertEquals("action button must not send without input", 0, sends) + } + } + + @Test + fun typedInput_actionButtonSends_andMicStillDictates() { + setComposer() + + composeTestRule.onNodeWithTag("chat_input").performTextInput("Hello Hermes") + composeTestRule.onNodeWithTag("send_button").performClick() + composeTestRule.runOnIdle { + assertEquals("send button must send", 1, sends) + assertEquals("send must not trigger dictation", 0, micTaps) + } + + composeTestRule.onNodeWithTag("chat_input").performTextInput("Again") + composeTestRule.onNodeWithTag("mic_button").performClick() + composeTestRule.runOnIdle { + assertEquals("mic next to send must start dictation", 1, micTaps) + assertEquals("mic must not send", 1, sends) + } + } + + @Test + fun emptyInput_stopButtonStopsDictation() { + setComposer() + + composeTestRule.onNodeWithTag("mic_button").performClick() + composeTestRule.onNodeWithTag("mic_button").assertDoesNotExist() + composeTestRule.onNodeWithTag("mic_stop_button").performClick() + + composeTestRule.onNodeWithTag("mic_stop_button").assertDoesNotExist() + composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed() + composeTestRule.runOnIdle { assertEquals("start + stop must both reach onMicTap", 2, micTaps) } + } + + @Test + fun typedInput_stopButtonStopsDictation_withoutSending() { + setComposer() + + composeTestRule.onNodeWithTag("chat_input").performTextInput("Hello Hermes") + composeTestRule.onNodeWithTag("mic_button").performClick() + composeTestRule.onNodeWithTag("send_button").assertIsDisplayed() + composeTestRule.onNodeWithTag("mic_stop_button").performClick() + + composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed() + composeTestRule.runOnIdle { + assertEquals("start + stop must both reach onMicTap", 2, micTaps) + assertEquals("stopping dictation must not send", 0, sends) + } + } + + @Test + fun modelSideOfPill_opensModelPicker_only() { + setComposer() + + composeTestRule.onNodeWithTag("model_chip").performClick() + + composeTestRule.onNodeWithText("REASONING").assertDoesNotExist() + composeTestRule.runOnIdle { + assertEquals("model side must open the picker", 1, modelTaps) + assertEquals("model side must not change reasoning", null, selectedLevel) + } + } + + @Test + fun reasoningSideOfPill_opensMenu_andSelectsLevel() { + setComposer(reasoningLevel = "medium") + + composeTestRule.onNodeWithTag("reasoning_chip").performClick() + composeTestRule.onNodeWithText("REASONING").assertIsDisplayed() + composeTestRule.onNodeWithText("High").performClick() + + composeTestRule.onNodeWithText("REASONING").assertDoesNotExist() + composeTestRule.runOnIdle { + assertEquals("reasoning menu must report the picked level", "high", selectedLevel) + assertEquals("reasoning side must not open the model picker", 0, modelTaps) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index 0f6c139a..03e9589f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -1,16 +1,12 @@ package com.m57.hermescontrol.ui.chat.components -import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.scaleIn -import androidx.compose.animation.scaleOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically -import androidx.compose.animation.togetherWith import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -32,13 +28,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.InsertDriveFile -import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.Close import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -51,7 +45,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalLayoutDirection @@ -76,8 +69,8 @@ import com.m57.hermescontrol.ui.common.BotAvatar import com.m57.hermescontrol.util.BidiUtils /** - * The chat input bar with a two-row layout: input+send on top, - * and a toolbar with attach/model chip/reasoning chip/mic below. + * The chat input bar: a single rounded card with the input on top and a + * controls row (attach, model/reasoning pill, mic, send) inside it below. */ @Composable fun ChatInputBar( @@ -116,29 +109,25 @@ fun ChatInputBar( // Attachment menu state var showAttachmentMenu by remember { mutableStateOf(false) } - var isFocused by remember { mutableStateOf(false) } + val palette = composerPalette() AnimatedVisibility( visible = true, enter = slideInVertically(initialOffsetY = { it }), exit = slideOutVertically(targetOffsetY = { it }), ) { + // One floating card holds the whole composer: suggestions, attachments, + // the input and the controls row. Flat fill, hairline edge, no shadow. Surface( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 6.dp), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, - border = - BorderStroke( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f), - ), - tonalElevation = 2.dp, - shadowElevation = 4.dp, + .padding(horizontal = 12.dp, vertical = 8.dp), + shape = MaterialTheme.shapes.large, + color = palette.card, + border = BorderStroke(width = 1.dp, color = palette.cardBorder), ) { - Column { + Column(modifier = Modifier.padding(top = 8.dp, bottom = 10.dp)) { // Commands hidden from the suggestion menu — desktop/CLI-only and // TUI-only commands that don't function on mobile (issue #574). // Single source of truth: CommandBlocklist.UNSUPPORTED, which is @@ -216,12 +205,12 @@ fun ChatInputBar( } } - // ── TOP ROW: Input field with embedded send button ── + // ── TOP ROW: Borderless input field ── Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp), + .padding(horizontal = 20.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { val placeholderText = @@ -257,109 +246,43 @@ fun ChatInputBar( modifier = Modifier .weight(1f) - .heightIn(min = 42.dp, max = 120.dp) + .heightIn(min = 42.dp, max = 200.dp) .padding(vertical = 4.dp) - .onFocusChanged { isFocused = it.isFocused } .testTag("chat_input"), enabled = isConnected, textStyle = - MaterialTheme.typography.bodyMedium.copy( - color = MaterialTheme.colorScheme.onSurface, + MaterialTheme.typography.bodyLarge.copy( + color = palette.text, textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr, ), singleLine = false, - maxLines = 4, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + maxLines = 8, + cursorBrush = SolidColor(palette.text), decorationBox = { innerTextField -> CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) { - Surface( - shape = RoundedCornerShape(18.dp), - border = - BorderStroke( - width = if (isFocused) 2.dp else 1.dp, - color = - if (isFocused) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.outline.copy(alpha = 0.5f) - }, - ), - color = MaterialTheme.colorScheme.surface, + Box( modifier = Modifier.fillMaxWidth(), + contentAlignment = + if (isInputRtl) { + Alignment.CenterEnd + } else { + Alignment.CenterStart + }, ) { - Row( - modifier = - Modifier - .padding(start = 12.dp, end = 4.dp, top = 9.dp, bottom = 9.dp) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier.weight(1f), - contentAlignment = - if (isInputRtl) { - Alignment.CenterEnd - } else { - Alignment.CenterStart - }, - ) { - CompositionLocalProvider( - LocalLayoutDirection provides inputLayoutDirection, - ) { - if (inputFieldValue.text.isEmpty()) { - Text( - text = placeholderText, - style = MaterialTheme.typography.bodyMedium, - textAlign = - if (isInputRtl) { - TextAlign.Right - } else { - TextAlign.Left - }, - color = - MaterialTheme.colorScheme.onSurfaceVariant.copy( - alpha = 0.6f, - ), - modifier = Modifier.fillMaxWidth(), - ) - } - innerTextField() - } - } - - // Send button INSIDE the field. Shown whenever a send is - // possible — text typed OR an attachment pending (issue - // #956): the old text-only gate hid the button entirely - // for attachment-only sends. - AnimatedContent( - targetState = canSend, - transitionSpec = { - (scaleIn(initialScale = 0.8f) + fadeIn()) - .togetherWith(scaleOut(targetScale = 0.8f) + fadeOut()) - }, - label = "send_toggle", - ) { showSend -> - if (showSend) { - IconButton( - onClick = onSend, - enabled = canSend, - colors = IconButtonDefaults.filledTonalIconButtonColors(), - modifier = - Modifier - .size(36.dp) - .testTag("send_button"), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.Send, - contentDescription = - stringResource( - R.string.chat_send_desc, - ), - ) - } - } + CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) { + if (inputFieldValue.text.isEmpty()) { + Text( + text = placeholderText, + style = MaterialTheme.typography.bodyLarge, + textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left, + color = palette.placeholder, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), + ) } + innerTextField() } } } @@ -374,6 +297,8 @@ fun ChatInputBar( currentSessionModel = currentSessionModel, reasoningLevel = reasoningLevel, isListening = isListening, + canSend = canSend, + onSend = onSend, onAttachTap = { showAttachmentMenu = true }, onModelTap = onModelTap, onReasoningSelected = onReasoningTap, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerPalette.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerPalette.kt new file mode 100644 index 00000000..531ba3f5 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerPalette.kt @@ -0,0 +1,52 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver + +/** + * Resolved colors for the chat composer card and its controls. + * + * Control fills are derived from [ColorScheme.onSurface] instead of the + * surfaceContainer tiers: several presets ship nearly identical tiers + * (Nord's High and Highest are the same color), which made the flat controls + * vanish into the card. A fixed onSurface tint keeps them visible in every + * preset and under dynamic color. Text and icon contrast is gated per preset + * in ComposerPaletteTest. + */ +internal data class ComposerPalette( + val card: Color, + val cardBorder: Color, + val text: Color, + val placeholder: Color, + val control: Color, + val onControl: Color, + val onControlVariant: Color, + val action: Color, + val onAction: Color, +) + +private const val CONTROL_TINT_ALPHA = 0.12f + +internal fun composerPalette(scheme: ColorScheme): ComposerPalette { + val card = scheme.surfaceContainer + val tint = scheme.onSurface.copy(alpha = CONTROL_TINT_ALPHA) + return ComposerPalette( + card = card, + cardBorder = tint.compositeOver(scheme.background), + text = scheme.onSurface, + placeholder = scheme.onSurfaceVariant, + control = tint.compositeOver(card), + onControl = scheme.onSurface, + onControlVariant = scheme.onSurfaceVariant, + action = scheme.onSurface, + onAction = scheme.surface, + ) +} + +@Composable +@ReadOnlyComposable +internal fun composerPalette(): ComposerPalette = composerPalette(MaterialTheme.colorScheme) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt index 5a0828ae..483bce86 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt @@ -1,28 +1,39 @@ package com.m57.hermescontrol.ui.chat.components -import androidx.compose.foundation.horizontalScroll +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight 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.widthIn -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AttachFile +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Bolt -import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material.icons.filled.Stop +import androidx.compose.material.icons.outlined.Mic import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilledIconButton import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonColors import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Switch @@ -35,6 +46,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.scale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -46,16 +58,24 @@ import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R /** - * Bottom toolbar row for the chat composer. + * Bottom controls row for the chat composer, rendered inside the composer card. * - * Layout: [📎 attach] [model chip] [←spacer→] [🧠 reasoning] [🎙 mic] + * Layout: [+ attach] [model · reasoning pill ──free space──] [mic] [action] * - * The reasoning chip opens a dropdown menu to pick a level (instead of cycling). + * All controls are flat and borderless; hierarchy comes from fill brightness. + * The pill shows the model and the reasoning level side by side — tapping the + * model half opens the model picker, tapping the level half opens the level menu. + * + * The trailing action button morphs: while a send is possible it sends, + * otherwise it carries the mic action. The flat mic button only appears next + * to it while it is in send mode, so dictation stays reachable at all times. + * + * The reasoning menu picks a level (instead of cycling). * When [canDisableReasoning] is false the "None" level is disabled with a * "reasoning always on" hint (issue #946). Absent key (null) means no * restriction is known — full scale offered. * When [supportsReasoning] is false the model takes no reasoning parameter - * and the chip is disabled. + * and the level menu is disabled. */ @Composable fun ComposerToolbar( @@ -68,6 +88,8 @@ fun ComposerToolbar( onReasoningSelected: (String?) -> Unit, onMicTap: () -> Unit, modifier: Modifier = Modifier, + canSend: Boolean = false, + onSend: () -> Unit = {}, canDisableReasoning: Boolean? = null, supportsReasoning: Boolean? = null, fastMode: Boolean = false, @@ -76,6 +98,7 @@ fun ComposerToolbar( onToggleFastMode: () -> Unit = {}, ) { var showReasoningMenu by remember { mutableStateOf(false) } + val palette = composerPalette() val reasoningDisabledForModel = supportsReasoning == false val canDisable = canDisableReasoning @@ -83,62 +106,74 @@ fun ComposerToolbar( modifier = modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 4.dp) + .padding(horizontal = 12.dp) + .padding(top = 6.dp) .testTag("composer_toolbar"), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { // Attach button - IconButton( + FilledIconButton( onClick = onAttachTap, enabled = isConnected, - modifier = Modifier.size(36.dp), + colors = flatIconButtonColors(palette), + modifier = Modifier.size(ControlSize), ) { Icon( - imageVector = Icons.Default.AttachFile, + imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.chat_attach_file), - tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } - // Model chip — takes available space, fixed height - FilterChip( - selected = currentSessionModel != null, - onClick = onModelTap, - label = { - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - ) { - Text( - text = currentSessionModel ?: "Model", - style = MaterialTheme.typography.bodySmall, - maxLines = 1, - ) - } - }, - modifier = - Modifier - .weight(1f) - .height(28.dp) - .testTag("model_chip"), - ) + // Model + reasoning pill — wraps its content inside the free space, + // pushing the mic/action buttons to the far end + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + Row( + modifier = + Modifier + .height(ControlSize) + .clip(CircleShape) + .background(palette.control), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + // Keeps the provider so same-named models from different providers + // stay distinguishable; trims from the middle when tight. + text = currentSessionModel?.let(::composerModelLabel) ?: "Model", + style = MaterialTheme.typography.bodyMedium, + color = palette.onControl, + maxLines = 1, + overflow = TextOverflow.MiddleEllipsis, + modifier = + Modifier + .weight(1f, fill = false) + .fillMaxHeight() + .clickable(onClick = onModelTap) + .wrapContentHeight() + .padding(start = 16.dp, end = 6.dp) + .testTag("model_chip"), + ) - // Reasoning & Generation controls with dropdown menu (Option 3) - Box { - FilterChip( - selected = reasoningLevel != null || fastMode, - onClick = { showReasoningMenu = true }, - enabled = isConnected, - label = { + Box { Row( + modifier = + Modifier + .fillMaxHeight() + .clickable(enabled = isConnected) { showReasoningMenu = true } + .padding(start = 6.dp, end = 12.dp) + .testTag("reasoning_chip"), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { + val levelColor = + palette.onControlVariant.copy( + alpha = if (isConnected) 1f else 0.38f, + ) if (fastMode) { Icon( imageVector = Icons.Filled.Bolt, contentDescription = stringResource(R.string.chat_fast_mode_label), - tint = MaterialTheme.colorScheme.primary, + tint = levelColor, modifier = Modifier.size(14.dp), ) } @@ -156,175 +191,265 @@ fun ComposerToolbar( rLabel } }, - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.bodyMedium, + color = levelColor, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - } - }, - modifier = - Modifier - .height(28.dp) - .testTag("reasoning_chip"), - ) - - DropdownMenu( - expanded = showReasoningMenu, - onDismissRequest = { showReasoningMenu = false }, - modifier = Modifier.widthIn(min = 220.dp), - ) { - // ── Fast Mode Toggle Item ── - val fastAvailable = isConnected && fastSupported - DropdownMenuItem( - leadingIcon = { + // Chevron marks the level half of the pill as a menu trigger Icon( - imageVector = Icons.Filled.Bolt, - contentDescription = null, - tint = - if (fastMode) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - modifier = Modifier.size(20.dp), + imageVector = Icons.Filled.ExpandMore, + contentDescription = stringResource(R.string.chat_reasoning_menu_desc), + tint = levelColor, + modifier = Modifier.size(16.dp), ) - }, - text = { - Column { - Text( - text = stringResource(R.string.chat_fast_mode_label), - style = MaterialTheme.typography.bodyMedium, - fontWeight = if (fastMode) FontWeight.SemiBold else FontWeight.Normal, - ) - if (!fastSupported) { - Text( - text = stringResource(R.string.chat_fast_mode_unavailable), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + } + + DropdownMenu( + expanded = showReasoningMenu, + onDismissRequest = { showReasoningMenu = false }, + modifier = Modifier.widthIn(min = 220.dp), + ) { + // ── Fast Mode Toggle Item ── + val fastAvailable = isConnected && fastSupported + DropdownMenuItem( + leadingIcon = { + Icon( + imageVector = Icons.Filled.Bolt, + contentDescription = null, + tint = + if (fastMode) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + modifier = Modifier.size(20.dp), ) - } - } - }, - trailingIcon = { - if (isFastModeChanging) { - CircularProgressIndicator( - modifier = Modifier.size(16.dp), - strokeWidth = 2.dp, - color = MaterialTheme.colorScheme.primary, - ) - } else { - Switch( - checked = fastMode, - onCheckedChange = null, // MenuItem click owns the trigger - enabled = fastAvailable && !isFastModeChanging, - modifier = - Modifier - .scale(0.85f) - .testTag("fast_mode_switch"), - ) - } - }, - onClick = { - if (fastAvailable && !isFastModeChanging) { - onToggleFastMode() - } - }, - enabled = fastAvailable && !isFastModeChanging, - ) + }, + text = { + Column { + Text( + text = stringResource(R.string.chat_fast_mode_label), + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (fastMode) FontWeight.SemiBold else FontWeight.Normal, + ) + if (!fastSupported) { + Text( + text = stringResource(R.string.chat_fast_mode_unavailable), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + }, + trailingIcon = { + if (isFastModeChanging) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Switch( + checked = fastMode, + onCheckedChange = null, // MenuItem click owns the trigger + enabled = fastAvailable && !isFastModeChanging, + modifier = + Modifier + .scale(0.85f) + .testTag("fast_mode_switch"), + ) + } + }, + onClick = { + if (fastAvailable && !isFastModeChanging) { + onToggleFastMode() + } + }, + enabled = fastAvailable && !isFastModeChanging, + ) - HorizontalDivider() + HorizontalDivider() - Text( - text = "REASONING", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - ) - if (canDisable == false) { - Text( - text = "reasoning always on", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), - ) - } - if (reasoningDisabledForModel) { - Text( - text = "no reasoning parameter for this model", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), - ) - } - val allLevels = - listOf( - "none" to "None", - "minimal" to "Minimal", - "low" to "Low", - "medium" to "Med", - "high" to "High", - "xhigh" to "XHigh", - "max" to "Max", - "ultra" to "Ultra", - ) - allLevels.forEach { (level, label) -> - val isNone = level == "none" - val noneDisabled = isNone && (canDisable == false || reasoningDisabledForModel) - DropdownMenuItem( - text = { + Text( + text = "REASONING", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + ) + if (canDisable == false) { Text( - text = label, - fontWeight = - if (reasoningLevel == level) { - MaterialTheme.typography.bodyMedium.fontWeight - } else { - null - }, - color = - when { - noneDisabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) - reasoningLevel == level -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.onSurface - }, + text = "reasoning always on", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), ) - }, - onClick = { - showReasoningMenu = false - onReasoningSelected(level) - }, - enabled = !noneDisabled && !reasoningDisabledForModel, - ) + } + if (reasoningDisabledForModel) { + Text( + text = "no reasoning parameter for this model", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), + ) + } + val allLevels = + listOf( + "none" to "None", + "minimal" to "Minimal", + "low" to "Low", + "medium" to "Med", + "high" to "High", + "xhigh" to "XHigh", + "max" to "Max", + "ultra" to "Ultra", + ) + allLevels.forEach { (level, label) -> + val isNone = level == "none" + val noneDisabled = isNone && (canDisable == false || reasoningDisabledForModel) + DropdownMenuItem( + text = { + Text( + text = label, + fontWeight = + if (reasoningLevel == level) { + MaterialTheme.typography.bodyMedium.fontWeight + } else { + null + }, + color = + when { + noneDisabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + reasoningLevel == level -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurface + }, + ) + }, + onClick = { + showReasoningMenu = false + onReasoningSelected(level) + }, + enabled = !noneDisabled && !reasoningDisabledForModel, + ) + } + } } } } - // Mic / Stop button - IconButton( - onClick = onMicTap, - enabled = isConnected, + // Flat mic / stop button — only while the action button is in send mode + AnimatedVisibility( + visible = canSend, + enter = fadeIn() + scaleIn(initialScale = 0.8f), + exit = fadeOut() + scaleOut(targetScale = 0.8f), + ) { + FilledIconButton( + onClick = onMicTap, + enabled = isConnected, + colors = if (isListening) listeningIconButtonColors() else flatIconButtonColors(palette), + modifier = + Modifier + .size(ControlSize) + .testTag(if (isListening) "mic_stop_button" else "mic_button"), + ) { + Icon( + imageVector = if (isListening) Icons.Default.Stop else Icons.Outlined.Mic, + contentDescription = if (isListening) "Stop listening" else "Mic", + ) + } + } + + // Action button — send when a send is possible, mic / stop otherwise + FilledIconButton( + onClick = if (canSend) onSend else onMicTap, + enabled = canSend || isConnected, colors = - if (isListening) { - IconButtonDefaults.filledTonalIconButtonColors( - containerColor = MaterialTheme.colorScheme.error, - contentColor = MaterialTheme.colorScheme.onError, - ) + if (!canSend && isListening) { + listeningIconButtonColors() } else { - IconButtonDefaults.filledTonalIconButtonColors() + IconButtonDefaults.filledIconButtonColors( + containerColor = palette.action, + contentColor = palette.onAction, + ) }, modifier = Modifier - .size(36.dp) - .testTag(if (isListening) "mic_stop_button" else "mic_button"), + .size(ControlSize) + .testTag( + when { + canSend -> "send_button" + isListening -> "mic_stop_button" + else -> "mic_button" + }, + ), ) { - Icon( - imageVector = if (isListening) Icons.Default.Stop else Icons.Default.Mic, - contentDescription = if (isListening) "Stop listening" else "Mic", - ) + Crossfade( + targetState = + when { + canSend -> ActionGlyph.SEND + isListening -> ActionGlyph.STOP + else -> ActionGlyph.VOICE + }, + label = "composer_action_glyph", + ) { glyph -> + when (glyph) { + ActionGlyph.SEND -> { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = stringResource(R.string.chat_send_desc), + ) + } + + ActionGlyph.STOP -> { + Icon(imageVector = Icons.Default.Stop, contentDescription = "Stop listening") + } + + ActionGlyph.VOICE -> { + Icon(imageVector = Icons.Outlined.Mic, contentDescription = "Mic") + } + } + } } } } +private val ControlSize = 36.dp + +private enum class ActionGlyph { SEND, STOP, VOICE } + +@Composable +private fun flatIconButtonColors(palette: ComposerPalette): IconButtonColors = + IconButtonDefaults.filledIconButtonColors( + containerColor = palette.control, + contentColor = palette.onControl, + ) + +@Composable +private fun listeningIconButtonColors(): IconButtonColors = + IconButtonDefaults.filledIconButtonColors( + containerColor = MaterialTheme.colorScheme.error, + contentColor = MaterialTheme.colorScheme.onError, + ) + +/** + * Model label for the composer pill: the session's "provider/model" id with + * only the "custom:" marker removed from the provider ("custom:acme/glm-5.3" + * → "acme/glm-5.3"). The provider itself is kept so the same model served by + * different providers never renders identically. + */ +internal fun composerModelLabel(sessionModel: String): String { + val slash = sessionModel.indexOf('/') + if (slash <= 0) return sessionModel + val provider = sessionModel.substring(0, slash) + if (!provider.startsWith(CUSTOM_PROVIDER_PREFIX) || provider.length == CUSTOM_PROVIDER_PREFIX.length) { + return sessionModel + } + return provider.removePrefix(CUSTOM_PROVIDER_PREFIX) + sessionModel.substring(slash) +} + +private const val CUSTOM_PROVIDER_PREFIX = "custom:" + /** * Build a human-readable label from a reasoning effort level. * diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ecea621e..3820722c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1241,6 +1241,7 @@ Fast Fast mode Fast mode unavailable for this model + Reasoning options Save %1$s Switch model (this chat) Clear value diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerPaletteTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerPaletteTest.kt new file mode 100644 index 00000000..93a76b07 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerPaletteTest.kt @@ -0,0 +1,76 @@ +package com.m57.hermescontrol.ui.chat.components + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import com.m57.hermescontrol.theme.ThemePreset +import com.m57.hermescontrol.theme.resolveColorScheme +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Contrast gate for the chat composer across every preset in both modes. + * + * - Primary text and control labels (onSurface) >= 4.5:1 — WCAG AA text. + * - Secondary text (placeholder, reasoning level) >= 3:1 — same bar as the + * full-bleed header gate in ThemePaletteTest. + * - Action button against the card, and its glyph against the button >= 3:1. + * - Flat controls and the card edge must stay visibly distinct (>= 1.15:1). + * Their labels and icons carry the WCAG 1.4.11 contrast; this floor catches + * presets whose surface tiers collapse (Nord's High == Highest rendered the + * controls invisible). + */ +class ComposerPaletteTest { + private fun contrast( + a: Color, + b: Color, + ): Float { + val l1 = a.luminance() + val l2 = b.luminance() + return (maxOf(l1, l2) + 0.05f) / (minOf(l1, l2) + 0.05f) + } + + private fun forEveryPresetMode(block: (String, ComposerPalette, Color) -> Unit) { + ThemePreset.entries.forEach { preset -> + listOf(true, false).forEach { dark -> + val scheme = resolveColorScheme(preset, darkTheme = dark) + block("$preset dark=$dark", composerPalette(scheme), scheme.background) + } + } + } + + private fun assertContrast( + label: String, + a: Color, + b: Color, + min: Float, + ) { + val ratio = contrast(a, b) + assertTrue("$label contrast %.2f must be >= $min".format(ratio), ratio >= min) + } + + @Test + fun textMeetsContrastInEveryPresetMode() { + forEveryPresetMode { mode, p, _ -> + assertContrast("$mode input text/card", p.text, p.card, 4.5f) + assertContrast("$mode placeholder/card", p.placeholder, p.card, 3f) + assertContrast("$mode control label/control", p.onControl, p.control, 4.5f) + assertContrast("$mode reasoning level/control", p.onControlVariant, p.control, 3f) + } + } + + @Test + fun actionButtonMeetsContrastInEveryPresetMode() { + forEveryPresetMode { mode, p, _ -> + assertContrast("$mode action/card", p.action, p.card, 3f) + assertContrast("$mode action glyph/action", p.onAction, p.action, 3f) + } + } + + @Test + fun controlsAndCardEdgeStayVisibleInEveryPresetMode() { + forEveryPresetMode { mode, p, background -> + assertContrast("$mode control/card", p.control, p.card, 1.15f) + assertContrast("$mode card border/background", p.cardBorder, background, 1.15f) + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerToolbarTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerToolbarTest.kt new file mode 100644 index 00000000..c0a90495 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ComposerToolbarTest.kt @@ -0,0 +1,35 @@ +package com.m57.hermescontrol.ui.chat.components + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Unit tests for [composerModelLabel] — the label shown in the composer's + * model/reasoning pill. Only the "custom:" provider marker is dropped; the + * provider stays so the same model from different providers never collides. + */ +class ComposerToolbarTest { + @Test + fun composerModelLabel_keepsProviderToDisambiguateSameModel() { + assertEquals("openai/gpt-5.5", composerModelLabel("openai/gpt-5.5")) + assertEquals("copilot/gpt-5.5", composerModelLabel("copilot/gpt-5.5")) + } + + @Test + fun composerModelLabel_dropsOnlyTheCustomMarker() { + assertEquals("my-provider/glm-5.3", composerModelLabel("custom:my-provider/glm-5.3")) + assertEquals("openrouter/openai/gpt-5.5", composerModelLabel("openrouter/openai/gpt-5.5")) + } + + @Test + fun composerModelLabel_leavesModelTagsAndPlainIdsAlone() { + assertEquals("ollama/llama3:8b", composerModelLabel("ollama/llama3:8b")) + assertEquals("llama3:8b", composerModelLabel("llama3:8b")) + assertEquals("gpt-5", composerModelLabel("gpt-5")) + } + + @Test + fun composerModelLabel_bareCustomMarker_fallsBackToFullId() { + assertEquals("custom:/glm-5.3", composerModelLabel("custom:/glm-5.3")) + } +} From 74f10ec1193df23235d1885f89a8ae09b862519c Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:47:25 +0300 Subject: [PATCH 20/29] fix(release): use tag as release title (#1128) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d17935d..48ce2ab6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -110,7 +110,7 @@ jobs: uses: softprops/action-gh-release@v3 with: tag_name: ${{ steps.version.outputs.tag }} - name: Hermes Mobile ${{ steps.version.outputs.tag }} + name: ${{ steps.version.outputs.tag }} generate_release_notes: true files: | release/hermes-mobile-${{ steps.version.outputs.tag }}.apk From e6b441cfc6f42feaca9ae7de217ef92c85a50e89 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:13:23 +0300 Subject: [PATCH 21/29] fix(chat): scroll long composer model names (#1129) --- .../components/ComposerInteractionTest.kt | 138 +++++++++++++++--- .../ui/chat/components/ComposerToolbar.kt | 44 ++++-- 2 files changed, 149 insertions(+), 33 deletions(-) diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt index 751abfbb..546b767d 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt @@ -1,16 +1,26 @@ package com.m57.hermescontrol.ui.chat.components +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.getUnclippedBoundsInRoot import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeLeft import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.m57.hermescontrol.data.ws.CommandCatalog @@ -36,30 +46,46 @@ class ComposerInteractionTest { private var selectedLevel: String? = null /** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */ - private fun setComposer(reasoningLevel: String? = "medium") { + private fun setComposer( + reasoningLevel: String? = "medium", + model: String = "openai/gpt-5.5", + composerWidth: Dp? = null, + modelAfterTap: String? = null, + ) { composeTestRule.setContent { var input by remember { mutableStateOf(TextFieldValue("")) } var listening by remember { mutableStateOf(false) } - ChatInputBar( - inputFieldValue = input, - onInputChange = { input = it }, - onSend = { - sends++ - input = TextFieldValue("") - }, - onMicTap = { - micTaps++ - listening = !listening - }, - isListening = listening, - isAgentTyping = false, - isConnected = true, - commandCatalog = CommandCatalog(), - currentSessionModel = "openai/gpt-5.5", - reasoningLevel = reasoningLevel, - onModelTap = { modelTaps++ }, - onReasoningTap = { selectedLevel = it }, - ) + var currentModel by remember { mutableStateOf(model) } + val composer: @Composable () -> Unit = { + ChatInputBar( + inputFieldValue = input, + onInputChange = { input = it }, + onSend = { + sends++ + input = TextFieldValue("") + }, + onMicTap = { + micTaps++ + listening = !listening + }, + isListening = listening, + isAgentTyping = false, + isConnected = true, + commandCatalog = CommandCatalog(), + currentSessionModel = currentModel, + reasoningLevel = reasoningLevel, + onModelTap = { + modelTaps++ + modelAfterTap?.let { currentModel = it } + }, + onReasoningTap = { selectedLevel = it }, + ) + } + if (composerWidth == null) { + composer() + } else { + Box(Modifier.width(composerWidth).testTag("composer_toolbar")) { composer() } + } } } @@ -151,4 +177,74 @@ class ComposerInteractionTest { assertEquals("reasoning side must not open the model picker", 0, modelTaps) } } + + @Test + fun longModelName_narrowComposer_scrollsWithoutOpeningPicker_orHidingControls() { + val longModel = "openrouter/some-extremely-long-model-name-preview-2026-with-extra-characters" + setComposer(model = longModel, composerWidth = 280.dp) + + composeTestRule.onNodeWithTag("reasoning_chip").assertIsDisplayed() + composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed() + val modelText = composeTestRule.onNodeWithText(longModel) + val leftBefore = modelText.getUnclippedBoundsInRoot().left + + composeTestRule.onNodeWithTag("model_chip").performTouchInput { swipeLeft() } + val leftAfter = modelText.getUnclippedBoundsInRoot().left + + composeTestRule.runOnIdle { + assertEquals("swiping the model must not open the picker", 0, modelTaps) + } + check(leftAfter < leftBefore) { "model text did not move after horizontal swipe" } + composeTestRule.onNodeWithTag("reasoning_chip").assertIsDisplayed() + composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed() + } + + @Test + fun shortModelName_wideComposerKeepsPillCompact() { + setComposer(composerWidth = 420.dp) + + val toolbarBounds = + composeTestRule + .onNodeWithTag("composer_toolbar", useUnmergedTree = true) + .getUnclippedBoundsInRoot() + val toolbarWidth = toolbarBounds.right - toolbarBounds.left + val modelChip = composeTestRule.onNodeWithTag("model_chip").getUnclippedBoundsInRoot() + val reasoningChip = composeTestRule.onNodeWithTag("reasoning_chip").getUnclippedBoundsInRoot() + val actionButton = composeTestRule.onNodeWithTag("mic_button").getUnclippedBoundsInRoot() + val pillWidth = maxOf(modelChip.right, reasoningChip.right) - minOf(modelChip.left, reasoningChip.left) + + check(pillWidth < toolbarWidth * 0.8f) { + "short model pill should remain content-sized, width=$pillWidth toolbar=$toolbarWidth" + } + check(actionButton.right > toolbarBounds.right - 32.dp) { + "trailing action should stay near the toolbar edge, right=${actionButton.right} toolbarRight=${toolbarBounds.right}" + } + } + + @Test + fun changingModel_resetsScrolledLabelToTheStart() { + val firstModel = "openrouter/some-extremely-long-model-name-preview-2026-with-extra-characters" + val secondModel = "anthropic/another-extremely-long-model-name-preview-2026-with-extra-characters" + setComposer(model = firstModel, modelAfterTap = secondModel, composerWidth = 280.dp) + + val firstText = composeTestRule.onNodeWithText(firstModel) + val leftBefore = firstText.getUnclippedBoundsInRoot().left + composeTestRule.onNodeWithTag("model_chip").performTouchInput { swipeLeft() } + check(firstText.getUnclippedBoundsInRoot().left < leftBefore) { + "first model text did not move after horizontal swipe" + } + + composeTestRule.onNodeWithTag("model_chip").performClick() + val secondText = composeTestRule.onNodeWithText(secondModel) + val leftAfterModelChange = secondText.getUnclippedBoundsInRoot().left + + check(leftAfterModelChange >= leftBefore - 1.dp) { + "new model label should reset to its initial left position" + } + composeTestRule.onNodeWithTag("reasoning_chip").assertIsDisplayed() + composeTestRule.onNodeWithTag("mic_button").assertIsDisplayed() + composeTestRule.runOnIdle { + assertEquals("model selection should still invoke the picker", 1, modelTaps) + } + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt index 483bce86..1d0a88a6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt @@ -8,17 +8,21 @@ import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight 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.layout.widthIn import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send @@ -40,6 +44,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -47,6 +52,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.draw.scale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource @@ -127,32 +133,46 @@ fun ComposerToolbar( // Model + reasoning pill — wraps its content inside the free space, // pushing the mic/action buttons to the far end + val modelScrollState = rememberScrollState() + LaunchedEffect(currentSessionModel) { + modelScrollState.scrollTo(0) + } Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { Row( modifier = Modifier + .width(IntrinsicSize.Max) .height(ControlSize) .clip(CircleShape) .background(palette.control), verticalAlignment = Alignment.CenterVertically, ) { - Text( - // Keeps the provider so same-named models from different providers - // stay distinguishable; trims from the middle when tight. - text = currentSessionModel?.let(::composerModelLabel) ?: "Model", - style = MaterialTheme.typography.bodyMedium, - color = palette.onControl, - maxLines = 1, - overflow = TextOverflow.MiddleEllipsis, + Box( modifier = Modifier - .weight(1f, fill = false) + .weight(1f) .fillMaxHeight() + .clipToBounds() + .horizontalScroll(modelScrollState) .clickable(onClick = onModelTap) - .wrapContentHeight() - .padding(start = 16.dp, end = 6.dp) .testTag("model_chip"), - ) + ) { + Text( + // Keeps the provider so same-named models from different providers + // stay distinguishable while allowing the full label to be revealed. + text = currentSessionModel?.let(::composerModelLabel) ?: "Model", + style = MaterialTheme.typography.bodyMedium, + color = palette.onControl, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + modifier = + Modifier + .fillMaxHeight() + .wrapContentHeight() + .padding(start = 16.dp, end = 6.dp), + ) + } Box { Row( From 984053b0e74b28d60d0cd069ddb29b47ff6c8ca4 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:43:20 +0300 Subject: [PATCH 22/29] feat(chat): simplify assistant message states (#1131) --- .../chat/fullbleed/FullBleedChatListTest.kt | 65 ++++++- .../m57/hermescontrol/ui/chat/ChatScreen.kt | 6 +- .../hermescontrol/ui/chat/ChatViewModel.kt | 10 + .../ui/chat/ChatWsEventReducer.kt | 17 +- .../ui/chat/components/MessageCards.kt | 27 ++- .../ui/chat/fullbleed/AgentStatusIndicator.kt | 172 +++++++++++++++++ .../chat/fullbleed/FullBleedAgentMessage.kt | 118 +----------- .../ui/chat/fullbleed/FullBleedChatList.kt | 102 +++++----- .../ui/chat/fullbleed/FullBleedStreaming.kt | 4 - .../ui/chat/fullbleed/FullBleedTurns.kt | 22 ++- app/src/main/res/values-ar/strings.xml | 8 +- app/src/main/res/values-ko/strings.xml | 8 +- app/src/main/res/values-zh/strings.xml | 8 +- app/src/main/res/values/strings.xml | 8 +- .../ui/chat/FullBleedTurnsTest.kt | 178 ++++++++++++++++++ 15 files changed, 552 insertions(+), 201 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/AgentStatusIndicator.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt index d94af3c5..e78ac7f1 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatListTest.kt @@ -6,12 +6,14 @@ import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ChatSearchState import com.m57.hermescontrol.ui.chat.ChatViewModel import com.m57.hermescontrol.ui.chat.MessageRole +import com.m57.hermescontrol.ui.chat.StreamingState import com.m57.hermescontrol.ui.chat.ToolStatus import com.m57.hermescontrol.ui.chat.components.ChatScrollController import io.mockk.mockk @@ -47,19 +49,21 @@ class FullBleedChatListTest { private fun render( messages: List, streamingMessage: ChatMessage? = null, + streamingState: StreamingState = StreamingState(streamingMessage = streamingMessage), + isAgentTyping: Boolean = streamingMessage?.isStreaming == true, clarify: Boolean = false, ) { composeTestRule.setContent { val listState = LazyListState() FullBleedChatList( messages = messages, - streamingMessage = streamingMessage, + streamingState = streamingState, + isAgentTyping = isAgentTyping, searchState = ChatSearchState(), typingEffectEnabled = false, typingEffectDelayMs = 30, isLoading = false, isLoadingOlder = false, - isDark = false, listState = listState, scrollController = ChatScrollController( @@ -115,7 +119,7 @@ class FullBleedChatListTest { } @Test - fun turnHeader_renderedOncePerAgentTurn() { + fun assistantHeader_isRemovedFromEveryAgentTurn() { render( listOf( msg("a1", MessageRole.ASSISTANT), @@ -125,8 +129,8 @@ class FullBleedChatListTest { msg("a3", MessageRole.ASSISTANT), ), ) - // Two agent turns (a1..a2, a3) -> two turn headers (one per turn). - composeTestRule.onAllNodesWithTag("fullbleed_agent_header").assertCountEquals(2) + composeTestRule.onAllNodesWithTag("fullbleed_agent_header").assertCountEquals(0) + composeTestRule.onNodeWithTag("fullbleed_finish_time").assertDoesNotExist() } @Test @@ -139,4 +143,55 @@ class FullBleedChatListTest { composeTestRule.onNodeWithTag("fullbleed_agent_message").assertIsDisplayed() composeTestRule.onNodeWithTag("clarify_bubble").assertIsDisplayed() } + + @Test + fun emptyStreamingMessage_showsTypingStatus_withoutEmptyAssistantProse() { + render( + messages = listOf(msg("u1", MessageRole.USER)), + streamingMessage = msg("s1", MessageRole.ASSISTANT, content = "", isStreaming = true), + isAgentTyping = true, + ) + composeTestRule.onNodeWithTag("typing_indicator").assertIsDisplayed() + composeTestRule.onNodeWithTag("fullbleed_agent_message").assertDoesNotExist() + } + + @Test + fun reasoningStreaming_showsThinkingStatus_andKeepsReasoningCard() { + val streaming = + msg( + "s1", + MessageRole.ASSISTANT, + content = "", + isStreaming = true, + ).copy(reasoningText = "step") + render( + messages = listOf(msg("u1", MessageRole.USER)), + streamingMessage = streaming, + streamingState = + StreamingState( + streamingMessage = streaming, + isReasoning = true, + reasoningText = "step", + ), + isAgentTyping = true, + ) + composeTestRule.onNodeWithTag("agent_status_thinking").assertIsDisplayed() + composeTestRule.onNodeWithTag("reasoning_card").assertIsDisplayed() + composeTestRule.onNodeWithTag("typing_indicator").assertDoesNotExist() + } + + @Test + fun runningTool_showsToolStatus_insteadOfTypingDots() { + render( + messages = + listOf( + msg("u1", MessageRole.USER), + msg("t1", MessageRole.TOOL, toolStatus = ToolStatus.RUNNING).copy(toolName = "web_search"), + ), + isAgentTyping = true, + ) + composeTestRule.onNodeWithTag("agent_status_tool").assertIsDisplayed() + composeTestRule.onNodeWithText("Searching…").assertIsDisplayed() + composeTestRule.onNodeWithTag("typing_indicator").assertDoesNotExist() + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index db8bfc30..9dd91ea3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -359,7 +358,6 @@ fun ChatScreen( } var viewingImage by rememberSaveable { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } - val isDark = isSystemInDarkTheme() val launchExternalActivity: (() -> Unit) -> Unit = { launch -> ExternalActivityLifecycleGuard.launchExternalActivity( acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease, @@ -668,14 +666,14 @@ fun ChatScreen( // surface since the bubble renderer was removed. FullBleedChatList( messages = state.messages, - streamingMessage = streamingState.streamingMessage, + streamingState = streamingState, + isAgentTyping = state.isAgentTyping, searchState = searchState, typingEffectEnabled = state.typingEffectEnabled, typingEffectDelayMs = state.typingEffectDelayMs, maxToolCallsPerTurn = state.maxToolCallsPerTurn, isLoading = state.isLoading, isLoadingOlder = state.isLoadingOlder, - isDark = isDark, listState = listState, scrollController = scrollController, lastAnimatedMessageId = lastAnimatedMessageId, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 75824201..f164cad4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -1518,6 +1518,16 @@ class ChatViewModel( else -> error.toString() } + if (method == WsMethods.PROMPT_SUBMIT || method == WsMethods.SESSION_REDIRECT) { + // A prompt rejection can arrive before message.start, leaving the + // UI with only the optimistic typing state. Clear the live tail so + // a failed generation cannot leave stale dots or reasoning behind. + sealStreamingMessageIfAny() + _uiState.update { it.copy(isAgentTyping = false) } + _streamingState.update { StreamingState() } + streamingController.resetStreaming() + } + // Session resume failures go through the bounded retry (desktop // parity) instead of a one-shot snackbar — the session may be // mid-flush on the gateway or the WS may have just rebound, and a diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt index f79882f0..f368f80c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatWsEventReducer.kt @@ -456,10 +456,11 @@ object ChatWsEventReducer { streamingState: StreamingState, ): ReducerResult { val streaming = - streamingState.streamingMessage ?: return ReducerResult( - state = state, - streamingState = streamingState, - ) + streamingState.streamingMessage + ?: return ReducerResult( + state = state.copy(isAgentTyping = false), + streamingState = streamingState, + ) val reasoning = if (streamingState.reasoningText.isNotBlank()) { streamingState.reasoningText @@ -578,7 +579,12 @@ object ChatWsEventReducer { val nextTodos = parsedTodos ?: newState.todos return ReducerResult( - state = newState.copy(messages = newState.messages + toolMessage, todos = nextTodos), + state = + newState.copy( + messages = newState.messages + toolMessage, + isAgentTyping = true, + todos = nextTodos, + ), streamingState = finalStreamingState, effects = effects, ) @@ -810,6 +816,7 @@ object ChatWsEventReducer { ReducerResult( state = state.copy( + isAgentTyping = false, isLoading = false, errorMessage = "Backend error: ${event.message ?: "Unknown gateway error"}", ), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt index dd0477ab..c4bf9e2e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt @@ -68,6 +68,8 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -746,13 +748,19 @@ fun SubagentCard( // ── TypingIndicator ─────────────────────────────────────────────────────── /** - * Three bouncing dots shown while the assistant is typing / thinking. - * Staggered animation: 0ms, 150ms, 300ms delay per dot. + * Three subtle dots shown while the assistant is waiting to produce visible + * content. Staggered opacity/scale animation keeps the indicator lightweight + * without the distracting vertical bounce used by the old chat renderer. */ @Composable fun TypingIndicator(modifier: Modifier = Modifier) { + val description = stringResource(R.string.chat_agent_status_typing) Row( - modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp).testTag("typing_indicator"), + modifier = + modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .semantics { contentDescription = description } + .testTag("typing_indicator"), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -768,18 +776,18 @@ private fun TypingDot(delayMs: Int) { val typingSpec: InfiniteRepeatableSpec = remember(delayMs) { infiniteRepeatable( - animation = tween(400, delayMillis = delayMs, easing = LinearEasing), + animation = tween(700, delayMillis = delayMs, easing = LinearEasing), repeatMode = RepeatMode.Reverse, ) } val offset by infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = -6f, + initialValue = 0.85f, + targetValue = 1f, animationSpec = typingSpec, - label = "typing_dot_offset_$delayMs", + label = "typing_dot_scale_$delayMs", ) val alpha by infiniteTransition.animateFloat( - initialValue = 0.4f, + initialValue = 0.45f, targetValue = 1f, animationSpec = typingSpec, label = "typing_dot_alpha_$delayMs", @@ -790,7 +798,8 @@ private fun TypingDot(delayMs: Int) { .size(8.dp) .clip(CircleShape) .graphicsLayer { - this.translationY = offset + this.scaleX = offset + this.scaleY = offset this.alpha = alpha }, ) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/AgentStatusIndicator.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/AgentStatusIndicator.kt new file mode 100644 index 00000000..1f6e1cc2 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/AgentStatusIndicator.kt @@ -0,0 +1,172 @@ +package com.m57.hermescontrol.ui.chat.fullbleed + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +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.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.ui.chat.ChatMessage +import com.m57.hermescontrol.ui.chat.MessageRole +import com.m57.hermescontrol.ui.chat.StreamingState +import com.m57.hermescontrol.ui.chat.ToolSchemaRegistry +import com.m57.hermescontrol.ui.chat.ToolStatus +import com.m57.hermescontrol.ui.chat.components.TypingIndicator +import com.m57.hermescontrol.ui.chat.isUserTurnBoundary + +/** Live status for an agent turn before visible assistant prose is available. */ +internal sealed interface AgentStatus { + data object Typing : AgentStatus + + data object Thinking : AgentStatus + + data class Tool( + val name: String?, + ) : AgentStatus +} + +/** + * Derives the one status that should occupy the agent turn's live tail. + * + * Tool execution wins over generic states, and visible streaming prose wins + * over waiting/reasoning states. This keeps one indicator on screen at a time + * without introducing a second generation state store. + */ +internal fun deriveAgentStatus( + isAgentTyping: Boolean, + streamingState: StreamingState, + messages: List, +): AgentStatus? { + if (!isAgentTyping) return null + + val currentTurnStart = messages.indexOfLast { it.isUserTurnBoundary() } + val currentTurnMessages = + if (currentTurnStart >= 0) { + messages.drop(currentTurnStart + 1) + } else { + emptyList() + } + val activeTool = + currentTurnMessages + .asReversed() + .firstOrNull { + it.role == MessageRole.TOOL && + it.toolStatus == ToolStatus.RUNNING + } + val hasVisibleStreamingContent = streamingState.streamingMessage?.content?.isNotBlank() == true + + return when { + activeTool != null -> AgentStatus.Tool(activeTool.toolName) + hasVisibleStreamingContent -> null + streamingState.isThinking || streamingState.isReasoning -> AgentStatus.Thinking + isAgentTyping -> AgentStatus.Typing + else -> null + } +} + +/** Maps existing canonical tool names to compact, user-facing activity text. */ +internal fun toolStatusLabelRes(toolName: String?): Int = + when (toolName?.lowercase()) { + "web_search", + "search_files", + "session_search", + "tool_search", + "x_search", + -> R.string.chat_agent_status_searching + + "read_file", + -> R.string.chat_agent_status_reading + + "web_extract", + "browser_navigate", + "browser_click", + "browser_type", + "browser_scroll", + "browser_back", + "browser_press", + "browser_snapshot", + "browser_get_images", + "browser_console", + "browser_cdp", + "browser_dialog", + "browser_vision", + -> R.string.chat_agent_status_browsing + + "terminal", + "execute_code", + "process", + "process_manage", + -> R.string.chat_agent_status_running_command + + else -> R.string.chat_agent_status_using_tool + } + +@Composable +internal fun AgentStatusIndicator( + status: AgentStatus, + modifier: Modifier = Modifier, +) { + when (status) { + AgentStatus.Typing -> { + TypingIndicator(modifier = modifier) + } + + AgentStatus.Thinking -> { + StatusText( + text = stringResource(R.string.chat_agent_status_thinking), + icon = null, + modifier = modifier.testTag("agent_status_thinking"), + ) + } + + is AgentStatus.Tool -> { + val config = ToolSchemaRegistry.getDisplayConfig(status.name) + val labelRes = toolStatusLabelRes(status.name) + StatusText( + text = stringResource(labelRes), + icon = config.icon, + modifier = modifier.testTag("agent_status_tool"), + ) + } + } +} + +@Composable +private fun StatusText( + text: String, + icon: ImageVector?, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (icon != null) { + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index f34a0fa5..b3df7430 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -1,7 +1,6 @@ package com.m57.hermescontrol.ui.chat.fullbleed import android.content.ClipData -import android.text.format.DateFormat import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -9,7 +8,6 @@ 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.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check @@ -29,10 +27,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.Attachment @@ -40,7 +36,6 @@ import com.m57.hermescontrol.ui.chat.ChatMessage import com.m57.hermescontrol.ui.chat.ImageViewerModel import com.m57.hermescontrol.ui.chat.InlineAttachment import com.m57.hermescontrol.ui.chat.MarkdownText -import com.m57.hermescontrol.ui.chat.TokenEstimator import com.m57.hermescontrol.ui.chat.components.ReasoningCard import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -50,17 +45,12 @@ import kotlinx.coroutines.launch * * Unlike [com.m57.hermescontrol.ui.chat.ChatBubble], agent prose renders * directly on the background — no bubble container, no width cap — with a - * turn header (role + time) and a trailing copy affordance. User messages - * keep their bubbles; this composable is only used for ASSISTANT messages. - * - * [showTurnHeader] renders the role/time header — true only for the first - * prose entry of an agent turn so multi-message turns don't repeat it. + * trailing copy affordance. User messages keep their bubbles; this composable + * is only used for ASSISTANT messages. */ @Composable internal fun FullBleedAgentMessage( message: ChatMessage, - showTurnHeader: Boolean, - isDarkTheme: Boolean, searchQuery: String = "", isCurrentMatch: Boolean = false, showReasoning: Boolean = true, @@ -92,10 +82,6 @@ internal fun FullBleedAgentMessage( .padding(horizontal = 16.dp) .testTag("fullbleed_agent_message"), ) { - if (showTurnHeader) { - AssistantTurnHeader(message.timestamp) - } - if (showReasoning && message.reasoningText.isNotBlank()) { ReasoningCard( reasoningText = message.reasoningText, @@ -105,10 +91,10 @@ internal fun FullBleedAgentMessage( } // Defense-in-depth: never render an empty prose block (blank bubble + - // lone Copy button). Blank settled rows are tool-call placeholders that - // slipped through upstream mapping; streaming keeps rendering so the - // live cursor survives until the first delta lands. - if (message.content.isNotBlank() || message.isStreaming) { + // lone Copy button). Blank rows are tool-call placeholders that slipped + // through upstream mapping; the parent list renders the live status + // indicator until the first visible delta lands. + if (message.content.isNotBlank()) { SelectionContainer { MarkdownText( text = message.content, @@ -140,7 +126,7 @@ internal fun FullBleedAgentMessage( } } - if (!message.isStreaming) { + if (!message.isStreaming && message.content.isNotBlank()) { Row( verticalAlignment = Alignment.CenterVertically, ) { @@ -160,97 +146,7 @@ internal fun FullBleedAgentMessage( tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Spacer(modifier = Modifier.width(4.dp)) - val finishTime = message.finishTimestamp ?: message.timestamp - Text( - text = - com.m57.hermescontrol.ui.chat.formatTimestamp( - finishTime, - DateFormat.is24HourFormat(LocalContext.current), - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.testTag("fullbleed_finish_time"), - ) - if (message.tokenCount != null && message.tokenCount > 0) { - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = "•", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = - stringResource( - R.string.chat_msg_tokens, - TokenEstimator.formatTokenCount(message.tokenCount), - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.testTag("fullbleed_token_count"), - ) - } - if (message.tps != null && message.tps > 0.0) { - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = "•", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = - stringResource( - R.string.chat_msg_tps, - TokenEstimator.formatTps(message.tps), - ), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - modifier = Modifier.testTag("fullbleed_tps"), - ) - } } } } } - -/** - * Agent turn header — "Agent ·