Merge pull request #1161 from Hy4ri/feat/1137-kanban-parity

feat(kanban): close mobile gaps with desktop parity (#1137)
This commit is contained in:
M57 authored and GitHub committed 2026-09-17 01:03:50 +03:00
commit 8007bd529d
43 files changed
+5632 -629

No files matched your search

@@ -0,0 +1,81 @@
package com.m57.hermescontrol.ui.kanban
import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class KanbanLifecycleTest {
@get:Rule
val compose = createComposeRule()
@Test
fun pendingExportSurvivesSavedStateRestoration() {
val restoration = StateRestorationTester(compose)
lateinit var path: MutableState<String?>
restoration.setContent {
path = rememberPendingKanbanExportPath()
}
compose.runOnIdle { path.value = "/cache/board.tar.gz" }
restoration.emulateSavedInstanceStateRestore()
compose.runOnIdle { assertEquals("/cache/board.tar.gz", path.value) }
}
@Test
fun tickerStopsInBackgroundRefreshesOnResumeAndStopsOnDisposal() {
lateinit var registry: LifecycleRegistry
val owner =
object : LifecycleOwner {
override val lifecycle: Lifecycle get() = registry
}
compose.runOnUiThread {
registry = LifecycleRegistry(owner)
registry.currentState = Lifecycle.State.STARTED
}
var now = 100L
val visible = mutableStateOf(true)
var displayed = 0L
compose.mainClock.autoAdvance = false
compose.setContent {
CompositionLocalProvider(LocalLifecycleOwner provides owner) {
if (visible.value) {
displayed = rememberKanbanNowSeconds(true) { now }
Text("$displayed")
}
}
}
compose.mainClock.advanceTimeBy(32)
compose.onNodeWithText("100").assertTextEquals("100")
compose.runOnUiThread { now = 105L }
compose.mainClock.advanceTimeBy(5_100)
compose.onNodeWithText("105").assertTextEquals("105")
compose.runOnUiThread {
registry.currentState = Lifecycle.State.CREATED
now = 200L
}
compose.mainClock.advanceTimeBy(10_100)
compose.onNodeWithText("105").assertTextEquals("105")
compose.runOnUiThread { registry.currentState = Lifecycle.State.STARTED }
compose.mainClock.advanceTimeBy(64)
compose.onNodeWithText("200").assertTextEquals("200")
compose.runOnUiThread { visible.value = false }
compose.mainClock.advanceTimeBy(64)
compose.runOnUiThread { now = 300L }
compose.mainClock.advanceTimeBy(10_100)
compose.runOnIdle { assertEquals(200L, displayed) }
}
}
@@ -0,0 +1,108 @@
package com.m57.hermescontrol.ui.kanban
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.ui.kanban.components.KanbanCreateTaskDialog
import com.m57.hermescontrol.ui.kanban.components.KanbanFilterSheet
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class KanbanScreenTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun testShowArchivedToggleInvokesCallback() {
var toggled = false
composeTestRule.setContent {
HermesControlTheme {
KanbanFilterSheet(
assignees = emptyList(),
tenants = emptyList(),
selectedAssignee = null,
selectedTenant = null,
includeArchived = false,
groupRunning = false,
onSelectAssignee = {},
onSelectTenant = {},
onToggleIncludeArchived = { toggled = it },
onToggleGroupRunning = {},
onClearFilters = {},
onDismiss = {},
)
}
}
composeTestRule.onNodeWithTag("kanban_show_archived").performClick()
assertTrue(toggled)
}
@Test
fun testWorkspacePathVisibilityInCreateTaskDialog() {
composeTestRule.setContent {
HermesControlTheme {
KanbanCreateTaskDialog(
columns = listOf(KanbanColumn(name = "todo")),
defaultColumn = "todo",
profiles = listOf(KanbanProfile(name = "researcher")),
existingTasks = emptyList(),
isCreating = false,
onDismiss = {},
onConfirm = { _, _ -> },
)
}
}
// Expand advanced options
composeTestRule.onNodeWithTag("kanban_advanced_options").performClick()
// By default scratch is selected, workspace path is not visible
composeTestRule.onNodeWithTag("kanban_workspace_path").assertDoesNotExist()
// Select Worktree
composeTestRule.onNodeWithTag("kanban_workspace_kinds").performScrollTo()
composeTestRule.onNodeWithTag("kanban_workspace_kind_worktree").performClick()
composeTestRule.onNodeWithTag("kanban_workspace_kind_worktree").assertIsSelected()
composeTestRule.onNodeWithTag("kanban_workspace_path").performScrollTo().assertIsDisplayed()
// Select Scratch again
composeTestRule.onNodeWithTag("kanban_workspace_kinds").performScrollTo()
composeTestRule.onNodeWithTag("kanban_workspace_kind_scratch").performClick()
composeTestRule.onNodeWithTag("kanban_workspace_path").assertDoesNotExist()
}
@Test
fun testDefaultAssigneeOptionPresent() {
composeTestRule.setContent {
HermesControlTheme {
KanbanCreateTaskDialog(
columns = listOf(KanbanColumn(name = "todo")),
defaultColumn = "todo",
profiles = listOf(KanbanProfile(name = "researcher")),
existingTasks = emptyList(),
isCreating = false,
defaultAssignee = "researcher",
onDismiss = {},
onConfirm = { _, _ -> },
)
}
}
composeTestRule.onNodeWithText("researcher (Default)").assertIsDisplayed()
}
}
@@ -12,6 +12,8 @@ interface KanbanPreferencesStore {
slug: String,
)
fun clearSelectedBoard(endpoint: String)
fun getViewMode(): String
fun setViewMode(mode: String)
@@ -45,6 +47,10 @@ class InMemoryKanbanPreferencesStore : KanbanPreferencesStore {
selectedBoards[endpoint] = slug
}
override fun clearSelectedBoard(endpoint: String) {
selectedBoards.remove(endpoint)
}
override fun getViewMode(): String = viewMode
override fun setViewMode(mode: String) {
@@ -92,3 +92,17 @@ data class DeleteBoardResponse(
val result: JsonElement? = null,
val current: String? = null,
)
@Serializable
data class ExportBoardBody(
val output: String = "",
val attachments: Boolean = true,
val logs: Boolean = false,
)
@Serializable
data class ImportBoardBody(
val archive: String,
val slug: String? = null,
val switch: Boolean = false,
)
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@@ -146,18 +147,18 @@ data class CreateTaskBody(
val assignee: String? = null,
val tenant: String? = null,
val priority: Int = 0,
val workspaceKind: String? = null,
val workspacePath: String? = null,
@SerialName("workspace_kind") val workspaceKind: String? = null,
@SerialName("workspace_path") val workspacePath: String? = null,
val parents: List<String> = emptyList(),
val triage: Boolean = false,
val maxRuntimeSeconds: Int? = null,
@SerialName("max_runtime_seconds") val maxRuntimeSeconds: Int? = null,
val skills: List<String>? = null,
val goalMode: Boolean = false,
val goalMaxTurns: Int? = null,
val modelOverride: String? = null,
val providerOverride: String? = null,
val reasoningEffort: String? = null,
val projectId: String? = null,
@SerialName("goal_mode") val goalMode: Boolean = false,
@SerialName("goal_max_turns") val goalMaxTurns: Int? = null,
@SerialName("model_override") val modelOverride: String? = null,
@SerialName("provider_override") val providerOverride: String? = null,
@SerialName("reasoning_effort") val reasoningEffort: String? = null,
@SerialName("project_id") val projectId: String? = null,
)
@Serializable
@@ -174,14 +175,14 @@ data class UpdateTaskBody(
val title: String? = null,
val body: String? = null,
val result: String? = null,
val blockReason: String? = null,
@SerialName("block_reason") val blockReason: String? = null,
val summary: String? = null,
val metadata: Map<String, JsonElement>? = null,
val modelOverride: String? = null,
val providerOverride: String? = null,
val clearModelOverride: Boolean = false,
val reasoningEffort: String? = null,
val clearReasoningEffort: Boolean = false,
@SerialName("model_override") val modelOverride: String? = null,
@SerialName("provider_override") val providerOverride: String? = null,
@SerialName("clear_model_override") val clearModelOverride: Boolean = false,
@SerialName("reasoning_effort") val reasoningEffort: String? = null,
@SerialName("clear_reasoning_effort") val clearReasoningEffort: Boolean = false,
)
@Serializable
@@ -199,12 +200,12 @@ data class BulkTasksBody(
val result: String? = null,
val summary: String? = null,
val metadata: Map<String, JsonElement>? = null,
val reclaimFirst: Boolean = false,
val modelOverride: String? = null,
val providerOverride: String? = null,
val clearModelOverride: Boolean = false,
val reasoningEffort: String? = null,
val clearReasoningEffort: Boolean = false,
@SerialName("reclaim_first") val reclaimFirst: Boolean = false,
@SerialName("model_override") val modelOverride: String? = null,
@SerialName("provider_override") val providerOverride: String? = null,
@SerialName("clear_model_override") val clearModelOverride: Boolean = false,
@SerialName("reasoning_effort") val reasoningEffort: String? = null,
@SerialName("clear_reasoning_effort") val clearReasoningEffort: Boolean = false,
)
@Serializable
@@ -252,4 +253,6 @@ data class TaskEstimate(
val complexity: String? = null,
val rationale: String? = null,
val model: String? = null,
)
) {
val tokens: Int? get() = estTokens
}
@@ -2,6 +2,8 @@ package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.model.AttachmentUploadResponse
import com.m57.hermescontrol.data.model.AutoDescribeResponse
import com.m57.hermescontrol.data.model.BoardExportResult
import com.m57.hermescontrol.data.model.BoardImportResult
import com.m57.hermescontrol.data.model.BulkTasksBody
import com.m57.hermescontrol.data.model.BulkTasksResponse
import com.m57.hermescontrol.data.model.CreateBoardBody
@@ -10,6 +12,8 @@ import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse
import com.m57.hermescontrol.data.model.DeleteBoardResponse
import com.m57.hermescontrol.data.model.DispatchResult
import com.m57.hermescontrol.data.model.ExportBoardBody
import com.m57.hermescontrol.data.model.ImportBoardBody
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
@@ -77,6 +81,17 @@ interface KanbanApiService {
@Path("slug") slug: String,
): Response<Unit>
@POST("api/plugins/kanban/boards/{slug}/export")
suspend fun exportBoard(
@Path("slug") slug: String,
@Body body: ExportBoardBody = ExportBoardBody(),
): Response<BoardExportResult>
@POST("api/plugins/kanban/boards/import")
suspend fun importBoard(
@Body body: ImportBoardBody,
): Response<BoardImportResult>
@GET("api/plugins/kanban/tasks/{id}")
suspend fun getTask(
@Path("id") taskId: String,
@@ -0,0 +1,35 @@
package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.remote.KanbanApiService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
interface KanbanDispatcherNudger {
fun scheduleNudge(board: String?)
}
class DefaultKanbanDispatcherNudger(
private val apiProvider: () -> KanbanApiService,
private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
private val debounceMs: Long = 400L,
) : KanbanDispatcherNudger {
private val lock = Any()
private val boardJobs = mutableMapOf<String?, Job>()
override fun scheduleNudge(board: String?) {
synchronized(lock) {
boardJobs[board]?.cancel()
boardJobs[board] =
scope.launch {
delay(debounceMs)
runCatching {
apiProvider().nudgeDispatcher(board)
}
}
}
}
}
@@ -2,6 +2,8 @@ package com.m57.hermescontrol.data.repository
import com.m57.hermescontrol.data.model.AttachmentUploadResponse
import com.m57.hermescontrol.data.model.AutoDescribeResponse
import com.m57.hermescontrol.data.model.BoardExportResult
import com.m57.hermescontrol.data.model.BoardImportResult
import com.m57.hermescontrol.data.model.BulkTasksBody
import com.m57.hermescontrol.data.model.BulkTasksResponse
import com.m57.hermescontrol.data.model.CreateBoardBody
@@ -10,6 +12,8 @@ import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse
import com.m57.hermescontrol.data.model.DeleteBoardResponse
import com.m57.hermescontrol.data.model.DispatchResult
import com.m57.hermescontrol.data.model.ExportBoardBody
import com.m57.hermescontrol.data.model.ImportBoardBody
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
@@ -58,6 +62,13 @@ interface KanbanRepository {
delete: Boolean = false,
): NetworkResult<DeleteBoardResponse>
suspend fun exportBoard(
slug: String,
body: ExportBoardBody = ExportBoardBody(),
): NetworkResult<BoardExportResult>
suspend fun importBoard(body: ImportBoardBody): NetworkResult<BoardImportResult>
suspend fun getTask(
taskId: String,
board: String?,
@@ -151,6 +162,7 @@ interface KanbanRepository {
class KanbanRepositoryImpl(
private val apiProvider: () -> KanbanApiService = { ApiClient.kanbanApi },
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
private val nudger: KanbanDispatcherNudger = DefaultKanbanDispatcherNudger(apiProvider),
) : KanbanRepository {
override suspend fun getBoards(includeArchived: Boolean): NetworkResult<KanbanBoardsResponse> =
withContext(ioDispatcher) {
@@ -187,6 +199,19 @@ class KanbanRepositoryImpl(
safeApiCall { apiProvider().deleteBoard(slug, delete) }
}
override suspend fun exportBoard(
slug: String,
body: ExportBoardBody,
): NetworkResult<BoardExportResult> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().exportBoard(slug, body) }
}
override suspend fun importBoard(body: ImportBoardBody): NetworkResult<BoardImportResult> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().importBoard(body) }
}
override suspend fun getTask(
taskId: String,
board: String?,
@@ -200,7 +225,11 @@ class KanbanRepositoryImpl(
body: CreateTaskBody,
): NetworkResult<CreateTaskResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().createTask(board = board, body = body) }
val result = safeApiCall { apiProvider().createTask(board = board, body = body) }
if (result is NetworkResult.Success) {
nudger.scheduleNudge(board)
}
result
}
override suspend fun updateTask(
@@ -209,7 +238,11 @@ class KanbanRepositoryImpl(
body: UpdateTaskBody,
): NetworkResult<UpdateTaskResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().updateTask(taskId = taskId, board = board, body = body) }
val result = safeApiCall { apiProvider().updateTask(taskId = taskId, board = board, body = body) }
if (result is NetworkResult.Success) {
nudger.scheduleNudge(board)
}
result
}
override suspend fun deleteTask(
@@ -217,7 +250,11 @@ class KanbanRepositoryImpl(
board: String?,
): NetworkResult<Unit> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().deleteTask(taskId = taskId, board = board) }
val result = safeApiCall { apiProvider().deleteTask(taskId = taskId, board = board) }
if (result is NetworkResult.Success) {
nudger.scheduleNudge(board)
}
result
}
override suspend fun bulkTasks(
@@ -225,7 +262,11 @@ class KanbanRepositoryImpl(
body: BulkTasksBody,
): NetworkResult<BulkTasksResponse> =
withContext(ioDispatcher) {
safeApiCall { apiProvider().bulkTasks(board = board, body = body) }
val result = safeApiCall { apiProvider().bulkTasks(board = board, body = body) }
if (result is NetworkResult.Success) {
nudger.scheduleNudge(board)
}
result
}
override suspend fun addComment(
@@ -250,13 +291,18 @@ class KanbanRepositoryImpl(
reclaimFirst: Boolean,
): NetworkResult<ReassignTaskResponse> =
withContext(ioDispatcher) {
safeApiCall {
apiProvider().reassignTask(
taskId = taskId,
board = board,
body = ReassignTaskBody(profile = profile, reclaimFirst = reclaimFirst),
)
val result =
safeApiCall {
apiProvider().reassignTask(
taskId = taskId,
board = board,
body = ReassignTaskBody(profile = profile, reclaimFirst = reclaimFirst),
)
}
if (result is NetworkResult.Success) {
nudger.scheduleNudge(board)
}
result
}
override suspend fun reclaimTask(
@@ -265,13 +311,18 @@ class KanbanRepositoryImpl(
reason: String?,
): NetworkResult<ReclaimTaskResponse> =
withContext(ioDispatcher) {
safeApiCall {
apiProvider().reclaimTask(
taskId = taskId,
board = board,
body = ReclaimTaskBody(reason = reason),
)
val result =
safeApiCall {
apiProvider().reclaimTask(
taskId = taskId,
board = board,
body = ReclaimTaskBody(reason = reason),
)
}
if (result is NetworkResult.Success) {
nudger.scheduleNudge(board)
}
result
}
override suspend fun getTaskLog(
@@ -0,0 +1,14 @@
package com.m57.hermescontrol.ui.kanban
object KanbanColumnCollapseHelper {
fun isColumnCollapsed(
columnName: String,
columnTaskCount: Int,
boardHasWork: Boolean,
overrides: Map<String, Boolean>,
): Boolean {
overrides[columnName]?.let { return it }
if (!boardHasWork) return false
return columnTaskCount == 0
}
}
@@ -0,0 +1,222 @@
package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.model.KanbanDetailEvent
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
enum class KanbanEventMessage {
CREATED,
CREATED_BY,
MOVED,
ASSIGNED,
UNASSIGNED,
COMMENTED,
CLAIMED_REVIEW,
CLAIMED_WORKER,
WORKER_STARTED,
COMPLETED,
BLOCKED,
UNBLOCKED,
RECLAIMED,
SPECIFIED,
PROMOTED,
SCHEDULED,
ARCHIVED,
PRIORITY,
UNKNOWN,
}
data class FormattedKanbanEvent(
val label: String,
val detail: String? = null,
val message: KanbanEventMessage = KanbanEventMessage.UNKNOWN,
val arguments: List<String> = emptyList(),
)
object KanbanEventFormatter {
private val json =
Json {
ignoreUnknownKeys = true
isLenient = true
}
fun format(event: KanbanDetailEvent): FormattedKanbanEvent {
val payloadMap = extractPayloadMap(event.payload)
fun str(key: String): String? {
val element = payloadMap[key] ?: return null
return when {
element is JsonPrimitive && element.isString -> element.content.takeIf { it.isNotBlank() }
element is JsonPrimitive -> element.content.takeIf { it.isNotBlank() }
else -> null
}
}
fun col(key: String): String? {
val raw = str(key) ?: return null
return when (raw.lowercase()) {
"triage" -> "Triage"
"todo" -> "Todo"
"scheduled" -> "Scheduled"
"ready" -> "Ready"
"running" -> "Running"
"blocked" -> "Blocked"
"review" -> "Review"
"done" -> "Done"
"archived" -> "Archived"
else -> raw.replaceFirstChar { it.uppercase() }
}
}
return when (event.kind.lowercase()) {
"created" -> {
val status = col("status") ?: "Todo"
val assignee = str("assignee")
val label = if (assignee != null) "Created in $status by $assignee" else "Created in $status"
FormattedKanbanEvent(
label = label,
message = if (assignee != null) KanbanEventMessage.CREATED_BY else KanbanEventMessage.CREATED,
arguments = listOfNotNull(status, assignee),
)
}
"status" -> {
val targetStatus = col("status") ?: "?"
val reason = str("reason")
val parent = str("parent")
val detail =
when (reason) {
"parent_reopened" -> if (parent != null) "Parent $parent reopened" else "Parent reopened"
else -> reason
}
FormattedKanbanEvent(
label = "Moved to $targetStatus",
detail = detail,
message = KanbanEventMessage.MOVED,
arguments = listOf(targetStatus),
)
}
"assigned" -> {
val assignee = str("assignee")
FormattedKanbanEvent(
label = if (assignee != null) "Assigned to $assignee" else "Unassigned",
message = if (assignee != null) KanbanEventMessage.ASSIGNED else KanbanEventMessage.UNASSIGNED,
arguments = listOfNotNull(assignee),
)
}
"commented" -> {
val author = str("author") ?: "someone"
FormattedKanbanEvent(
label = "Comment by $author",
message = KanbanEventMessage.COMMENTED,
arguments = listOf(author),
)
}
"claimed" -> {
val source = str("source_status")
FormattedKanbanEvent(
label = if (source == "review") "Claimed for review" else "Claimed by worker",
message =
if (source ==
"review"
) {
KanbanEventMessage.CLAIMED_REVIEW
} else {
KanbanEventMessage.CLAIMED_WORKER
},
)
}
"spawned" -> {
val pid = str("pid") ?: payloadMap["pid"]?.toString()
FormattedKanbanEvent(
label = "Worker started",
detail = if (pid != null && pid != "null") "PID $pid" else null,
message = KanbanEventMessage.WORKER_STARTED,
arguments = listOfNotNull(pid?.takeIf { it != "null" }),
)
}
"completed" -> {
FormattedKanbanEvent(label = "Completed", message = KanbanEventMessage.COMPLETED)
}
"blocked" -> {
val reason = str("reason")
FormattedKanbanEvent(label = "Blocked", detail = reason, message = KanbanEventMessage.BLOCKED)
}
"unblocked" -> {
val status = col("status")
val label = if (status != null) "Unblocked ($status)" else "Unblocked"
FormattedKanbanEvent(
label = label,
message = KanbanEventMessage.UNBLOCKED,
arguments = listOfNotNull(status),
)
}
"reclaimed" -> {
val reason = str("reason")
FormattedKanbanEvent(label = "Reclaimed", detail = reason, message = KanbanEventMessage.RECLAIMED)
}
"specified" -> {
FormattedKanbanEvent(label = "Specified", message = KanbanEventMessage.SPECIFIED)
}
"promoted" -> {
FormattedKanbanEvent(label = "Promoted to ready", message = KanbanEventMessage.PROMOTED)
}
"scheduled" -> {
val reason = str("reason")
FormattedKanbanEvent(label = "Scheduled", detail = reason, message = KanbanEventMessage.SCHEDULED)
}
"archived" -> {
FormattedKanbanEvent(label = "Archived", message = KanbanEventMessage.ARCHIVED)
}
"reprioritized" -> {
val priority = str("priority") ?: payloadMap["priority"]?.toString() ?: "?"
FormattedKanbanEvent(
label = "Priority set to $priority",
message = KanbanEventMessage.PRIORITY,
arguments = listOf(priority),
)
}
else -> {
val label = event.kind.replace('_', ' ').replaceFirstChar { it.uppercase() }
val detail =
payloadMap.entries
.filter { (_, v) -> v is JsonPrimitive }
.joinToString(" ") { (k, v) -> "$k=${(v as JsonPrimitive).content}" }
.ifBlank { null }
FormattedKanbanEvent(label = label, detail = detail)
}
}
}
private fun extractPayloadMap(element: JsonElement?): Map<String, JsonElement> {
if (element == null) return emptyMap()
if (element is JsonObject) return element
if (element is JsonPrimitive && element.isString) {
val content = element.content.trim()
if (content.startsWith("{") && content.endsWith("}")) {
runCatching {
val parsed = json.parseToJsonElement(content)
if (parsed is JsonObject) return parsed
}
}
return if (content.isNotEmpty()) mapOf("raw" to element) else emptyMap()
}
return emptyMap()
}
}
@@ -0,0 +1,10 @@
package com.m57.hermescontrol.ui.kanban
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
/** Keep the cache path while the external document picker recreates the Activity (#1137). */
@Composable
internal fun rememberPendingKanbanExportPath(): MutableState<String?> = rememberSaveable { mutableStateOf(null) }
@@ -0,0 +1,26 @@
package com.m57.hermescontrol.ui.kanban
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
/** Own only confirmed uploads; cancellation must not strand their temporary archive (#1137). */
internal suspend fun <T> withKanbanImportCleanup(
cleanup: suspend () -> Unit,
onCleanupFailure: (Exception) -> Unit,
dispatcher: CoroutineDispatcher = Dispatchers.IO,
operation: suspend () -> T,
): T =
try {
operation()
} finally {
withContext(NonCancellable + dispatcher) {
try {
withTimeout(10_000L) { cleanup() }
} catch (e: Exception) {
onCleanupFailure(e)
}
}
}
@@ -0,0 +1,26 @@
package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.model.KanbanTask
data class KanbanRunningGroup(
val assignee: String?,
val tasks: List<KanbanTask>,
)
object KanbanLaneGrouping {
fun groupRunningTasks(tasks: List<KanbanTask>): List<KanbanRunningGroup> {
val grouped = tasks.groupBy { it.assignee?.takeIf { a -> a.isNotBlank() } }
val namedGroups =
grouped
.filterKeys { it != null }
.entries
.sortedBy { it.key!!.lowercase() }
.map { KanbanRunningGroup(assignee = it.key, tasks = it.value) }
val unassignedTasks = grouped[null]
return if (!unassignedTasks.isNullOrEmpty()) {
namedGroups + KanbanRunningGroup(assignee = null, tasks = unassignedTasks)
} else {
namedGroups
}
}
}
@@ -0,0 +1,93 @@
package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.model.UpdateTaskBody
private val ALL_REASONING_EFFORTS = listOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
fun supportedKanbanReasoningEfforts(
supportsReasoning: Boolean,
canDisableReasoning: Boolean,
): List<String> =
if (!supportsReasoning) {
listOf("")
} else if (canDisableReasoning) {
ALL_REASONING_EFFORTS
} else {
ALL_REASONING_EFFORTS.filterNot { it == "none" }
}
/**
* Task model override matching desktop's TaskModelOverride contract:
* - model: model name or ID
* - provider: provider name or ID
* - effort: "" (inherit), "none" (explicitly disabled), or reasoning effort string ("low", "medium", "high")
*/
data class KanbanModelOverride(
val model: String = "",
val provider: String = "",
val effort: String = "",
) {
val isInherited: Boolean
get() = model.isBlank() && provider.isBlank() && effort.isBlank()
fun displayLabel(inheritLabel: String = "Inherit profile"): String {
if (isInherited) return inheritLabel
val trimmedModel = model.trim()
val trimmedProvider = provider.trim()
val trimmedEffort = effort.trim()
val base =
when {
trimmedModel.isNotEmpty() && trimmedProvider.isNotEmpty() -> "$trimmedProvider: $trimmedModel"
trimmedModel.isNotEmpty() -> trimmedModel
else -> inheritLabel
}
val effortPart =
when (trimmedEffort.lowercase()) {
"none" -> "None"
"low" -> "Low"
"medium" -> "Medium"
"high" -> "High"
"" -> ""
else -> trimmedEffort.replaceFirstChar { it.uppercase() }
}
return if (effortPart.isNotEmpty() && base != inheritLabel) {
"$base · $effortPart"
} else if (effortPart.isNotEmpty()) {
"Effort: $effortPart"
} else {
base
}
}
fun toUpdateTaskBody(): UpdateTaskBody {
val trimmedModel = model.trim()
val trimmedProvider = provider.trim()
val trimmedEffort = effort.trim()
return UpdateTaskBody(
modelOverride = trimmedModel.ifEmpty { null },
providerOverride = if (trimmedModel.isNotEmpty() && trimmedProvider.isNotEmpty()) trimmedProvider else null,
clearModelOverride = trimmedModel.isEmpty(),
reasoningEffort = trimmedEffort.ifEmpty { null },
clearReasoningEffort = trimmedEffort.isEmpty(),
)
}
companion object {
val EMPTY = KanbanModelOverride()
fun fromTask(
model: String?,
provider: String?,
effort: String?,
): KanbanModelOverride =
KanbanModelOverride(
model = model?.trim().orEmpty(),
provider = provider?.trim().orEmpty(),
effort = effort?.trim().orEmpty(),
)
}
}
@@ -0,0 +1,80 @@
package com.m57.hermescontrol.ui.kanban
import androidx.compose.runtime.Composable
import androidx.compose.runtime.produceState
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.m57.hermescontrol.data.model.KanbanTask
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
enum class KanbanArcState {
QUEUED,
RUNNING,
STALE,
}
object KanbanRuntimeHelper {
fun arcState(
task: KanbanTask,
fallbackAssignee: String? = null,
nowSeconds: Long = System.currentTimeMillis() / 1000L,
): KanbanArcState? {
val normalized = task.status.lowercase()
if (normalized == "running") {
val isStale = task.lastHeartbeatAt?.let { (nowSeconds - it) > 120L } ?: false
return if (isStale) KanbanArcState.STALE else KanbanArcState.RUNNING
}
val isQueued =
normalized == "triage" ||
normalized == "review" ||
(normalized == "ready" && (!task.assignee.isNullOrBlank() || !fallbackAssignee.isNullOrBlank()))
return if (isQueued) KanbanArcState.QUEUED else null
}
fun isWontRun(
task: KanbanTask,
fallbackAssignee: String? = null,
): Boolean =
task.status.equals("ready", ignoreCase = true) &&
task.assignee.isNullOrBlank() &&
fallbackAssignee.isNullOrBlank()
fun formatElapsed(
startSeconds: Long?,
nowSeconds: Long = System.currentTimeMillis() / 1000L,
): String? {
if (startSeconds == null || startSeconds <= 0L || nowSeconds < startSeconds) return null
val diff = nowSeconds - startSeconds
return when {
diff < 60L -> "${diff}s"
diff < 3600L -> "${diff / 60L}m"
diff < 86400L -> "${diff / 3600L}h"
else -> "${diff / 86400L}d"
}
}
}
/** One coarse clock per board screen, rather than one coroutine per task card. */
@Composable
fun rememberKanbanNowSeconds(
enabled: Boolean,
clock: () -> Long = { System.currentTimeMillis() / 1000L },
): Long {
val lifecycle = LocalLifecycleOwner.current.lifecycle
return produceState(
initialValue = clock(),
key1 = enabled,
key2 = lifecycle,
) {
if (enabled) {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
while (isActive) {
value = clock()
delay(5_000L)
}
}
}
}.value
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,15 @@
package com.m57.hermescontrol.ui.kanban
/**
* Shared constraints for Kanban target columns.
* Mirrors desktop's LOCKED_COLUMNS: system-owned columns that users can drag/move
* cards OUT of, but never manually target for moves or creation.
*/
object KanbanStatusConstraints {
val LOCKED_COLUMNS = setOf("running", "review", "scheduled")
fun isLockedTarget(status: String): Boolean = status.lowercase() in LOCKED_COLUMNS
fun isUserWritableTarget(status: String): Boolean =
!isLockedTarget(status) && !status.equals("archived", ignoreCase = true)
}
File diff suppressed because it is too large. Load diff
@@ -2,19 +2,28 @@ package com.m57.hermescontrol.ui.kanban
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.ReassignTaskBody
import com.m57.hermescontrol.data.model.ReclaimTaskBody
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.model.WorkerLog
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.repository.KanbanRepository
import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
import com.m57.hermescontrol.data.ws.KanbanEventsClient
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
@@ -24,43 +33,84 @@ data class KanbanTaskUiState(
val isLoading: Boolean = false,
val detail: KanbanTaskDetailResponse? = null,
val workerLog: WorkerLog? = null,
val profiles: List<KanbanProfile> = emptyList(),
val modelProviders: List<ModelProvider> = emptyList(),
val pinnedModels: List<PinnedModel> = emptyList(),
val estimate: TaskEstimate? = null,
val isEstimating: Boolean = false,
val isDeleting: Boolean = false,
val errorMessage: String? = null,
val toastMessage: String? = null,
val isSubmittingComment: Boolean = false,
val isSavingDescription: Boolean = false,
val isReclaiming: Boolean = false,
val isReassigning: Boolean = false,
val isUpdatingModel: Boolean = false,
val isUploadingAttachment: Boolean = false,
)
class KanbanTaskViewModel(
private val repository: KanbanRepository = KanbanRepositoryImpl(),
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
private val enableFallbackPolling: Boolean = true,
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(KanbanTaskUiState())
val uiState: StateFlow<KanbanTaskUiState> = _uiState.asStateFlow()
private var eventsClient: KanbanEventsClient? = null
private var eventsBoard: String? = null
private var activeTaskId: String? = null
private var currentLoadGen: Int = 0
private var reloadJob: Job? = null
private var logPollJob: Job? = null
private var detailPollJob: Job? = null
fun loadTask(
board: String,
taskId: String,
) {
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
val gen = ++currentLoadGen
reloadJob?.cancel()
stopLogPolling()
detailPollJob?.cancel()
activeTaskId = taskId
_uiState.update {
it.copy(
isLoading = true,
detail = null,
workerLog = null,
estimate = null,
errorMessage = null,
)
}
loadProfiles()
loadModelOptions()
connectEvents(board)
startDetailPolling(board, taskId)
viewModelScope.launch {
when (val result = repository.getTask(taskId = taskId, board = board)) {
is NetworkResult.Success -> {
if (gen != currentLoadGen) return@launch
val taskData = result.data
_uiState.update {
it.copy(
isLoading = false,
detail = result.data,
detail = taskData,
)
}
if (result.data.task.status
.equals("running", ignoreCase = true)
) {
if (taskData.task.status.equals("running", ignoreCase = true)) {
startLogPolling(board, taskId)
} else {
stopLogPolling()
loadLog(board, taskId)
}
}
is NetworkResult.Failure -> {
if (gen != currentLoadGen) return@launch
stopLogPolling()
_uiState.update {
it.copy(
isLoading = false,
@@ -72,19 +122,147 @@ class KanbanTaskViewModel(
}
}
fun loadProfiles() {
viewModelScope.launch {
when (val result = repository.getProfiles()) {
is NetworkResult.Success -> {
_uiState.update { it.copy(profiles = result.data.profiles) }
}
is NetworkResult.Failure -> {
// Failures to read profiles don't block detail
}
}
}
}
fun loadModelOptions() {
viewModelScope.launch {
when (val res = safeApiCall { ApiClient.hermesApi.getModelOptions() }) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
modelProviders = res.data.providers,
pinnedModels = AuthManager.getPinnedModels(),
)
}
}
is NetworkResult.Failure -> {
// Failures to read models don't block detail
}
}
}
}
fun loadLog(
board: String,
taskId: String,
) {
viewModelScope.launch {
when (val result = repository.getTaskLog(taskId = taskId, board = board)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(workerLog = result.data) }
}
fetchLogInternal(board, taskId)
}
}
is NetworkResult.Failure -> {
// Failures to read log don't blank the task screen
private suspend fun fetchLogInternal(
board: String,
taskId: String,
) {
when (val result = repository.getTaskLog(taskId = taskId, board = board)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(workerLog = result.data) }
}
is NetworkResult.Failure -> {
// Failures to read log don't blank the task screen
}
}
}
private fun startLogPolling(
board: String,
taskId: String,
) {
logPollJob?.cancel()
logPollJob =
viewModelScope.launch {
fetchLogInternal(board, taskId)
val isRunning = {
_uiState.value.detail
?.task
?.status
?.equals("running", ignoreCase = true) == true
}
while (isActive && isRunning()) {
delay(POLL_INTERVAL_MS)
fetchLogInternal(board, taskId)
}
}
}
private fun stopLogPolling() {
logPollJob?.cancel()
logPollJob = null
}
private fun startDetailPolling(
board: String,
taskId: String,
) {
if (!enableFallbackPolling) return
detailPollJob =
viewModelScope.launch {
while (isActive) {
delay(DETAIL_POLL_INTERVAL_MS)
loadTaskSilently(board, taskId)
}
}
}
private fun connectEvents(board: String) {
if (eventsBoard == board) return
eventsClient?.disconnect()
eventsBoard = board
val client = eventsClient ?: eventsClientProvider().also { eventsClient = it }
client.connect(
scope = viewModelScope,
board = board,
onEvents = { envelope ->
val current = activeTaskId
if (current != null && envelope.events.any { it.taskId == current }) {
scheduleTaskReload(board, current)
}
},
onStatus = {},
)
}
private fun scheduleTaskReload(
board: String,
taskId: String,
) {
reloadJob?.cancel()
reloadJob =
viewModelScope.launch {
delay(RELOAD_DEBOUNCE_MS)
loadTaskSilently(board, taskId)
}
}
private suspend fun loadTaskSilently(
board: String,
taskId: String,
) {
val gen = currentLoadGen
val result = repository.getTask(taskId = taskId, board = board)
if (gen != currentLoadGen || taskId != activeTaskId) return
if (result is NetworkResult.Success) {
val taskData = result.data
_uiState.update { it.copy(detail = taskData) }
if (taskData.task.status.equals("running", ignoreCase = true)) {
if (logPollJob == null) startLogPolling(board, taskId)
} else {
stopLogPolling()
}
}
}
@@ -128,6 +306,117 @@ class KanbanTaskViewModel(
}
}
fun reassign(
board: String,
taskId: String,
profile: String?,
) {
_uiState.update { it.copy(isReassigning = true) }
viewModelScope.launch {
when (
val result =
repository.reassignTask(
taskId = taskId,
board = board,
profile = profile,
reclaimFirst = true,
)
) {
is NetworkResult.Success -> {
val msg = if (profile != null) "Task reassigned to $profile" else "Task unassigned"
_uiState.update { it.copy(isReassigning = false, toastMessage = msg) }
loadTask(board, taskId)
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(isReassigning = false, toastMessage = "Failed to reassign: ${result.error.message}")
}
}
}
}
}
fun updateModelOverride(
board: String,
taskId: String,
override: KanbanModelOverride,
) {
_uiState.update { it.copy(isUpdatingModel = true) }
viewModelScope.launch {
when (val res = repository.updateTask(taskId = taskId, board = board, body = override.toUpdateTaskBody())) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(isUpdatingModel = false, toastMessage = "Model override updated")
}
loadTask(board, taskId)
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(isUpdatingModel = false, toastMessage = "Failed to update model: ${res.error.message}")
}
}
}
}
}
fun estimateTask(
board: String,
taskId: String,
) {
if (_uiState.value.isEstimating) return
_uiState.update { it.copy(isEstimating = true) }
viewModelScope.launch {
when (val res = repository.estimateTask(taskId = taskId, board = board)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isEstimating = false,
estimate = res.data,
toastMessage = "Estimate completed",
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isEstimating = false,
toastMessage = "Estimation failed: ${res.error.message}",
)
}
}
}
}
}
fun deleteTask(
board: String,
taskId: String,
onSuccess: () -> Unit,
) {
if (_uiState.value.isDeleting) return
_uiState.update { it.copy(isDeleting = true) }
viewModelScope.launch {
when (val res = repository.deleteTask(taskId = taskId, board = board)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(isDeleting = false, toastMessage = "Task deleted") }
onSuccess()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isDeleting = false,
toastMessage = "Failed to delete task: ${res.error.message}",
)
}
}
}
}
}
fun addComment(
board: String,
taskId: String,
@@ -232,27 +521,6 @@ class KanbanTaskViewModel(
}
}
fun reassign(
board: String,
taskId: String,
profile: String,
) {
viewModelScope.launch {
when (val result = repository.reassignTask(taskId = taskId, board = board, profile = profile)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Task reassigned to $profile") }
loadTask(board, taskId)
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(toastMessage = "Failed to reassign: ${result.error.message}")
}
}
}
}
}
fun uploadAttachment(
board: String,
taskId: String,
@@ -295,7 +563,21 @@ class KanbanTaskViewModel(
}
}
public override fun onCleared() {
stopLogPolling()
detailPollJob?.cancel()
reloadJob?.cancel()
eventsClient?.disconnect()
super.onCleared()
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
private companion object {
const val RELOAD_DEBOUNCE_MS = 250L
const val POLL_INTERVAL_MS = 3000L
const val DETAIL_POLL_INTERVAL_MS = 30_000L
}
}
@@ -5,17 +5,28 @@ import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore
import com.m57.hermescontrol.data.local.KanbanPreferencesStore
import com.m57.hermescontrol.data.model.BoardExportResult
import com.m57.hermescontrol.data.model.BoardImportResult
import com.m57.hermescontrol.data.model.BulkTasksBody
import com.m57.hermescontrol.data.model.CreateBoardBody
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.ExportBoardBody
import com.m57.hermescontrol.data.model.ImportBoardBody
import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanProject
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.OrchestrationSettings
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.RenameBoardBody
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.repository.KanbanRepository
import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
import com.m57.hermescontrol.data.ws.KanbanEventsClient
@@ -38,7 +49,14 @@ data class KanbanUiState(
val columns: List<KanbanColumn> = emptyList(),
val tasks: List<KanbanTask> = emptyList(),
val profiles: List<KanbanProfile> = emptyList(),
val projects: List<KanbanProject> = emptyList(),
val orchestration: OrchestrationSettings? = null,
val modelProviders: List<ModelProvider> = emptyList(),
val pinnedModels: List<PinnedModel> = emptyList(),
val isLive: Boolean = false,
val includeArchived: Boolean = false,
val groupRunning: Boolean = false,
val columnCollapseOverrides: Map<String, Boolean> = emptyMap(),
val errorMessage: String? = null,
val toastMessage: String? = null,
)
@@ -131,7 +149,13 @@ class KanbanViewModel(
},
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(KanbanUiState())
private val _uiState =
MutableStateFlow(
KanbanUiState(
includeArchived = preferences.getIncludeArchived(),
groupRunning = preferences.getGroupRunning(),
),
)
val uiState: StateFlow<KanbanUiState> = _uiState.asStateFlow()
private var eventsClient: KanbanEventsClient? = null
@@ -146,14 +170,18 @@ class KanbanViewModel(
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
loadProfiles()
loadProjects()
loadOrchestration()
loadModelOptions()
viewModelScope.launch {
when (val result = repository.getBoards()) {
when (val result = repository.getBoards(includeArchived = false)) {
is NetworkResult.Success -> {
val boards = result.data.boards
_uiState.update { it.copy(isLoading = false, boards = boards) }
val targetBoard =
boards.find { it.id == previouslySelectedId }
?: boards.find { it.id == result.data.current }
?: boards.find { it.id.equals("default", ignoreCase = true) }
?: boards.firstOrNull()
if (targetBoard != null) {
selectBoard(targetBoard)
@@ -186,6 +214,40 @@ class KanbanViewModel(
}
}
fun setIncludeArchived(include: Boolean) {
preferences.setIncludeArchived(include)
_uiState.update { it.copy(includeArchived = include) }
val board = _uiState.value.selectedBoard ?: return
val gen = ++currentLoadGen
viewModelScope.launch {
loadBoardIntoState(board, gen)
}
}
fun setGroupRunning(group: Boolean) {
preferences.setGroupRunning(group)
_uiState.update { it.copy(groupRunning = group) }
}
fun toggleColumnCollapse(
columnName: String,
boardHasWork: Boolean,
currentCount: Int,
) {
_uiState.update { state ->
val currentlyCollapsed =
KanbanColumnCollapseHelper.isColumnCollapsed(
columnName = columnName,
columnTaskCount = currentCount,
boardHasWork = boardHasWork,
overrides = state.columnCollapseOverrides,
)
state.copy(
columnCollapseOverrides = state.columnCollapseOverrides + (columnName to !currentlyCollapsed),
)
}
}
fun loadProfiles() {
viewModelScope.launch {
when (val result = repository.getProfiles()) {
@@ -200,6 +262,123 @@ class KanbanViewModel(
}
}
fun loadProjects() {
viewModelScope.launch {
when (val result = repository.getProjects()) {
is NetworkResult.Success -> {
_uiState.update { it.copy(projects = result.data.projects) }
}
is NetworkResult.Failure -> {
// Do not block UI if projects fetch fails
}
}
}
}
fun loadOrchestration() {
viewModelScope.launch {
when (val result = repository.getOrchestration()) {
is NetworkResult.Success -> {
_uiState.update { it.copy(orchestration = result.data) }
}
is NetworkResult.Failure -> {
// Do not block UI if orchestration fetch fails
}
}
}
}
fun loadModelOptions() {
viewModelScope.launch {
when (val res = safeApiCall { ApiClient.hermesApi.getModelOptions() }) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
modelProviders = res.data.providers,
pinnedModels = AuthManager.getPinnedModels(),
)
}
}
is NetworkResult.Failure -> {
// Do not block UI if model options fetch fails
}
}
}
}
fun updateOrchestration(body: OrchestrationSettingsUpdate) {
viewModelScope.launch {
when (val res = repository.updateOrchestration(body)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
orchestration = res.data,
toastMessage = "Orchestration settings saved",
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(toastMessage = "Failed to save orchestration: ${res.error.message}")
}
}
}
}
}
fun updateProfileDescription(
name: String,
description: String,
) {
viewModelScope.launch {
when (val res = repository.updateProfileDescription(name, description)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Profile description saved") }
loadProfiles()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(toastMessage = "Failed to save profile description: ${res.error.message}")
}
}
}
}
}
fun autoDescribeProfile(
name: String,
onComplete: ((String?) -> Unit)? = null,
) {
viewModelScope.launch {
when (val res = repository.autoDescribeProfile(name)) {
is NetworkResult.Success -> {
if (res.data.ok) {
_uiState.update { it.copy(toastMessage = "Profile auto-described") }
loadProfiles()
onComplete?.invoke(res.data.description)
} else {
_uiState.update {
it.copy(toastMessage = res.data.reason ?: "Auto-describe failed")
}
onComplete?.invoke(null)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(toastMessage = "Failed to auto-describe: ${res.error.message}")
}
onComplete?.invoke(null)
}
}
}
}
fun createTask(
body: CreateTaskBody,
targetStatus: String = "todo",
@@ -254,11 +433,16 @@ class KanbanViewModel(
description: String?,
status: String = "todo",
) {
val defaultAssignee =
_uiState.value.orchestration
?.resolvedDefaultAssignee
?.ifBlank { null } ?: "default"
createTask(
body =
CreateTaskBody(
title = title,
body = description,
assignee = defaultAssignee,
),
targetStatus = status,
)
@@ -277,6 +461,7 @@ class KanbanViewModel(
slug: String,
name: String? = null,
description: String? = null,
projectId: String? = null,
) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
@@ -285,6 +470,7 @@ class KanbanViewModel(
slug = slug,
name = name,
description = description,
projectId = projectId?.ifBlank { null },
)
when (val res = repository.createBoard(body)) {
is NetworkResult.Success -> {
@@ -305,16 +491,16 @@ class KanbanViewModel(
}
}
fun renameBoard(
fun updateBoard(
slug: String,
newName: String,
body: RenameBoardBody,
) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.updateBoard(slug, RenameBoardBody(name = newName))) {
when (val res = repository.updateBoard(slug, body)) {
is NetworkResult.Success -> {
val boardName = res.data.board?.name ?: newName
_uiState.update { it.copy(toastMessage = "Board renamed to $boardName") }
val boardName = res.data.board?.displayName ?: slug
_uiState.update { it.copy(toastMessage = "Board updated: $boardName") }
loadBoards()
}
@@ -322,7 +508,7 @@ class KanbanViewModel(
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Failed to rename board: ${res.error.message}",
toastMessage = "Failed to update board: ${res.error.message}",
)
}
}
@@ -330,12 +516,25 @@ class KanbanViewModel(
}
}
fun renameBoard(
slug: String,
newName: String,
) {
updateBoard(slug, RenameBoardBody(name = newName))
}
fun deleteBoard(slug: String) {
if (slug.equals("default", ignoreCase = true)) {
_uiState.update { it.copy(toastMessage = "Cannot delete or archive the default board") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.deleteBoard(slug, delete = true)) {
when (val res = repository.deleteBoard(slug, delete = false)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Board deleted") }
val endpoint = endpointProvider()
preferences.clearSelectedBoard(endpoint)
_uiState.update { it.copy(selectedBoard = null, toastMessage = "Board archived") }
loadBoards()
}
@@ -343,7 +542,29 @@ class KanbanViewModel(
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Failed to delete board: ${res.error.message}",
toastMessage = "Failed to archive board: ${res.error.message}",
)
}
}
}
}
}
fun deleteTask(taskId: String) {
val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.deleteTask(taskId = taskId, board = board.id)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Task deleted") }
reloadBoardSilently()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Failed to delete task: ${res.error.message}",
)
}
}
@@ -354,15 +575,26 @@ class KanbanViewModel(
fun bulkMove(
taskIds: List<String>,
targetStatus: String,
onComplete: ((failedIds: Set<String>) -> Unit)? = null,
) {
val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, status = targetStatus))) {
is NetworkResult.Success -> {
val count = res.data.results.count { it.ok }
_uiState.update { it.copy(toastMessage = "Moved $count tasks to $targetStatus") }
val results = res.data.results
val succeeded = results.filter { it.ok }.map { it.id }.toSet()
val failed = results.filter { !it.ok }.map { it.id }.toSet()
val firstError = results.firstOrNull { !it.ok }?.error
val msg =
when {
failed.isEmpty() -> "Moved ${succeeded.size} tasks to $targetStatus"
succeeded.isEmpty() -> "Failed to move tasks: ${firstError ?: "unknown error"}"
else -> "Moved ${succeeded.size} tasks, ${failed.size} failed: ${firstError ?: ""}"
}
_uiState.update { it.copy(toastMessage = msg) }
reloadBoardSilently()
onComplete?.invoke(failed)
}
is NetworkResult.Failure -> {
@@ -372,20 +604,34 @@ class KanbanViewModel(
toastMessage = "Bulk move failed: ${res.error.message}",
)
}
onComplete?.invoke(taskIds.toSet())
}
}
}
}
fun bulkArchive(taskIds: List<String>) {
fun bulkArchive(
taskIds: List<String>,
onComplete: ((failedIds: Set<String>) -> Unit)? = null,
) {
val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, archive = true))) {
is NetworkResult.Success -> {
val count = res.data.results.count { it.ok }
_uiState.update { it.copy(toastMessage = "Archived $count tasks") }
val results = res.data.results
val succeeded = results.filter { it.ok }.map { it.id }.toSet()
val failed = results.filter { !it.ok }.map { it.id }.toSet()
val firstError = results.firstOrNull { !it.ok }?.error
val msg =
when {
failed.isEmpty() -> "Archived ${succeeded.size} tasks"
succeeded.isEmpty() -> "Failed to archive tasks: ${firstError ?: "unknown error"}"
else -> "Archived ${succeeded.size} tasks, ${failed.size} failed: ${firstError ?: ""}"
}
_uiState.update { it.copy(toastMessage = msg) }
reloadBoardSilently()
onComplete?.invoke(failed)
}
is NetworkResult.Failure -> {
@@ -395,6 +641,7 @@ class KanbanViewModel(
toastMessage = "Bulk archive failed: ${res.error.message}",
)
}
onComplete?.invoke(taskIds.toSet())
}
}
}
@@ -403,15 +650,33 @@ class KanbanViewModel(
fun bulkAssign(
taskIds: List<String>,
assignee: String?,
onComplete: ((failedIds: Set<String>) -> Unit)? = null,
) {
val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, assignee = assignee))) {
val body =
BulkTasksBody(
ids = taskIds,
assignee = assignee ?: "",
reclaimFirst = true,
)
when (val res = repository.bulkTasks(board.id, body)) {
is NetworkResult.Success -> {
val count = res.data.results.count { it.ok }
_uiState.update { it.copy(toastMessage = "Assigned $count tasks") }
val results = res.data.results
val succeeded = results.filter { it.ok }.map { it.id }.toSet()
val failed = results.filter { !it.ok }.map { it.id }.toSet()
val firstError = results.firstOrNull { !it.ok }?.error
val actionName = if (!assignee.isNullOrBlank()) "Assigned" else "Unassigned"
val msg =
when {
failed.isEmpty() -> "$actionName ${succeeded.size} tasks"
succeeded.isEmpty() -> "Failed to update assignment: ${firstError ?: "unknown error"}"
else -> "$actionName ${succeeded.size} tasks, ${failed.size} failed: ${firstError ?: ""}"
}
_uiState.update { it.copy(toastMessage = msg) }
reloadBoardSilently()
onComplete?.invoke(failed)
}
is NetworkResult.Failure -> {
@@ -421,11 +686,54 @@ class KanbanViewModel(
toastMessage = "Bulk assign failed: ${res.error.message}",
)
}
onComplete?.invoke(taskIds.toSet())
}
}
}
}
fun bulkDelete(
taskIds: List<String>,
onComplete: ((failedIds: Set<String>) -> Unit)? = null,
) {
val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val succeeded = mutableSetOf<String>()
val failed = mutableSetOf<String>()
var firstError: String? = null
taskIds.forEach { taskId ->
when (val res = repository.deleteTask(taskId = taskId, board = board.id)) {
is NetworkResult.Success -> {
succeeded.add(taskId)
}
is NetworkResult.Failure -> {
failed.add(taskId)
if (firstError == null) firstError = res.error.message
}
}
}
val msg =
when {
failed.isEmpty() -> "Deleted ${succeeded.size} tasks"
succeeded.isEmpty() -> "Failed to delete tasks: ${firstError ?: "unknown error"}"
else -> "Deleted ${succeeded.size} tasks, ${failed.size} failed: ${firstError ?: ""}"
}
_uiState.update { it.copy(toastMessage = msg) }
reloadBoardSilently()
onComplete?.invoke(failed)
}
}
suspend fun exportBoard(slug: String): NetworkResult<BoardExportResult> =
repository.exportBoard(slug, ExportBoardBody(output = ""))
suspend fun importBoard(serverArchive: String): NetworkResult<BoardImportResult> =
repository.importBoard(ImportBoardBody(archive = serverArchive))
fun moveTask(
task: KanbanTask,
action: KanbanTaskAction,
@@ -576,7 +884,11 @@ class KanbanViewModel(
val board = _uiState.value.selectedBoard ?: return
val gen = currentLoadGen
viewModelScope.launch {
val result = repository.getBoard(board = board.id)
val result =
repository.getBoard(
board = board.id,
includeArchived = _uiState.value.includeArchived,
)
if (gen != currentLoadGen) return@launch
if (result is NetworkResult.Success) {
val body = result.data
@@ -598,7 +910,11 @@ class KanbanViewModel(
board: KanbanBoard,
gen: Int = currentLoadGen,
) {
val result = repository.getBoard(board = board.id)
val result =
repository.getBoard(
board = board.id,
includeArchived = _uiState.value.includeArchived,
)
if (gen != currentLoadGen) return
when (result) {
is NetworkResult.Success -> {
@@ -632,6 +948,10 @@ class KanbanViewModel(
}
}
fun showToast(message: String) {
_uiState.update { it.copy(toastMessage = message) }
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
@@ -0,0 +1,155 @@
package com.m57.hermescontrol.ui.kanban.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanProject
import com.m57.hermescontrol.data.model.RenameBoardBody
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KanbanBoardSettingsDialog(
board: KanbanBoard,
projects: List<KanbanProject>,
onSave: (RenameBoardBody) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
var name by remember(board) { mutableStateOf(board.name ?: "") }
var description by remember(board) { mutableStateOf(board.description ?: "") }
var defaultWorkdir by remember(board) { mutableStateOf(board.defaultWorkdir ?: "") }
var selectedProjectId by remember(board) { mutableStateOf(board.projectId) }
var projectExpanded by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
modifier = modifier,
title = { Text(stringResource(R.string.kanban_board_settings)) },
text = {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text(stringResource(R.string.kanban_board_name)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text(stringResource(R.string.kanban_description)) },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
)
// Project Selector
ExposedDropdownMenuBox(
expanded = projectExpanded,
onExpandedChange = { projectExpanded = it },
) {
val displayProject =
projects.find { it.id == selectedProjectId }?.name
?: stringResource(R.string.kanban_no_project)
OutlinedTextField(
value = displayProject,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.kanban_project)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = projectExpanded) },
modifier =
Modifier
.menuAnchor(
ExposedDropdownMenuAnchorType.PrimaryNotEditable,
).fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = projectExpanded,
onDismissRequest = { projectExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_no_project)) },
onClick = {
selectedProjectId = null
projectExpanded = false
},
)
projects.forEach { proj ->
DropdownMenuItem(
text = { Text(proj.name) },
onClick = {
selectedProjectId = proj.id
projectExpanded = false
},
)
}
}
}
OutlinedTextField(
value = defaultWorkdir,
onValueChange = { defaultWorkdir = it },
label = { Text(stringResource(R.string.kanban_default_workdir)) },
placeholder = { Text(stringResource(R.string.kanban_workdir_example)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
},
confirmButton = {
Button(
onClick = {
onSave(
RenameBoardBody(
name = name.trim().ifBlank { null },
description = description.trim().ifBlank { null },
defaultWorkdir = defaultWorkdir.trim().ifBlank { null },
projectId = selectedProjectId ?: "",
),
)
onDismiss()
},
enabled = name.isNotBlank(),
) {
Text(stringResource(R.string.action_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
@@ -29,6 +29,7 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -42,6 +43,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
@@ -49,8 +51,12 @@ import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState
import com.m57.hermescontrol.ui.kanban.KanbanModelOverride
import com.m57.hermescontrol.ui.kanban.KanbanStatusConstraints
import kotlinx.coroutines.launch
private const val PARKED_VALUE = "__parked__"
@@ -66,13 +72,20 @@ fun KanbanCreateTaskDialog(
onDismiss: () -> Unit,
onConfirm: (body: CreateTaskBody, targetStatus: String) -> Unit,
modifier: Modifier = Modifier,
defaultAssignee: String? = null,
boardDefaultWorkspaceKind: String? = null,
boardDefaultWorkdir: String? = null,
modelProviders: List<ModelProvider> = emptyList(),
pinnedModels: List<PinnedModel> = emptyList(),
onEstimate: (suspend (title: String, body: String?) -> TaskEstimate?)? = null,
) {
var title by remember { mutableStateOf("") }
var desc by remember { mutableStateOf("") }
val descTextFieldState = rememberSyncedTextFieldState(desc) { desc = it }
var selectedColumn by remember(defaultColumn) { mutableStateOf(defaultColumn) }
var selectedAssignee by remember { mutableStateOf<String?>(null) }
var selectedAssignee by remember(defaultAssignee) {
mutableStateOf(defaultAssignee.orEmpty())
}
var priority by remember { mutableIntStateOf(0) }
var goalMode by remember { mutableStateOf(false) }
@@ -84,16 +97,22 @@ fun KanbanCreateTaskDialog(
// Advanced options
var showAdvanced by remember { mutableStateOf(false) }
var parentTaskId by remember { mutableStateOf<String?>(null) }
var modelOverride by remember { mutableStateOf("") }
var providerOverride by remember { mutableStateOf("") }
var reasoningEffort by remember { mutableStateOf<String?>(null) }
var workspaceKind by remember { mutableStateOf<String?>(null) }
var modelOverride by remember { mutableStateOf(KanbanModelOverride.EMPTY) }
var workspaceKind by remember(boardDefaultWorkspaceKind) {
mutableStateOf(boardDefaultWorkspaceKind ?: "scratch")
}
var workspacePath by remember { mutableStateOf("") }
var skillsText by remember { mutableStateOf("") }
var assigneeExpanded by remember { mutableStateOf(false) }
var columnExpanded by remember { mutableStateOf(false) }
var parentExpanded by remember { mutableStateOf(false) }
val writableColumns =
remember(columns) {
columns.filter { KanbanStatusConstraints.isUserWritableTarget(it.name) }
}
AlertDialog(
onDismissRequest = { if (!isCreating) onDismiss() },
modifier = modifier,
@@ -127,7 +146,7 @@ fun KanbanCreateTaskDialog(
Spacer(modifier = Modifier.height(12.dp))
// Target Column selector
if (columns.isNotEmpty()) {
if (writableColumns.isNotEmpty()) {
ExposedDropdownMenuBox(
expanded = columnExpanded,
onExpandedChange = { if (!isCreating) columnExpanded = it },
@@ -149,7 +168,7 @@ fun KanbanCreateTaskDialog(
expanded = columnExpanded,
onDismissRequest = { columnExpanded = false },
) {
columns.forEach { col ->
writableColumns.forEach { col ->
DropdownMenuItem(
text = { Text(col.name.uppercase()) },
onClick = {
@@ -169,10 +188,22 @@ fun KanbanCreateTaskDialog(
onExpandedChange = { if (!isCreating) assigneeExpanded = it },
) {
val displayAssignee =
when (selectedAssignee) {
null -> stringResource(R.string.kanban_assignee_unassigned)
PARKED_VALUE -> stringResource(R.string.kanban_assignee_parked)
else -> selectedAssignee ?: ""
when {
selectedAssignee == PARKED_VALUE -> {
stringResource(R.string.kanban_assignee_parked)
}
selectedAssignee == defaultAssignee && !defaultAssignee.isNullOrBlank() -> {
"$defaultAssignee (Default)"
}
selectedAssignee.isNullOrBlank() -> {
stringResource(R.string.kanban_assignee_unassigned)
}
else -> {
selectedAssignee
}
}
OutlinedTextField(
value = displayAssignee,
@@ -187,18 +218,18 @@ fun KanbanCreateTaskDialog(
expanded = assigneeExpanded,
onDismissRequest = { assigneeExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_assignee_unassigned)) },
onClick = {
selectedAssignee = null
assigneeExpanded = false
},
)
profiles.forEach { profile ->
val isDef = profile.name == defaultAssignee
DropdownMenuItem(
text = {
Column {
Text(profile.name)
Text(
if (isDef) {
stringResource(R.string.kanban_assignee_default_suffix, profile.name)
} else {
profile.name
},
)
if (profile.model != null) {
Text(
profile.model,
@@ -258,11 +289,7 @@ fun KanbanCreateTaskDialog(
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier =
Modifier
.fillMaxWidth()
.clickable(enabled = !isCreating) { goalMode = !goalMode }
.padding(vertical = 4.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
@@ -271,11 +298,10 @@ fun KanbanCreateTaskDialog(
)
Text(
text = stringResource(R.string.kanban_goal_mode_desc),
style = MaterialTheme.typography.labelSmall,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.width(8.dp))
Switch(
checked = goalMode,
onCheckedChange = { goalMode = it },
@@ -286,34 +312,43 @@ fun KanbanCreateTaskDialog(
Spacer(modifier = Modifier.height(8.dp))
// Advanced Options Toggle
TextButton(
onClick = { showAdvanced = !showAdvanced },
modifier = Modifier.align(Alignment.Start),
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable { showAdvanced = !showAdvanced }
.testTag("kanban_advanced_options")
.padding(vertical = 8.dp),
) {
Text(
text = stringResource(R.string.kanban_advanced_options),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
)
Icon(
imageVector = if (showAdvanced) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.kanban_advanced_options))
}
AnimatedVisibility(visible = showAdvanced) {
Column(modifier = Modifier.fillMaxWidth()) {
Column {
// Parent Task
if (existingTasks.isNotEmpty()) {
ExposedDropdownMenuBox(
expanded = parentExpanded,
onExpandedChange = { if (!isCreating) parentExpanded = it },
) {
val displayParent =
existingTasks.find { it.id == parentTaskId }?.let {
"#${it.id.removePrefix("t_").take(6)} • ${it.title}"
val parentDisplay =
parentTaskId?.let { pid ->
existingTasks.find { it.id == pid }?.title ?: pid
} ?: stringResource(R.string.kanban_parent_none)
OutlinedTextField(
value = displayParent,
value = parentDisplay,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.kanban_parent_task)) },
@@ -342,7 +377,12 @@ fun KanbanCreateTaskDialog(
)
existingTasks.forEach { task ->
DropdownMenuItem(
text = { Text("#${task.id.removePrefix("t_").take(6)} • ${task.title}") },
text = {
Text(
task.title,
maxLines = 1,
)
},
onClick = {
parentTaskId = task.id
parentExpanded = false
@@ -354,60 +394,17 @@ fun KanbanCreateTaskDialog(
Spacer(modifier = Modifier.height(8.dp))
}
// Model Override
OutlinedTextField(
value = modelOverride,
onValueChange = { modelOverride = it },
label = { Text(stringResource(R.string.kanban_model_override)) },
placeholder = { Text("e.g. claude-3-5-sonnet") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
// Model Override via shared ModelPickerDialog
KanbanModelOverrideEditor(
override = modelOverride,
onOverrideChange = { modelOverride = it },
modelProviders = modelProviders,
pinnedModels = pinnedModels,
enabled = !isCreating,
)
Spacer(modifier = Modifier.height(8.dp))
// Provider Override
OutlinedTextField(
value = providerOverride,
onValueChange = { providerOverride = it },
label = { Text(stringResource(R.string.kanban_provider_override)) },
placeholder = { Text("e.g. anthropic") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isCreating,
)
Spacer(modifier = Modifier.height(8.dp))
// Reasoning Effort
Text(
text = stringResource(R.string.kanban_reasoning_effort),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
) {
listOf(
null to "Inherit",
"low" to "Low",
"medium" to "Medium",
"high" to "High",
).forEach { (effort, label) ->
FilterChip(
selected = reasoningEffort == effort,
onClick = { reasoningEffort = effort },
label = { Text(label) },
enabled = !isCreating,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Workspace Kind
Text(
text = stringResource(R.string.kanban_workspace_kind),
@@ -417,22 +414,46 @@ fun KanbanCreateTaskDialog(
Spacer(modifier = Modifier.height(4.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.testTag("kanban_workspace_kinds"),
) {
listOf(
null to "Scratch (Default)",
"worktree" to "Worktree",
"dir" to "Dir",
"scratch" to stringResource(R.string.kanban_workspace_scratch),
"worktree" to stringResource(R.string.kanban_workspace_worktree),
"dir" to stringResource(R.string.kanban_workspace_dir),
).forEach { (kind, label) ->
FilterChip(
selected = workspaceKind == kind,
onClick = { workspaceKind = kind },
label = { Text(label) },
enabled = !isCreating,
modifier = Modifier.testTag("kanban_workspace_kind_$kind"),
)
}
}
if (workspaceKind == "worktree" || workspaceKind == "dir") {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = workspacePath,
onValueChange = { workspacePath = it },
label = { Text(stringResource(R.string.kanban_workspace_path)) },
placeholder = {
if (!boardDefaultWorkdir.isNullOrBlank()) {
Text(boardDefaultWorkdir)
} else {
Text(stringResource(R.string.kanban_workspace_path_hint))
}
},
modifier = Modifier.fillMaxWidth().testTag("kanban_workspace_path"),
singleLine = true,
enabled = !isCreating,
)
}
Spacer(modifier = Modifier.height(8.dp))
// Skills
@@ -440,7 +461,7 @@ fun KanbanCreateTaskDialog(
value = skillsText,
onValueChange = { skillsText = it },
label = { Text(stringResource(R.string.kanban_skills)) },
placeholder = { Text("e.g. git, coding, tests") },
placeholder = { Text(stringResource(R.string.kanban_skills_placeholder)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isCreating,
@@ -448,40 +469,57 @@ fun KanbanCreateTaskDialog(
}
}
// Estimation Section
if (onEstimate != null) {
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
if (estimate != null) {
estimate?.let { est ->
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Text(
text = "Estimated Tokens: ${est.tokens ?: "N/A"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
if (!est.complexity.isNullOrBlank()) {
Text(
text = "Complexity: ${est.complexity}",
style = MaterialTheme.typography.bodySmall,
)
}
if (!est.rationale.isNullOrBlank()) {
Text(
text = est.rationale,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
OutlinedButton(
onClick = {
if (title.isNotBlank() && !isEstimating) {
isEstimating = true
coroutineScope.launch {
val res = onEstimate(title.trim(), desc.trim().ifBlank { null })
estimate = res
isEstimating = false
}
}
},
enabled = title.isNotBlank() && !isEstimating && !isCreating,
modifier = Modifier.fillMaxWidth(),
) {
if (estimate != null && estimate?.ok == true) {
Text(
text = "~${estimate?.estTokens ?: 0} tokens · ${estimate?.complexity ?: "normal"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
TextButton(
onClick = {
if (title.isNotBlank() && !isEstimating) {
coroutineScope.launch {
isEstimating = true
estimate = onEstimate(title, desc.ifBlank { null })
isEstimating = false
}
}
},
enabled = title.isNotBlank() && !isEstimating && !isCreating,
) {
if (isEstimating) {
CircularProgressIndicator(modifier = Modifier.size(12.dp), strokeWidth = 1.5.dp)
Spacer(modifier = Modifier.width(4.dp))
}
Text(if (estimate != null) "Re-estimate" else "Estimate tokens")
if (isEstimating) {
CircularProgressIndicator(modifier = Modifier.size(12.dp), strokeWidth = 1.5.dp)
Spacer(modifier = Modifier.width(4.dp))
}
Text(if (estimate != null) "Re-estimate" else "Estimate tokens")
}
}
}
@@ -497,6 +535,23 @@ fun KanbanCreateTaskDialog(
.filter { it.isNotEmpty() }
.takeIf { it.isNotEmpty() }
val finalWorkspaceKind = if (workspaceKind == "scratch") null else workspaceKind
val finalWorkspacePath =
if (finalWorkspaceKind != null && workspacePath.isNotBlank()) {
workspacePath.trim()
} else {
null
}
val finalModel = modelOverride.model.trim().ifBlank { null }
val finalProvider =
if (finalModel != null && modelOverride.provider.isNotBlank()) {
modelOverride.provider.trim()
} else {
null
}
val finalEffort = modelOverride.effort.trim().ifBlank { null }
val body =
CreateTaskBody(
title = title.trim(),
@@ -505,10 +560,11 @@ fun KanbanCreateTaskDialog(
priority = priority,
goalMode = goalMode,
parents = parentTaskId?.let { listOf(it) } ?: emptyList(),
modelOverride = modelOverride.trim().ifBlank { null },
providerOverride = providerOverride.trim().ifBlank { null },
reasoningEffort = reasoningEffort,
workspaceKind = workspaceKind,
modelOverride = finalModel,
providerOverride = finalProvider,
reasoningEffort = finalEffort,
workspaceKind = finalWorkspaceKind,
workspacePath = finalWorkspacePath,
skills = skillsList,
triage = selectedColumn.equals("triage", ignoreCase = true),
)
@@ -21,8 +21,11 @@ import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -60,7 +63,7 @@ fun KanbanFilterSheet(
fontWeight = FontWeight.Bold,
)
TextButton(onClick = onClearFilters) {
Text("Clear all")
Text(stringResource(R.string.kanban_clear_all))
}
}
@@ -75,10 +78,11 @@ fun KanbanFilterSheet(
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Text("Show archived tasks", style = MaterialTheme.typography.bodyMedium)
Text(stringResource(R.string.kanban_show_archived), style = MaterialTheme.typography.bodyMedium)
Switch(
checked = includeArchived,
onCheckedChange = onToggleIncludeArchived,
modifier = Modifier.testTag("kanban_show_archived"),
)
}
@@ -91,10 +95,11 @@ fun KanbanFilterSheet(
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Text("Group running lane by profile", style = MaterialTheme.typography.bodyMedium)
Text(stringResource(R.string.kanban_group_running), style = MaterialTheme.typography.bodyMedium)
Switch(
checked = groupRunning,
onCheckedChange = onToggleGroupRunning,
modifier = Modifier.testTag("kanban_group_running"),
)
}
@@ -102,7 +107,7 @@ fun KanbanFilterSheet(
// Assignee Filter
Text(
text = "Assignee",
text = stringResource(R.string.kanban_assignee),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 4.dp),
@@ -120,7 +125,7 @@ fun KanbanFilterSheet(
selected = selectedAssignee == null,
onClick = { onSelectAssignee(null) },
)
Text("All assignees", style = MaterialTheme.typography.bodyMedium)
Text(stringResource(R.string.kanban_all_assignees), style = MaterialTheme.typography.bodyMedium)
}
assignees.forEach { assignee ->
@@ -144,7 +149,7 @@ fun KanbanFilterSheet(
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Text(
text = "Tenant",
text = stringResource(R.string.kanban_tenant),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 4.dp),
@@ -162,7 +167,7 @@ fun KanbanFilterSheet(
selected = selectedTenant == null,
onClick = { onSelectTenant(null) },
)
Text("All tenants", style = MaterialTheme.typography.bodyMedium)
Text(stringResource(R.string.kanban_all_tenants), style = MaterialTheme.typography.bodyMedium)
}
tenants.forEach { tenant ->
@@ -0,0 +1,191 @@
package com.m57.hermescontrol.ui.kanban.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.ui.kanban.KanbanModelOverride
import com.m57.hermescontrol.ui.kanban.supportedKanbanReasoningEfforts
import com.m57.hermescontrol.ui.model.components.ModelPickerDialog
@Composable
fun KanbanModelOverrideEditor(
override: KanbanModelOverride,
onOverrideChange: (KanbanModelOverride) -> Unit,
modelProviders: List<ModelProvider>,
pinnedModels: List<PinnedModel> = emptyList(),
enabled: Boolean = true,
modifier: Modifier = Modifier,
) {
var showPickerDialog by remember { mutableStateOf(false) }
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.kanban_model_override),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
OutlinedCard(
onClick = { if (enabled) showPickerDialog = true },
modifier = Modifier.fillMaxWidth(),
enabled = enabled,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.height(48.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.weight(1f)
.padding(horizontal = 12.dp),
) {
Icon(
imageVector = Icons.Default.Tune,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint =
if (override.isInherited) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.primary
},
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = override.displayLabel(stringResource(R.string.kanban_model_inherit)),
style = MaterialTheme.typography.bodyMedium,
color =
if (override.isInherited) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 1,
)
}
if (!override.isInherited && enabled) {
IconButton(
onClick = { onOverrideChange(override.copy(model = "", provider = "")) },
modifier = Modifier.size(40.dp),
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.kanban_model_clear),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.kanban_reasoning_effort),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
val selectedCapabilities =
modelProviders
.firstOrNull { it.slug == override.provider }
?.capabilities
?.get(override.model)
val reasoningSupported = selectedCapabilities?.reasoning != false
val canDisableReasoning = selectedCapabilities?.can_disable_reasoning != false
val supportedEfforts = supportedKanbanReasoningEfforts(reasoningSupported, canDisableReasoning)
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
) {
supportedEfforts
.map { effortVal ->
effortVal to
when (effortVal) {
"" -> stringResource(R.string.kanban_effort_inherit)
"none" -> stringResource(R.string.kanban_effort_none)
"minimal" -> stringResource(R.string.kanban_effort_minimal)
"low" -> stringResource(R.string.kanban_effort_low)
"medium" -> stringResource(R.string.kanban_effort_medium)
"high" -> stringResource(R.string.kanban_effort_high)
"xhigh" -> stringResource(R.string.kanban_effort_xhigh)
"max" -> stringResource(R.string.kanban_effort_max)
"ultra" -> stringResource(R.string.kanban_effort_ultra)
else -> effortVal
}
}.forEach { (effortVal, label) ->
FilterChip(
selected = override.effort == effortVal,
onClick = {
if (enabled) {
onOverrideChange(override.copy(effort = effortVal))
}
},
label = { Text(label) },
enabled = enabled,
)
}
}
}
if (showPickerDialog) {
ModelPickerDialog(
providers = modelProviders,
title = stringResource(R.string.kanban_select_model),
pinnedModels = pinnedModels,
onSelect = { provider, model ->
val capabilities = modelProviders.firstOrNull { it.slug == provider }?.capabilities?.get(model)
onOverrideChange(
override.copy(
provider = provider,
model = model,
effort = if (capabilities?.reasoning == false) "" else override.effort,
),
)
showPickerDialog = false
},
onDismiss = { showPickerDialog = false },
)
}
}
@@ -0,0 +1,324 @@
package com.m57.hermescontrol.ui.kanban.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.OrchestrationSettings
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KanbanOrchestrationDialog(
settings: OrchestrationSettings?,
profiles: List<KanbanProfile>,
onSaveSettings: (OrchestrationSettingsUpdate) -> Unit,
onSaveProfileDescription: (profile: String, description: String) -> Unit,
onAutoDescribe: (profile: String, onComplete: (String?) -> Unit) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
var orchestratorProfile by remember(settings) {
mutableStateOf(settings?.orchestratorProfile ?: "")
}
var defaultAssignee by remember(settings) {
mutableStateOf(settings?.defaultAssignee ?: "")
}
var autoDecompose by remember(settings) {
mutableStateOf(settings?.autoDecompose ?: true)
}
var orchestratorExpanded by remember { mutableStateOf(false) }
var defaultAssigneeExpanded by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
modifier = modifier,
title = { Text(stringResource(R.string.kanban_orchestration_settings)) },
text = {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Orchestrator Profile
ExposedDropdownMenuBox(
expanded = orchestratorExpanded,
onExpandedChange = { orchestratorExpanded = it },
) {
val display =
if (orchestratorProfile.isBlank()) {
stringResource(R.string.kanban_assignee_unassigned)
} else {
orchestratorProfile
}
OutlinedTextField(
value = display,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.kanban_orchestrator_profile)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = orchestratorExpanded) },
modifier =
Modifier
.menuAnchor(
ExposedDropdownMenuAnchorType.PrimaryNotEditable,
).fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = orchestratorExpanded,
onDismissRequest = { orchestratorExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_assignee_unassigned)) },
onClick = {
orchestratorProfile = ""
orchestratorExpanded = false
},
)
profiles.forEach { p ->
DropdownMenuItem(
text = { Text(p.name) },
onClick = {
orchestratorProfile = p.name
orchestratorExpanded = false
},
)
}
}
}
// Default Assignee
ExposedDropdownMenuBox(
expanded = defaultAssigneeExpanded,
onExpandedChange = { defaultAssigneeExpanded = it },
) {
val display =
if (defaultAssignee.isBlank()) {
stringResource(R.string.kanban_assignee_unassigned)
} else {
defaultAssignee
}
OutlinedTextField(
value = display,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.kanban_default_assignee)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = defaultAssigneeExpanded) },
modifier =
Modifier
.menuAnchor(
ExposedDropdownMenuAnchorType.PrimaryNotEditable,
).fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = defaultAssigneeExpanded,
onDismissRequest = { defaultAssigneeExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_assignee_unassigned)) },
onClick = {
defaultAssignee = ""
defaultAssigneeExpanded = false
},
)
profiles.forEach { p ->
DropdownMenuItem(
text = { Text(p.name) },
onClick = {
defaultAssignee = p.name
defaultAssigneeExpanded = false
},
)
}
}
}
// Auto-decompose Switch
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.kanban_auto_decompose),
style = MaterialTheme.typography.bodyMedium,
)
Switch(
checked = autoDecompose,
onCheckedChange = { autoDecompose = it },
)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Profile Descriptions
Text(
text = stringResource(R.string.kanban_profile_descriptions),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
profiles.forEach { profile ->
ProfileDescriptionEditor(
profile = profile,
onSave = { onSaveProfileDescription(profile.name, it) },
onAutoDescribe = { cb -> onAutoDescribe(profile.name, cb) },
)
}
}
},
confirmButton = {
Button(
onClick = {
onSaveSettings(
OrchestrationSettingsUpdate(
orchestratorProfile = orchestratorProfile,
defaultAssignee = defaultAssignee,
autoDecompose = autoDecompose,
),
)
onDismiss()
},
) {
Text(stringResource(R.string.action_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
@Composable
private fun ProfileDescriptionEditor(
profile: KanbanProfile,
onSave: (String) -> Unit,
onAutoDescribe: ((String?) -> Unit) -> Unit,
modifier: Modifier = Modifier,
) {
var draft by remember(profile.description) { mutableStateOf(profile.description) }
var isDescribing by remember { mutableStateOf(false) }
OutlinedCard(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(10.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = profile.name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
if (profile.isDefault) {
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "(Default)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (isDescribing) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
IconButton(
onClick = {
isDescribing = true
onAutoDescribe { desc ->
isDescribing = false
if (desc != null) draft = desc
}
},
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Default.AutoAwesome,
contentDescription = stringResource(R.string.kanban_auto_describe),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
IconButton(
onClick = { onSave(draft.trim()) },
enabled = draft.trim() != profile.description.trim(),
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Default.Save,
contentDescription = stringResource(R.string.action_save),
modifier = Modifier.size(16.dp),
tint =
if (draft.trim() != profile.description.trim()) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
},
)
}
}
}
Spacer(modifier = Modifier.height(6.dp))
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 4,
placeholder = { Text(stringResource(R.string.kanban_profile_description_hint)) },
)
}
}
}
@@ -31,12 +31,16 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.kanban.KanbanArcState
import com.m57.hermescontrol.ui.kanban.KanbanRuntimeHelper
@Composable
fun KanbanTaskCard(
@@ -44,6 +48,8 @@ fun KanbanTaskCard(
onTaskClick: (KanbanTask) -> Unit,
modifier: Modifier = Modifier,
isSelected: Boolean = false,
defaultAssignee: String? = null,
nowSeconds: Long = System.currentTimeMillis() / 1000L,
onActionClick: ((KanbanTask) -> Unit)? = null,
) {
val statusColors = LocalHermesStatusColors.current
@@ -57,6 +63,10 @@ fun KanbanTaskCard(
else -> MaterialTheme.colorScheme.outline
}
val arcState = KanbanRuntimeHelper.arcState(task, defaultAssignee, nowSeconds)
val isWontRun = KanbanRuntimeHelper.isWontRun(task, defaultAssignee)
val elapsed = KanbanRuntimeHelper.formatElapsed(task.startedAt, nowSeconds)
val cardColors =
if (isSelected) {
CardDefaults.cardColors(
@@ -92,6 +102,41 @@ fun KanbanTaskCard(
fontWeight = FontWeight.Bold,
color = statusTone,
)
when {
arcState == KanbanArcState.RUNNING -> {
Spacer(modifier = Modifier.width(4.dp))
Text(
text =
stringResource(
R.string.kanban_runtime_elapsed,
elapsed ?: stringResource(R.string.kanban_runtime_working),
),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium,
color = statusColors.success,
)
}
arcState == KanbanArcState.STALE -> {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(R.string.kanban_runtime_stale_badge),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = statusColors.warning,
)
}
isWontRun -> {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = stringResource(R.string.kanban_runtime_wont_run_badge),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = statusColors.error,
)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -108,7 +153,7 @@ fun KanbanTaskCard(
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Task actions",
contentDescription = stringResource(R.string.kanban_task_actions),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -145,7 +190,14 @@ fun KanbanTaskCard(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
task.assignee?.let { assignee ->
val displayAssignee =
task.assignee
?: if (arcState == KanbanArcState.QUEUED && !defaultAssignee.isNullOrBlank()) {
"$defaultAssignee (Default)"
} else {
null
}
displayAssignee?.let { assignee ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(end = 8.dp),
@@ -0,0 +1,37 @@
package com.m57.hermescontrol.util
import android.content.ContentResolver
import android.net.Uri
import okhttp3.MediaType
import okhttp3.RequestBody
import okio.BufferedSink
import okio.source
import java.io.IOException
/**
* A streaming OkHttp [RequestBody] backed by an Android SAF [Uri] and [ContentResolver].
*
* Streams bytes directly from the open stream into OkHttp's [BufferedSink] without
* buffering the entire file into heap memory (avoiding OutOfMemoryError on large archives).
*/
class StreamingUriRequestBody(
private val contentResolver: ContentResolver,
private val uri: Uri,
private val contentType: MediaType?,
private val contentLength: Long = -1L,
) : RequestBody() {
override fun contentType(): MediaType? = contentType
override fun contentLength(): Long = contentLength
override fun writeTo(sink: BufferedSink) {
val inputStream =
contentResolver.openInputStream(uri)
?: throw IOException("Unable to open input stream for URI: $uri")
inputStream.use { stream ->
stream.source().use { source ->
sink.writeAll(source)
}
}
}
}
+147 -1
View File
@@ -318,7 +318,153 @@
<string name="kanban_live">مباشر</string>
<string name="kanban_offline">غير متصل</string>
<string name="kanban_no_tasks">لا توجد مهام</string>
<string name="action_add">إضافة</string>
<string name="kanban_priority">الأولوية</string>
<string name="kanban_priority_normal">0 (عادي)</string>
<string name="kanban_priority_med">1 (متوسط)</string>
<string name="kanban_priority_high">2 (مرتفع)</string>
<string name="kanban_priority_urgent">3 (عاجل)</string>
<string name="kanban_assignee">المسؤول</string>
<string name="kanban_assignee_default_suffix">%1$s (الافتراضي)</string>
<string name="kanban_assignee_unassigned">افتراضي (غير معيَّن)</string>
<string name="kanban_assignee_parked">موقوف (بدون عامل)</string>
<string name="kanban_goal_mode">وضع الهدف الذاتي</string>
<string name="kanban_goal_mode_desc">يتحقق القاضي من اكتمال المهمة عبر تكرارات متعددة</string>
<string name="kanban_advanced_options">خيارات متقدمة</string>
<string name="kanban_parent_task">المهمة الأصلية</string>
<string name="kanban_parent_none">لا يوجد</string>
<string name="kanban_model_override">تجاوز النموذج</string>
<string name="kanban_provider_override">تجاوز المزود</string>
<string name="kanban_reasoning_effort">جهد الاستنتاج</string>
<string name="kanban_workspace_kind">نوع مساحة العمل</string>
<string name="kanban_workspace_path">تجاوز مسار مساحة العمل</string>
<string name="kanban_workspace_path_hint">مسار اختياري (الافتراضي مجلد المشروع)</string>
<string name="kanban_skills">المهارات</string>
<string name="kanban_target_column">العمود</string>
<string name="kanban_model_inherit">وراثة الملف الشخصي</string>
<string name="kanban_model_clear">مسح تجاوز النموذج</string>
<string name="kanban_select_model">اختيار النموذج</string>
<string name="kanban_effort_inherit">تلقائي</string>
<string name="kanban_effort_none">معطل</string>
<string name="kanban_effort_low">منخفض</string>
<string name="kanban_effort_medium">متوسط</string>
<string name="kanban_effort_high">مرتفع</string>
<string name="kanban_effort_minimal">حد أدنى</string>
<string name="kanban_effort_xhigh">مرتفع جدًا</string>
<string name="kanban_effort_max">أقصى</string>
<string name="kanban_effort_ultra">فائق</string>
<string name="kanban_show_archived">إظهار المهام المؤرشفة</string>
<string name="kanban_group_running">تجميع المهام قيد التشغيل حسب الملف الشخصي</string>
<string name="kanban_all_assignees">جميع المسؤولين</string>
<string name="kanban_tenant">المستأجر</string>
<string name="kanban_all_tenants">جميع المستأجرين</string>
<string name="kanban_execution_runs">عمليات التنفيذ</string>
<string name="kanban_no_run_history">لا يتوفر سجل للتنفيذ.</string>
<string name="kanban_run_number">التنفيذ #%1$d</string>
<string name="kanban_run_profile">الملف الشخصي: %1$s</string>
<string name="kanban_run_outcome">النتيجة: %1$s</string>
<string name="kanban_run_duration">المدة: %1$s</string>
<string name="kanban_run_error">الخطأ: %1$s</string>
<string name="kanban_run_summary">الملخص: %1$s</string>
<string name="kanban_project">نطاق المشروع</string>
<string name="kanban_no_project">بدون مشروع</string>
<string name="kanban_archive_board">أرشفة اللوحة</string>
<string name="kanban_archive_board_confirm">هل أنت متأكد من أرشفة اللوحة \'%1$s\'؟ يمكنك استعادتها لاحقًا من اللوحات المؤرشفة.</string>
<string name="kanban_board_settings">إعدادات اللوحة</string>
<string name="kanban_board_name">اسم اللوحة</string>
<string name="kanban_description">الوصف</string>
<string name="kanban_default_workdir">مجلد العمل الافتراضي</string>
<string name="kanban_workdir_example">مثال: /home/user/project</string>
<string name="kanban_orchestration_settings">إعدادات التنسيق</string>
<string name="kanban_orchestrator_profile">الملف المنسق</string>
<string name="kanban_default_assignee">المسؤول الافتراضي</string>
<string name="kanban_auto_decompose">تفكيك تلقائي</string>
<string name="kanban_profile_descriptions">أوصاف الملفات الشخصية</string>
<string name="kanban_auto_describe">وصف تلقائي</string>
<string name="kanban_dependencies">الارتباطات والتبعيات</string>
<string name="kanban_blocked_by">محظورة بواسطة</string>
<string name="kanban_blocks">تحظر</string>
<string name="kanban_estimate">تقدير الجهد</string>
<string name="kanban_estimate_action">تقدير الجهد</string>
<string name="kanban_reestimate_action">إعادة التقدير</string>
<string name="kanban_estimated_tokens">الرموز المقدرة: %1$d</string>
<string name="kanban_complexity">التعقيد: %1$s</string>
<string name="kanban_rationale">السبب والمنطق</string>
<string name="kanban_delete_task">حذف المهمة</string>
<string name="kanban_delete_task_confirm">هل أنت متأكد من حذف هذه المهمة؟ لا يمكن التراجع عن هذا الإجراء.</string>
<string name="kanban_bulk_assign">تعيين</string>
<string name="kanban_bulk_unassign">إلغاء التعيين</string>
<string name="kanban_bulk_delete">حذف</string>
<string name="kanban_bulk_delete_confirm">هل تريد حذف %1$d مهمة محددة؟ لا يمكن التراجع عن هذا الإجراء.</string>
<string name="kanban_export_board">تصدير اللوحة</string>
<string name="kanban_import_board">استيراد لوحة</string>
<string name="kanban_runtime_working">قيد التشغيل</string>
<string name="kanban_runtime_stale">متوقف (لا توجد إشارة)</string>
<string name="kanban_runtime_wont_run">لن تعمل: غير معيّنة</string>
<string name="kanban_runtime_elapsed">· %1$s</string>
<string name="kanban_runtime_stale_badge">· متوقف</string>
<string name="kanban_runtime_wont_run_badge">· لن تعمل</string>
<string name="kanban_task_actions">إجراءات المهمة</string>
<string name="kanban_reclaim_task">استعادة المهمة</string>
<string name="kanban_no_comments">لا توجد تعليقات بعد.</string>
<string name="kanban_write_comment">اكتب تعليقًا...</string>
<string name="kanban_note_requeue">ملاحظة وإعادة إدراج</string>
<string name="kanban_comment">تعليق</string>
<string name="kanban_worker_log">مخرجات سجل العامل</string>
<string name="kanban_event_timeline">الخط الزمني للأحداث</string>
<string name="kanban_no_activity">لا توجد أحداث نشاط.</string>
<string name="kanban_attachments">المرفقات</string>
<string name="kanban_upload">رفع</string>
<string name="kanban_no_attachments">لا توجد مرفقات مرفوعة.</string>
<string name="kanban_clear_all">مسح الكل</string>
<string name="kanban_skills_placeholder">مثال: git، coding، tests</string>
<string name="kanban_profile_description_hint">وصف الملف الشخصي للتوجيه</string>
<string name="kanban_filter">تصفية</string>
<string name="kanban_board_options">خيارات اللوحة</string>
<string name="kanban_new_board">لوحة جديدة</string>
<string name="kanban_clear_selection">مسح</string>
<string name="kanban_select_all">الكل</string>
<string name="kanban_move">نقل</string>
<string name="kanban_close_multi_select">إغلاق التحديد المتعدد</string>
<string name="kanban_move_tasks">نقل %1$d من المهام</string>
<string name="kanban_create_new_board">إنشاء لوحة جديدة</string>
<string name="kanban_slug">المعرّف (Slug)</string>
<string name="kanban_display_name">اسم العرض</string>
<string name="kanban_filters">عوامل التصفية</string>
<string name="kanban_confirm_action">تأكيد الإجراء</string>
<string name="kanban_edit_description">تعديل الوصف</string>
<string name="kanban_task_details">تفاصيل المهمة</string>
<string name="kanban_tab_overview">نظرة عامة</string>
<string name="kanban_tab_discussion">النقاش</string>
<string name="kanban_tab_runs">التنفيذات</string>
<string name="kanban_tab_activity">النشاط</string>
<string name="kanban_tab_files">الملفات</string>
<string name="kanban_event_created">أُنشئت في %1$s</string>
<string name="kanban_event_created_by">أُنشئت في %1$s بواسطة %2$s</string>
<string name="kanban_event_moved">نُقلت إلى %1$s</string>
<string name="kanban_event_assigned">عُيّنت إلى %1$s</string>
<string name="kanban_event_unassigned">غير معيّنة</string>
<string name="kanban_event_commented">تعليق بواسطة %1$s</string>
<string name="kanban_event_claimed_review">تمت المطالبة للمراجعة</string>
<string name="kanban_event_claimed_worker">تمت المطالبة بواسطة العامل</string>
<string name="kanban_event_worker_started">بدأ العامل</string>
<string name="kanban_event_completed">مكتملة</string>
<string name="kanban_event_blocked">محظورة</string>
<string name="kanban_event_unblocked">أزيل حظرها</string>
<string name="kanban_event_unblocked_to">أزيل حظرها (%1$s)</string>
<string name="kanban_event_reclaimed">تمت الاستعادة</string>
<string name="kanban_event_specified">تم التحديد</string>
<string name="kanban_event_promoted">تمت الترقية إلى جاهزة</string>
<string name="kanban_event_scheduled">مجدولة</string>
<string name="kanban_event_archived">مؤرشفة</string>
<string name="kanban_event_priority">تم ضبط الأولوية على %1$s</string>
<string name="kanban_workspace_scratch">مؤقت (افتراضي)</string>
<string name="kanban_workspace_worktree">شجرة عمل</string>
<string name="kanban_workspace_dir">مجلد</string>
<string name="kanban_unassigned_group">غير معيّن</string>
<string name="kanban_expand_column">توسيع %1$s</string>
<string name="kanban_collapse_column">طي %1$s</string>
<string name="kanban_add_to_column">إضافة مهمة إلى %1$s</string>
<string name="action_add">إضافة</string>
<string name="skills_status_all">جميع الحالات</string>
<string name="skills_status_enabled">مفعَّلة</string>
<string name="skills_status_disabled">معطَّلة</string>
+164
View File
@@ -299,6 +299,170 @@
<string name="kanban_add_new_task">새 작업 추가</string>
<string name="kanban_task_title">작업 제목</string>
<string name="kanban_task_desc">작업 설명</string>
<string name="kanban_add_task">작업 추가</string>
<string name="kanban_action_triage">분류</string>
<string name="kanban_action_ready">대기</string>
<string name="kanban_action_block">차단</string>
<string name="kanban_action_unblock">차단 해제</string>
<string name="kanban_action_complete">완료</string>
<string name="kanban_action_archive">보관</string>
<string name="kanban_confirm_blocked">이 작업을 차단하시겠습니까? 워커 점유가 해제됩니다.</string>
<string name="kanban_confirm_done">이 작업을 완료하시겠습니까? 워커 점유가 해제됩니다.</string>
<string name="kanban_confirm_archive">이 작업을 보관하시겠습니까? 기본 보드 뷰에서 숨겨집니다.</string>
<string name="kanban_complete_title">작업 완료</string>
<string name="kanban_complete_summary_label">완료 요약</string>
<string name="kanban_complete_summary_hint">작업 결과로 저장됩니다</string>
<string name="kanban_move_failed">이동 실패: %1$s</string>
<string name="kanban_live">실시간</string>
<string name="kanban_offline">오프라인</string>
<string name="kanban_no_tasks">작업 없음</string>
<string name="kanban_priority">우선순위</string>
<string name="kanban_priority_normal">0 (일반)</string>
<string name="kanban_priority_med">1 (중간)</string>
<string name="kanban_priority_high">2 (높음)</string>
<string name="kanban_priority_urgent">3 (긴급)</string>
<string name="kanban_assignee">담당자</string>
<string name="kanban_assignee_default_suffix">%1$s (기본값)</string>
<string name="kanban_assignee_unassigned">기본 (미지정)</string>
<string name="kanban_assignee_parked">보류 (워커 없음)</string>
<string name="kanban_goal_mode">자율 목표 모드</string>
<string name="kanban_goal_mode_desc">판정자가 다회차 반복 작업을 통해 완료 여부를 검증합니다</string>
<string name="kanban_advanced_options">고급 옵션</string>
<string name="kanban_parent_task">상위 작업</string>
<string name="kanban_parent_none">없음</string>
<string name="kanban_model_override">모델 오버라이드</string>
<string name="kanban_provider_override">공급자 오버라이드</string>
<string name="kanban_reasoning_effort">추론 강도</string>
<string name="kanban_workspace_kind">작업공간 종류</string>
<string name="kanban_workspace_path">작업공간 경로 오버라이드</string>
<string name="kanban_workspace_path_hint">선택적 경로 (기본값: 프로젝트 디렉터리)</string>
<string name="kanban_skills">스킬</string>
<string name="kanban_target_column">컬럼</string>
<string name="kanban_model_inherit">프로필 기본값 상속</string>
<string name="kanban_model_clear">모델 오버라이드 초기화</string>
<string name="kanban_select_model">모델 선택</string>
<string name="kanban_effort_inherit">상속</string>
<string name="kanban_effort_none">비활성화</string>
<string name="kanban_effort_low">낮음</string>
<string name="kanban_effort_medium">중간</string>
<string name="kanban_effort_high">높음</string>
<string name="kanban_effort_minimal">최소</string>
<string name="kanban_effort_xhigh">매우 높음</string>
<string name="kanban_effort_max">최대</string>
<string name="kanban_effort_ultra">울트라</string>
<string name="kanban_show_archived">보관된 작업 표시</string>
<string name="kanban_group_running">실행 중인 작업을 프로필별로 그룹화</string>
<string name="kanban_all_assignees">모든 담당자</string>
<string name="kanban_tenant">테넌트</string>
<string name="kanban_all_tenants">모든 테넌트</string>
<string name="kanban_execution_runs">실행 기록</string>
<string name="kanban_no_run_history">실행 기록이 없습니다.</string>
<string name="kanban_run_number">실행 #%1$d</string>
<string name="kanban_run_profile">프로필: %1$s</string>
<string name="kanban_run_outcome">결과: %1$s</string>
<string name="kanban_run_duration">기간: %1$s</string>
<string name="kanban_run_error">오류: %1$s</string>
<string name="kanban_run_summary">요약: %1$s</string>
<string name="kanban_project">프로젝트 범위</string>
<string name="kanban_no_project">프로젝트 없음</string>
<string name="kanban_archive_board">보드 보관</string>
<string name="kanban_archive_board_confirm">\'%1$s\' 보드를 보관하시겠습니까? 나중에 보관된 보드에서 복구할 수 있습니다.</string>
<string name="kanban_board_settings">보드 설정</string>
<string name="kanban_board_name">보드 이름</string>
<string name="kanban_description">설명</string>
<string name="kanban_default_workdir">기본 작업 디렉터리</string>
<string name="kanban_workdir_example">예: /home/user/project</string>
<string name="kanban_orchestration_settings">오케스트레이션 설정</string>
<string name="kanban_orchestrator_profile">오케스트레이터 프로필</string>
<string name="kanban_default_assignee">기본 담당자</string>
<string name="kanban_auto_decompose">자동 분해</string>
<string name="kanban_profile_descriptions">프로필 설명</string>
<string name="kanban_auto_describe">자동 설명 생성</string>
<string name="kanban_dependencies">종속성</string>
<string name="kanban_blocked_by">차단됨 (상위)</string>
<string name="kanban_blocks">차단함 (하위)</string>
<string name="kanban_estimate">노력 추정</string>
<string name="kanban_estimate_action">노력 추정</string>
<string name="kanban_reestimate_action">재추정</string>
<string name="kanban_estimated_tokens">예상 토큰: %1$d</string>
<string name="kanban_complexity">복잡도: %1$s</string>
<string name="kanban_rationale">근거</string>
<string name="kanban_delete_task">작업 삭제</string>
<string name="kanban_delete_task_confirm">이 작업을 삭제하시겠습니까? 이 동작은 되돌릴 수 없습니다.</string>
<string name="kanban_bulk_assign">담당자 지정</string>
<string name="kanban_bulk_unassign">담당자 해제</string>
<string name="kanban_bulk_delete">삭제</string>
<string name="kanban_bulk_delete_confirm">선택한 %1$d개 작업을 삭제하시겠습니까? 이 동작은 되돌릴 수 없습니다.</string>
<string name="kanban_export_board">보드 내보내기</string>
<string name="kanban_import_board">보드 가져오기</string>
<string name="kanban_runtime_working">실행 중</string>
<string name="kanban_runtime_stale">응답 없음 (하트비트 지연)</string>
<string name="kanban_runtime_wont_run">실행 안 됨: 담당자 미지정</string>
<string name="kanban_runtime_elapsed">· %1$s</string>
<string name="kanban_runtime_stale_badge">· 응답 없음</string>
<string name="kanban_runtime_wont_run_badge">· 실행 안 됨</string>
<string name="kanban_task_actions">작업 작업</string>
<string name="kanban_reclaim_task">작업 회수</string>
<string name="kanban_no_comments">아직 댓글이 없습니다.</string>
<string name="kanban_write_comment">댓글을 작성하세요...</string>
<string name="kanban_note_requeue">메모 및 다시 대기열에 추가</string>
<string name="kanban_comment">댓글</string>
<string name="kanban_worker_log">워커 로그 출력</string>
<string name="kanban_event_timeline">이벤트 타임라인</string>
<string name="kanban_no_activity">활동 이벤트가 없습니다.</string>
<string name="kanban_attachments">첨부파일</string>
<string name="kanban_upload">업로드</string>
<string name="kanban_no_attachments">업로드된 첨부파일이 없습니다.</string>
<string name="kanban_clear_all">모두 지우기</string>
<string name="kanban_skills_placeholder">예: git, coding, tests</string>
<string name="kanban_profile_description_hint">라우팅에 사용할 프로필 설명</string>
<string name="kanban_filter">필터</string>
<string name="kanban_filter_placeholder">제목, 상태 또는 담당자로 작업 필터링...</string>
<string name="kanban_board_options">보드 옵션</string>
<string name="kanban_new_board">새 보드</string>
<string name="kanban_clear_selection">지우기</string>
<string name="kanban_select_all">전체</string>
<string name="kanban_move">이동</string>
<string name="kanban_close_multi_select">다중 선택 닫기</string>
<string name="kanban_move_tasks">작업 %1$d개 이동</string>
<string name="kanban_create_new_board">새 보드 만들기</string>
<string name="kanban_slug">슬러그 (id)</string>
<string name="kanban_display_name">표시 이름</string>
<string name="kanban_filters">필터</string>
<string name="kanban_confirm_action">작업 확인</string>
<string name="kanban_edit_description">설명 편집</string>
<string name="kanban_task_details">작업 세부정보</string>
<string name="kanban_tab_overview">개요</string>
<string name="kanban_tab_discussion">토론</string>
<string name="kanban_tab_runs">실행</string>
<string name="kanban_tab_activity">활동</string>
<string name="kanban_tab_files">파일</string>
<string name="kanban_event_created">%1$s에서 생성됨</string>
<string name="kanban_event_created_by">%1$s에서 %2$s가 생성함</string>
<string name="kanban_event_moved">%1$s로 이동됨</string>
<string name="kanban_event_assigned">%1$s에게 할당됨</string>
<string name="kanban_event_unassigned">미지정</string>
<string name="kanban_event_commented">%1$s의 댓글</string>
<string name="kanban_event_claimed_review">검토를 위해 할당됨</string>
<string name="kanban_event_claimed_worker">워커가 할당함</string>
<string name="kanban_event_worker_started">워커 시작됨</string>
<string name="kanban_event_completed">완료됨</string>
<string name="kanban_event_blocked">차단됨</string>
<string name="kanban_event_unblocked">차단 해제됨</string>
<string name="kanban_event_unblocked_to">차단 해제됨 (%1$s)</string>
<string name="kanban_event_reclaimed">회수됨</string>
<string name="kanban_event_specified">지정됨</string>
<string name="kanban_event_promoted">준비 상태로 승격됨</string>
<string name="kanban_event_scheduled">예약됨</string>
<string name="kanban_event_archived">보관됨</string>
<string name="kanban_event_priority">우선순위가 %1$s로 설정됨</string>
<string name="kanban_workspace_scratch">임시 (기본값)</string>
<string name="kanban_workspace_worktree">작업 트리</string>
<string name="kanban_workspace_dir">디렉터리</string>
<string name="kanban_unassigned_group">미지정</string>
<string name="kanban_expand_column">%1$s 펼치기</string>
<string name="kanban_collapse_column">%1$s 접기</string>
<string name="kanban_add_to_column">%1$s에 작업 추가</string>
<string name="action_add">추가</string>
<!-- Skills Screen -->
+146
View File
@@ -317,6 +317,152 @@
<string name="kanban_live">实时</string>
<string name="kanban_offline">离线</string>
<string name="kanban_no_tasks">暂无任务</string>
<string name="kanban_priority">优先级</string>
<string name="kanban_priority_normal">0 (普通)</string>
<string name="kanban_priority_med">1 (中等)</string>
<string name="kanban_priority_high">2 (高)</string>
<string name="kanban_priority_urgent">3 (紧急)</string>
<string name="kanban_assignee">负责人</string>
<string name="kanban_assignee_default_suffix">%1$s(默认)</string>
<string name="kanban_assignee_unassigned">默认 (未指定)</string>
<string name="kanban_assignee_parked">暂挂 (无 worker)</string>
<string name="kanban_goal_mode">自主目标模式</string>
<string name="kanban_goal_mode_desc">由评判者在多轮迭代中验证任务是否完成</string>
<string name="kanban_advanced_options">高级选项</string>
<string name="kanban_parent_task">父任务</string>
<string name="kanban_parent_none">无</string>
<string name="kanban_model_override">覆盖模型</string>
<string name="kanban_provider_override">覆盖提供商</string>
<string name="kanban_reasoning_effort">推理强度</string>
<string name="kanban_workspace_kind">工作区类型</string>
<string name="kanban_workspace_path">覆盖工作区路径</string>
<string name="kanban_workspace_path_hint">可选路径(默认为项目目录)</string>
<string name="kanban_skills">技能</string>
<string name="kanban_target_column">列</string>
<string name="kanban_model_inherit">继承配置默认模型</string>
<string name="kanban_model_clear">清除模型覆盖</string>
<string name="kanban_select_model">选择模型</string>
<string name="kanban_effort_inherit">继承</string>
<string name="kanban_effort_none">禁用</string>
<string name="kanban_effort_low">低</string>
<string name="kanban_effort_medium">中</string>
<string name="kanban_effort_high">高</string>
<string name="kanban_effort_minimal">最小</string>
<string name="kanban_effort_xhigh">超高</string>
<string name="kanban_effort_max">最大</string>
<string name="kanban_effort_ultra">极高</string>
<string name="kanban_show_archived">显示已归档任务</string>
<string name="kanban_group_running">按配置文件分组运行中的任务</string>
<string name="kanban_all_assignees">所有负责人</string>
<string name="kanban_tenant">租户</string>
<string name="kanban_all_tenants">所有租户</string>
<string name="kanban_execution_runs">执行记录</string>
<string name="kanban_no_run_history">暂无执行记录。</string>
<string name="kanban_run_number">执行 #%1$d</string>
<string name="kanban_run_profile">配置文件:%1$s</string>
<string name="kanban_run_outcome">结果:%1$s</string>
<string name="kanban_run_duration">时长:%1$s</string>
<string name="kanban_run_error">错误:%1$s</string>
<string name="kanban_run_summary">摘要:%1$s</string>
<string name="kanban_project">项目范围</string>
<string name="kanban_no_project">无项目</string>
<string name="kanban_archive_board">归档看板</string>
<string name="kanban_archive_board_confirm">确定要归档看板 \'%1$s\' 吗?稍后可从已归档看板中恢复。</string>
<string name="kanban_board_settings">看板设置</string>
<string name="kanban_board_name">看板名称</string>
<string name="kanban_description">描述</string>
<string name="kanban_default_workdir">默认工作目录</string>
<string name="kanban_workdir_example">例如:/home/user/project</string>
<string name="kanban_orchestration_settings">编排设置</string>
<string name="kanban_orchestrator_profile">编排配置文件</string>
<string name="kanban_default_assignee">默认负责人</string>
<string name="kanban_auto_decompose">自动分解</string>
<string name="kanban_profile_descriptions">配置文件描述</string>
<string name="kanban_auto_describe">自动生成描述</string>
<string name="kanban_dependencies">依赖关系</string>
<string name="kanban_blocked_by">阻塞于 (父级)</string>
<string name="kanban_blocks">阻塞 (子级)</string>
<string name="kanban_estimate">工作量评估</string>
<string name="kanban_estimate_action">评估工作量</string>
<string name="kanban_reestimate_action">重新评估</string>
<string name="kanban_estimated_tokens">预计 Token:%1$d</string>
<string name="kanban_complexity">复杂度:%1$s</string>
<string name="kanban_rationale">评估依据</string>
<string name="kanban_delete_task">删除任务</string>
<string name="kanban_delete_task_confirm">确定要删除此任务吗?此操作无法撤销。</string>
<string name="kanban_bulk_assign">分配负责人</string>
<string name="kanban_bulk_unassign">取消分配</string>
<string name="kanban_bulk_delete">删除</string>
<string name="kanban_bulk_delete_confirm">确定要删除所选的 %1$d 个任务吗?此操作无法撤销。</string>
<string name="kanban_export_board">导出看板</string>
<string name="kanban_import_board">导入看板</string>
<string name="kanban_runtime_working">运行中</string>
<string name="kanban_runtime_stale">无响应 (心跳超时)</string>
<string name="kanban_runtime_wont_run">无法运行:未指定负责人</string>
<string name="kanban_runtime_elapsed">· %1$s</string>
<string name="kanban_runtime_stale_badge">· 无响应</string>
<string name="kanban_runtime_wont_run_badge">· 无法运行</string>
<string name="kanban_task_actions">任务操作</string>
<string name="kanban_reclaim_task">回收任务</string>
<string name="kanban_no_comments">暂无评论。</string>
<string name="kanban_write_comment">写下评论……</string>
<string name="kanban_note_requeue">备注并重新排队</string>
<string name="kanban_comment">评论</string>
<string name="kanban_worker_log">工作器日志输出</string>
<string name="kanban_event_timeline">事件时间线</string>
<string name="kanban_no_activity">暂无活动事件。</string>
<string name="kanban_attachments">附件</string>
<string name="kanban_upload">上传</string>
<string name="kanban_no_attachments">暂无已上传附件。</string>
<string name="kanban_clear_all">全部清除</string>
<string name="kanban_skills_placeholder">例如:git、coding、tests</string>
<string name="kanban_profile_description_hint">用于路由的配置文件描述</string>
<string name="kanban_filter">筛选</string>
<string name="kanban_board_options">看板选项</string>
<string name="kanban_new_board">新建看板</string>
<string name="kanban_clear_selection">清除</string>
<string name="kanban_select_all">全部</string>
<string name="kanban_move">移动</string>
<string name="kanban_close_multi_select">关闭多选</string>
<string name="kanban_move_tasks">移动 %1$d 个任务</string>
<string name="kanban_create_new_board">创建新看板</string>
<string name="kanban_slug">Slug(ID)</string>
<string name="kanban_display_name">显示名称</string>
<string name="kanban_filters">筛选</string>
<string name="kanban_confirm_action">确认操作</string>
<string name="kanban_edit_description">编辑描述</string>
<string name="kanban_task_details">任务详情</string>
<string name="kanban_tab_overview">概览</string>
<string name="kanban_tab_discussion">讨论</string>
<string name="kanban_tab_runs">执行</string>
<string name="kanban_tab_activity">活动</string>
<string name="kanban_tab_files">文件</string>
<string name="kanban_event_created">创建于 %1$s</string>
<string name="kanban_event_created_by">由 %2$s 创建于 %1$s</string>
<string name="kanban_event_moved">移动到 %1$s</string>
<string name="kanban_event_assigned">分配给 %1$s</string>
<string name="kanban_event_unassigned">未分配</string>
<string name="kanban_event_commented">%1$s 的评论</string>
<string name="kanban_event_claimed_review">已领取进行审核</string>
<string name="kanban_event_claimed_worker">已由工作器领取</string>
<string name="kanban_event_worker_started">工作器已启动</string>
<string name="kanban_event_completed">已完成</string>
<string name="kanban_event_blocked">已阻塞</string>
<string name="kanban_event_unblocked">已解除阻塞</string>
<string name="kanban_event_unblocked_to">已解除阻塞(%1$s)</string>
<string name="kanban_event_reclaimed">已回收</string>
<string name="kanban_event_specified">已指定</string>
<string name="kanban_event_promoted">已提升为就绪</string>
<string name="kanban_event_scheduled">已计划</string>
<string name="kanban_event_archived">已归档</string>
<string name="kanban_event_priority">优先级设置为 %1$s</string>
<string name="kanban_workspace_scratch">临时(默认)</string>
<string name="kanban_workspace_worktree">工作树</string>
<string name="kanban_workspace_dir">目录</string>
<string name="kanban_unassigned_group">未分配</string>
<string name="kanban_expand_column">展开 %1$s</string>
<string name="kanban_collapse_column">折叠 %1$s</string>
<string name="kanban_add_to_column">添加任务到 %1$s</string>
<string name="action_add">添加</string>
<string name="skills_status_all">全部状态</string>
<string name="skills_status_enabled">已启用</string>
+127
View File
@@ -359,6 +359,7 @@
<string name="kanban_priority_high">2 (High)</string>
<string name="kanban_priority_urgent">3 (Urgent)</string>
<string name="kanban_assignee">Assignee</string>
<string name="kanban_assignee_default_suffix">%1$s (Default)</string>
<string name="kanban_assignee_unassigned">Default (Unassigned)</string>
<string name="kanban_assignee_parked">Parked (No worker)</string>
<string name="kanban_goal_mode">Autonomous Goal Mode</string>
@@ -370,8 +371,134 @@
<string name="kanban_provider_override">Provider Override</string>
<string name="kanban_reasoning_effort">Reasoning Effort</string>
<string name="kanban_workspace_kind">Workspace Kind</string>
<string name="kanban_workspace_path">Workspace Path Override</string>
<string name="kanban_workspace_path_hint">Optional path (defaults to project dir)</string>
<string name="kanban_skills">Skills</string>
<string name="kanban_target_column">Column</string>
<string name="kanban_model_inherit">Inherit profile</string>
<string name="kanban_model_clear">Clear model override</string>
<string name="kanban_select_model">Select Model</string>
<string name="kanban_effort_inherit">Inherit</string>
<string name="kanban_effort_none">None</string>
<string name="kanban_effort_low">Low</string>
<string name="kanban_effort_medium">Medium</string>
<string name="kanban_effort_high">High</string>
<string name="kanban_effort_minimal">Minimal</string>
<string name="kanban_effort_xhigh">XHigh</string>
<string name="kanban_effort_max">Max</string>
<string name="kanban_effort_ultra">Ultra</string>
<string name="kanban_show_archived">Show archived tasks</string>
<string name="kanban_group_running">Group running lane by profile</string>
<string name="kanban_all_assignees">All assignees</string>
<string name="kanban_tenant">Tenant</string>
<string name="kanban_all_tenants">All tenants</string>
<string name="kanban_execution_runs">Execution Runs</string>
<string name="kanban_no_run_history">No run history available.</string>
<string name="kanban_run_number">Run #%1$d</string>
<string name="kanban_run_profile">Profile: %1$s</string>
<string name="kanban_run_outcome">Outcome: %1$s</string>
<string name="kanban_run_duration">Duration: %1$s</string>
<string name="kanban_run_error">Error: %1$s</string>
<string name="kanban_run_summary">Summary: %1$s</string>
<string name="kanban_project">Project Scope</string>
<string name="kanban_no_project">No project</string>
<string name="kanban_archive_board">Archive Board</string>
<string name="kanban_archive_board_confirm">Are you sure you want to archive board \'%1$s\'? You can recover it later from archived boards.</string>
<string name="kanban_board_settings">Board Settings</string>
<string name="kanban_board_name">Board Name</string>
<string name="kanban_description">Description</string>
<string name="kanban_default_workdir">Default Workdir</string>
<string name="kanban_workdir_example">e.g. /home/user/project</string>
<string name="kanban_orchestration_settings">Orchestration Settings</string>
<string name="kanban_orchestrator_profile">Orchestrator Profile</string>
<string name="kanban_default_assignee">Default Assignee</string>
<string name="kanban_auto_decompose">Auto-decompose</string>
<string name="kanban_profile_descriptions">Profile Descriptions</string>
<string name="kanban_auto_describe">Auto-describe</string>
<string name="kanban_dependencies">Dependencies</string>
<string name="kanban_blocked_by">Blocked by</string>
<string name="kanban_blocks">Blocks</string>
<string name="kanban_estimate">Effort Estimation</string>
<string name="kanban_estimate_action">Estimate effort</string>
<string name="kanban_reestimate_action">Re-estimate</string>
<string name="kanban_estimated_tokens">Tokens: %1$d</string>
<string name="kanban_complexity">Complexity: %1$s</string>
<string name="kanban_rationale">Rationale</string>
<string name="kanban_delete_task">Delete Task</string>
<string name="kanban_delete_task_confirm">Are you sure you want to delete this task? This cannot be undone.</string>
<string name="kanban_bulk_assign">Assign</string>
<string name="kanban_bulk_unassign">Unassign</string>
<string name="kanban_bulk_delete">Delete</string>
<string name="kanban_bulk_delete_confirm">Delete %1$d selected tasks? This cannot be undone.</string>
<string name="kanban_export_board">Export Board</string>
<string name="kanban_import_board">Import Board</string>
<string name="kanban_runtime_working">Working</string>
<string name="kanban_runtime_stale">Stale (no heartbeat)</string>
<string name="kanban_runtime_wont_run">Won\'t run: unassigned</string>
<string name="kanban_runtime_elapsed">· %1$s</string>
<string name="kanban_runtime_stale_badge">· STALE</string>
<string name="kanban_runtime_wont_run_badge">· WON\'T RUN</string>
<string name="kanban_task_actions">Task actions</string>
<string name="kanban_reclaim_task">Reclaim task</string>
<string name="kanban_no_comments">No comments yet.</string>
<string name="kanban_write_comment">Write a comment...</string>
<string name="kanban_note_requeue">Note &amp; Requeue</string>
<string name="kanban_comment">Comment</string>
<string name="kanban_worker_log">Worker Log Output</string>
<string name="kanban_event_timeline">Event Timeline</string>
<string name="kanban_no_activity">No activity events.</string>
<string name="kanban_attachments">Attachments</string>
<string name="kanban_upload">Upload</string>
<string name="kanban_no_attachments">No attachments uploaded.</string>
<string name="kanban_clear_all">Clear all</string>
<string name="kanban_skills_placeholder">e.g. git, coding, tests</string>
<string name="kanban_profile_description_hint">Profile description for routing</string>
<string name="kanban_filter">Filter</string>
<string name="kanban_board_options">Board options</string>
<string name="kanban_new_board">New Board</string>
<string name="kanban_clear_selection">Clear</string>
<string name="kanban_select_all">All</string>
<string name="kanban_move">Move</string>
<string name="kanban_close_multi_select">Close multi-select</string>
<string name="kanban_move_tasks">Move %1$d tasks</string>
<string name="kanban_create_new_board">Create New Board</string>
<string name="kanban_slug">Slug (id)</string>
<string name="kanban_display_name">Display Name</string>
<string name="kanban_filters">Filters</string>
<string name="kanban_confirm_action">Confirm action</string>
<string name="kanban_edit_description">Edit Description</string>
<string name="kanban_task_details">Task Details</string>
<string name="kanban_tab_overview">Overview</string>
<string name="kanban_tab_discussion">Discussion</string>
<string name="kanban_tab_runs">Runs</string>
<string name="kanban_tab_activity">Activity</string>
<string name="kanban_tab_files">Files</string>
<string name="kanban_event_created">Created in %1$s</string>
<string name="kanban_event_created_by">Created in %1$s by %2$s</string>
<string name="kanban_event_moved">Moved to %1$s</string>
<string name="kanban_event_assigned">Assigned to %1$s</string>
<string name="kanban_event_unassigned">Unassigned</string>
<string name="kanban_event_commented">Comment by %1$s</string>
<string name="kanban_event_claimed_review">Claimed for review</string>
<string name="kanban_event_claimed_worker">Claimed by worker</string>
<string name="kanban_event_worker_started">Worker started</string>
<string name="kanban_event_completed">Completed</string>
<string name="kanban_event_blocked">Blocked</string>
<string name="kanban_event_unblocked">Unblocked</string>
<string name="kanban_event_unblocked_to">Unblocked (%1$s)</string>
<string name="kanban_event_reclaimed">Reclaimed</string>
<string name="kanban_event_specified">Specified</string>
<string name="kanban_event_promoted">Promoted to ready</string>
<string name="kanban_event_scheduled">Scheduled</string>
<string name="kanban_event_archived">Archived</string>
<string name="kanban_event_priority">Priority set to %1$s</string>
<string name="kanban_workspace_scratch">Scratch (Default)</string>
<string name="kanban_workspace_worktree">Worktree</string>
<string name="kanban_workspace_dir">Dir</string>
<string name="kanban_unassigned_group">Unassigned</string>
<string name="kanban_expand_column">Expand %1$s</string>
<string name="kanban_collapse_column">Collapse %1$s</string>
<string name="kanban_add_to_column">Add task to %1$s</string>
<string name="action_add">Add</string>
<!-- Skills Screen -->
<string name="skills_status_all">All Statuses</string>
@@ -289,4 +289,29 @@ class KanbanSerializationTest {
assertEquals("M", estimate.complexity)
assertEquals("Multi-file changes needed", estimate.rationale)
}
@Test
fun testBulkAssignmentUsesExplicitEmptyStringAndReclaim() {
val assignJson =
json.encodeToString(
BulkTasksBody(
ids = listOf("t_1"),
assignee = "researcher",
reclaimFirst = true,
),
)
val unassignJson =
json.encodeToString(
BulkTasksBody(
ids = listOf("t_1"),
assignee = "",
reclaimFirst = true,
),
)
assertTrue(assignJson.contains("\"assignee\":\"researcher\""))
assertTrue(assignJson.contains("\"reclaim_first\":true"))
assertTrue(unassignJson.contains("\"assignee\":\"\""))
assertTrue(unassignJson.contains("\"reclaim_first\":true"))
}
}
@@ -0,0 +1,63 @@
package com.m57.hermescontrol.ui.kanban
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class KanbanColumnCollapseHelperTest {
@Test
fun testBoardWithoutWorkDoesNotCollapse() {
assertFalse(
KanbanColumnCollapseHelper.isColumnCollapsed(
columnName = "todo",
columnTaskCount = 0,
boardHasWork = false,
overrides = emptyMap(),
),
)
}
@Test
fun testBoardWithWorkAutoCollapsesEmptyColumn() {
assertTrue(
KanbanColumnCollapseHelper.isColumnCollapsed(
columnName = "todo",
columnTaskCount = 0,
boardHasWork = true,
overrides = emptyMap(),
),
)
assertFalse(
KanbanColumnCollapseHelper.isColumnCollapsed(
columnName = "ready",
columnTaskCount = 3,
boardHasWork = true,
overrides = emptyMap(),
),
)
}
@Test
fun testManualOverrideTakesPrecedence() {
// Force expand an empty column
assertFalse(
KanbanColumnCollapseHelper.isColumnCollapsed(
columnName = "todo",
columnTaskCount = 0,
boardHasWork = true,
overrides = mapOf("todo" to false),
),
)
// Force collapse an occupied column
assertTrue(
KanbanColumnCollapseHelper.isColumnCollapsed(
columnName = "ready",
columnTaskCount = 3,
boardHasWork = true,
overrides = mapOf("ready" to true),
),
)
}
}
@@ -0,0 +1,174 @@
package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.CreateTaskResponse
import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.repository.KanbanRepository
import com.m57.hermescontrol.data.ws.KanbanEventsClient
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class KanbanCreateTaskPayloadTest {
private val testDispatcher = StandardTestDispatcher()
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
private fun createViewModel(): KanbanViewModel =
KanbanViewModel(
repository = mockRepository,
eventsClientProvider = { mockEventsClient },
)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
@Test
fun testCreateTaskPayloadWithAllFields() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev")
val newTask = KanbanTask(id = "t_100", title = "Full task", status = "todo")
coEvery { mockRepository.getBoard("dev", any(), any()) } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
coEvery { mockRepository.createTask("dev", any()) } returns
NetworkResult.Success(CreateTaskResponse(task = newTask))
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
val body =
CreateTaskBody(
title = "Full task",
body = "Rich details",
assignee = "researcher",
priority = 2,
goalMode = true,
parents = listOf("p_1", "p_2"),
modelOverride = "claude-3-5-sonnet",
providerOverride = "anthropic",
reasoningEffort = "high",
workspaceKind = "worktree",
workspacePath = "/custom/wt/path",
skills = listOf("git", "testing"),
triage = true,
)
vm.createTask(body, targetStatus = "triage")
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
mockRepository.createTask(
"dev",
match {
it.title == "Full task" &&
it.body == "Rich details" &&
it.assignee == "researcher" &&
it.priority == 2 &&
it.goalMode &&
it.parents == listOf("p_1", "p_2") &&
it.modelOverride == "claude-3-5-sonnet" &&
it.providerOverride == "anthropic" &&
it.reasoningEffort == "high" &&
it.workspaceKind == "worktree" &&
it.workspacePath == "/custom/wt/path" &&
it.skills == listOf("git", "testing") &&
it.triage
},
)
}
}
@Test
fun testScratchWorkspaceNeverSendsPath() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev")
coEvery { mockRepository.getBoard("dev", any(), any()) } returns
NetworkResult.Success(KanbanBoardResponse(columns = emptyList()))
coEvery { mockRepository.createTask("dev", any()) } returns
NetworkResult.Success(
CreateTaskResponse(task = KanbanTask(id = "t_1", title = "Task", status = "todo")),
)
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
// In our dialog logic, scratch sets workspaceKind = null (or scratch) and workspacePath = null
val body =
CreateTaskBody(
title = "Scratch Task",
workspaceKind = null,
workspacePath = null,
)
vm.createTask(body)
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
mockRepository.createTask(
"dev",
match {
it.workspaceKind == null && it.workspacePath == null
},
)
}
}
@Test
fun testParkedAssigneeSendsNull() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev")
coEvery { mockRepository.getBoard("dev", any(), any()) } returns
NetworkResult.Success(KanbanBoardResponse(columns = emptyList()))
coEvery { mockRepository.createTask("dev", any()) } returns
NetworkResult.Success(
CreateTaskResponse(task = KanbanTask(id = "t_1", title = "Task", status = "todo")),
)
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
val body =
CreateTaskBody(
title = "Parked Task",
assignee = null, // Parked
)
vm.createTask(body)
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
mockRepository.createTask(
"dev",
match { it.assignee == null },
)
}
}
}
@@ -0,0 +1,89 @@
package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.model.DispatchResult
import com.m57.hermescontrol.data.remote.KanbanApiService
import com.m57.hermescontrol.data.repository.DefaultKanbanDispatcherNudger
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Test
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class KanbanDispatcherNudgerTest {
private val testDispatcher = StandardTestDispatcher()
private val testScope = TestScope(testDispatcher)
private val mockApi = mockk<KanbanApiService>()
@Test
fun testNudgeDebouncingSchedulesSingleCall() =
testScope.runTest {
coEvery { mockApi.nudgeDispatcher(any()) } returns Response.success(DispatchResult())
val nudger =
DefaultKanbanDispatcherNudger(
apiProvider = { mockApi },
scope = this,
debounceMs = 400L,
)
nudger.scheduleNudge("board-a")
advanceTimeBy(100L)
nudger.scheduleNudge("board-a")
advanceTimeBy(100L)
nudger.scheduleNudge("board-a")
// Before 400ms passes from last call
advanceTimeBy(300L)
coVerify(exactly = 0) { mockApi.nudgeDispatcher("board-a") }
// After 400ms passes
advanceTimeBy(150L)
coVerify(exactly = 1) { mockApi.nudgeDispatcher("board-a") }
}
@Test
fun testNudgeScopedPerBoard() =
testScope.runTest {
coEvery { mockApi.nudgeDispatcher(any()) } returns Response.success(DispatchResult())
val nudger =
DefaultKanbanDispatcherNudger(
apiProvider = { mockApi },
scope = this,
debounceMs = 400L,
)
nudger.scheduleNudge("board-a")
nudger.scheduleNudge("board-b")
advanceTimeBy(450L)
coVerify(exactly = 1) { mockApi.nudgeDispatcher("board-a") }
coVerify(exactly = 1) { mockApi.nudgeDispatcher("board-b") }
}
@Test
fun testNudgeFailureAbsorbedSilently() =
testScope.runTest {
coEvery { mockApi.nudgeDispatcher(any()) } throws RuntimeException("Network timeout")
val nudger =
DefaultKanbanDispatcherNudger(
apiProvider = { mockApi },
scope = this,
debounceMs = 100L,
)
nudger.scheduleNudge("board-a")
advanceTimeBy(150L)
coVerify(exactly = 1) { mockApi.nudgeDispatcher("board-a") }
// No unhandled exception thrown
}
}
@@ -0,0 +1,197 @@
package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.model.KanbanDetailEvent
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class KanbanEventFormatterTest {
@Test
fun testCreatedEvent() {
val payload =
buildJsonObject {
put("status", "ready")
put("assignee", "coder")
}
val event = KanbanDetailEvent(id = 1, kind = "created", payload = payload, createdAt = 1000)
val formatted = KanbanEventFormatter.format(event)
assertEquals("Created in Ready by coder", formatted.label)
assertNull(formatted.detail)
}
@Test
fun testStatusEventWithReason() {
val payload =
buildJsonObject {
put("status", "done")
put("reason", "completed successfully")
}
val event = KanbanDetailEvent(id = 2, kind = "status", payload = payload, createdAt = 1000)
val formatted = KanbanEventFormatter.format(event)
assertEquals("Moved to Done", formatted.label)
assertEquals("completed successfully", formatted.detail)
}
@Test
fun testParentReopenedStatusEvent() {
val payload =
buildJsonObject {
put("status", "todo")
put("reason", "parent_reopened")
put("parent", "t_parent1")
}
val event = KanbanDetailEvent(id = 3, kind = "status", payload = payload, createdAt = 1000)
val formatted = KanbanEventFormatter.format(event)
assertEquals("Moved to Todo", formatted.label)
assertEquals("Parent t_parent1 reopened", formatted.detail)
}
@Test
fun testAssignedEvent() {
val payload = buildJsonObject { put("assignee", "reviewer") }
val event = KanbanDetailEvent(id = 4, kind = "assigned", payload = payload, createdAt = 1000)
val formatted = KanbanEventFormatter.format(event)
assertEquals("Assigned to reviewer", formatted.label)
val unassignedEvent = KanbanDetailEvent(id = 5, kind = "assigned", payload = null, createdAt = 1000)
assertEquals("Unassigned", KanbanEventFormatter.format(unassignedEvent).label)
}
@Test
fun testClaimedReviewAndWorker() {
val reviewClaim =
KanbanDetailEvent(
id = 6,
kind = "claimed",
payload = buildJsonObject { put("source_status", "review") },
createdAt = 1000,
)
assertEquals("Claimed for review", KanbanEventFormatter.format(reviewClaim).label)
val workerClaim =
KanbanDetailEvent(
id = 7,
kind = "claimed",
payload = buildJsonObject { put("source_status", "ready") },
createdAt = 1000,
)
assertEquals("Claimed by worker", KanbanEventFormatter.format(workerClaim).label)
}
@Test
fun testSpawnedEvent() {
val event =
KanbanDetailEvent(
id = 8,
kind = "spawned",
payload = buildJsonObject { put("pid", 12345) },
createdAt = 1000,
)
val formatted = KanbanEventFormatter.format(event)
assertEquals("Worker started", formatted.label)
assertEquals("PID 12345", formatted.detail)
}
@Test
fun testLifecycleEvents() {
assertEquals("Completed", KanbanEventFormatter.format(KanbanDetailEvent(9, "completed", null, 1000)).label)
assertEquals(
"Blocked",
KanbanEventFormatter
.format(
KanbanDetailEvent(
10,
"blocked",
buildJsonObject { put("reason", "waiting on CI") },
1000,
),
).label,
)
assertEquals(
"Unblocked (Ready)",
KanbanEventFormatter
.format(
KanbanDetailEvent(
11,
"unblocked",
buildJsonObject { put("status", "ready") },
1000,
),
).label,
)
assertEquals(
"Reclaimed",
KanbanEventFormatter
.format(
KanbanDetailEvent(
12,
"reclaimed",
buildJsonObject { put("reason", "timeout") },
1000,
),
).label,
)
assertEquals("Specified", KanbanEventFormatter.format(KanbanDetailEvent(13, "specified", null, 1000)).label)
assertEquals(
"Promoted to ready",
KanbanEventFormatter.format(KanbanDetailEvent(14, "promoted", null, 1000)).label,
)
assertEquals("Archived", KanbanEventFormatter.format(KanbanDetailEvent(15, "archived", null, 1000)).label)
assertEquals(
"Priority set to 2",
KanbanEventFormatter
.format(
KanbanDetailEvent(
16,
"reprioritized",
buildJsonObject { put("priority", 2) },
1000,
),
).label,
)
}
@Test
fun testUnknownEventWithScalarPayload() {
val payload =
buildJsonObject {
put("foo", "bar")
put("count", 42)
}
val event = KanbanDetailEvent(id = 17, kind = "custom_hook", payload = payload, createdAt = 1000)
val formatted = KanbanEventFormatter.format(event)
assertEquals("Custom hook", formatted.label)
assertEquals("foo=bar count=42", formatted.detail)
}
@Test
fun testStringJsonPayload() {
val event =
KanbanDetailEvent(
id = 18,
kind = "status",
payload = JsonPrimitive("{\"status\":\"blocked\",\"reason\":\"rate limit\"}"),
createdAt = 1000,
)
val formatted = KanbanEventFormatter.format(event)
assertEquals("Moved to Blocked", formatted.label)
assertEquals("rate limit", formatted.detail)
}
@Test
fun testMalformedPayloadGraceful() {
val event =
KanbanDetailEvent(
id = 19,
kind = "custom_test",
payload = JsonPrimitive("{not valid json"),
createdAt = 1000,
)
val formatted = KanbanEventFormatter.format(event)
assertEquals("Custom test", formatted.label)
assertEquals("raw={not valid json", formatted.detail)
}
}
@@ -0,0 +1,108 @@
package com.m57.hermescontrol.ui.kanban
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class KanbanImportCleanupTest {
@Test
fun cleansAfterSuccess() =
runTest {
var cleaned = false
val result =
withKanbanImportCleanup(
cleanup = { cleaned = true },
onCleanupFailure = { throw AssertionError(it) },
dispatcher = StandardTestDispatcher(testScheduler),
) { "imported" }
assertEquals("imported", result)
assertTrue(cleaned)
}
@Test
fun cleansAfterImportFailureWithoutMaskingIt() =
runTest {
val failure = IllegalStateException("import failed")
var cleaned = false
try {
withKanbanImportCleanup(
cleanup = { cleaned = true },
onCleanupFailure = { throw AssertionError(it) },
dispatcher = StandardTestDispatcher(testScheduler),
) { throw failure }
} catch (e: IllegalStateException) {
assertSame(failure, e)
}
assertTrue(cleaned)
}
@Test
fun cancellationWaitsForSuspendingCleanupAndRemainsCancelled() =
runTest {
var cleaned = false
var cancellationPropagated = false
val job =
launch {
try {
withKanbanImportCleanup(
cleanup = {
delay(100)
cleaned = true
},
onCleanupFailure = { throw AssertionError(it) },
dispatcher = StandardTestDispatcher(testScheduler),
) { awaitCancellation() }
} catch (e: CancellationException) {
cancellationPropagated = true
throw e
}
}
runCurrent()
job.cancel()
advanceUntilIdle()
assertTrue(cleaned)
assertTrue(cancellationPropagated)
assertTrue(job.isCancelled)
}
@Test
fun cleanupFailureIsReportedWithoutMaskingImportResult() =
runTest {
val failure = IllegalStateException("HTTP 500")
var reported: Exception? = null
val result =
withKanbanImportCleanup(
cleanup = { throw failure },
onCleanupFailure = { reported = it },
dispatcher = StandardTestDispatcher(testScheduler),
) { "imported" }
assertEquals("imported", result)
// Coroutine stack-trace recovery can copy exceptions across dispatcher boundaries.
assertEquals(failure.message, reported?.message)
assertTrue(reported is IllegalStateException)
}
@Test
fun cleanupIsBounded() =
runTest {
var reported = false
withKanbanImportCleanup(
cleanup = { awaitCancellation() },
onCleanupFailure = { reported = true },
dispatcher = StandardTestDispatcher(testScheduler),
) { Unit }
assertTrue(reported)
assertEquals(10_000L, testScheduler.currentTime)
}
}
@@ -0,0 +1,50 @@
package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.model.KanbanTask
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class KanbanLaneGroupingTest {
@Test
fun testGroupRunningTasksSeparatesProfilesDeterministically() {
val tasks =
listOf(
KanbanTask(id = "t1", title = "Task 1", status = "running", assignee = "reviewer"),
KanbanTask(id = "t2", title = "Task 2", status = "running", assignee = "coder"),
KanbanTask(id = "t3", title = "Task 3", status = "running", assignee = null),
KanbanTask(id = "t4", title = "Task 4", status = "running", assignee = "coder"),
)
val groups = KanbanLaneGrouping.groupRunningTasks(tasks)
// Coder first (alphabetical), then Reviewer, then Unassigned
assertEquals(3, groups.size)
assertEquals("coder", groups[0].assignee)
assertEquals(listOf("t2", "t4"), groups[0].tasks.map { it.id })
assertEquals("reviewer", groups[1].assignee)
assertEquals(listOf("t1"), groups[1].tasks.map { it.id })
assertNull(groups[2].assignee)
assertEquals(listOf("t3"), groups[2].tasks.map { it.id })
}
@Test
fun testGroupRunningTasksWithoutUnassigned() {
val tasks =
listOf(
KanbanTask(id = "t1", title = "Task 1", status = "running", assignee = "coder"),
)
val groups = KanbanLaneGrouping.groupRunningTasks(tasks)
assertEquals(1, groups.size)
assertEquals("coder", groups[0].assignee)
}
@Test
fun testGroupRunningTasksEmpty() {
val groups = KanbanLaneGrouping.groupRunningTasks(emptyList())
assertEquals(0, groups.size)
}
}
@@ -0,0 +1,82 @@
package com.m57.hermescontrol.ui.kanban
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class KanbanModelOverrideTest {
@Test
fun testEmptyOverrideIsInherited() {
val override = KanbanModelOverride.EMPTY
assertTrue(override.isInherited)
assertEquals("Inherit profile", override.displayLabel("Inherit profile"))
val patch = override.toUpdateTaskBody()
assertNull(patch.modelOverride)
assertNull(patch.providerOverride)
assertTrue(patch.clearModelOverride)
assertNull(patch.reasoningEffort)
assertTrue(patch.clearReasoningEffort)
}
@Test
fun testCustomModelAndEffortPatch() {
val override =
KanbanModelOverride(
model = "claude-3-5-sonnet",
provider = "anthropic",
effort = "high",
)
assertFalse(override.isInherited)
assertEquals("anthropic: claude-3-5-sonnet · High", override.displayLabel())
val patch = override.toUpdateTaskBody()
assertEquals("claude-3-5-sonnet", patch.modelOverride)
assertEquals("anthropic", patch.providerOverride)
assertFalse(patch.clearModelOverride)
assertEquals("high", patch.reasoningEffort)
assertFalse(patch.clearReasoningEffort)
}
@Test
fun testExplicitNoneReasoningEffort() {
val override =
KanbanModelOverride(
model = "gpt-4o",
provider = "openai",
effort = "none",
)
assertEquals("openai: gpt-4o · None", override.displayLabel())
val patch = override.toUpdateTaskBody()
assertEquals("none", patch.reasoningEffort)
assertFalse(patch.clearReasoningEffort)
}
@Test
fun testModelClearedLeavesEffortIntact() {
val override = KanbanModelOverride(model = "", provider = "", effort = "low")
val patch = override.toUpdateTaskBody()
assertTrue(patch.clearModelOverride)
assertEquals("low", patch.reasoningEffort)
assertFalse(patch.clearReasoningEffort)
}
@Test
fun testFullReasoningScaleAndCapabilities() {
assertEquals(
listOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"),
supportedKanbanReasoningEfforts(supportsReasoning = true, canDisableReasoning = true),
)
assertEquals(
listOf("minimal", "low", "medium", "high", "xhigh", "max", "ultra"),
supportedKanbanReasoningEfforts(supportsReasoning = true, canDisableReasoning = false),
)
assertEquals(
listOf(""),
supportedKanbanReasoningEfforts(supportsReasoning = false, canDisableReasoning = false),
)
}
}
@@ -374,6 +374,40 @@ class KanbanMutationTest {
assertEquals("Board created: Sprint 2", vm.uiState.value.toastMessage)
}
@Test
fun testBulkAssignUsesReclaimAndRetainsOnlyFailures() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev")
coEvery { mockRepository.getBoard("dev") } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
coEvery { mockRepository.bulkTasks("dev", any()) } returns
NetworkResult.Success(
BulkTasksResponse(
results =
listOf(
BulkTaskResult("t_1", ok = true),
BulkTaskResult("t_2", ok = false, error = "locked"),
),
),
)
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
var failedIds = emptySet<String>()
vm.bulkAssign(listOf("t_1", "t_2"), assignee = null) { failedIds = it }
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
mockRepository.bulkTasks(
"dev",
match { it.ids == listOf("t_1", "t_2") && it.assignee == "" && it.reclaimFirst },
)
}
assertEquals(setOf("t_2"), failedIds)
}
@Test
fun testRenameBoardSuccess() =
runTest(testDispatcher) {
@@ -393,26 +427,122 @@ class KanbanMutationTest {
coVerify {
mockRepository.updateBoard("dev", match { it.name == "Dev Renamed" })
}
assertEquals("Board renamed to Dev Renamed", vm.uiState.value.toastMessage)
assertEquals("Board updated: Dev Renamed", vm.uiState.value.toastMessage)
}
@Test
fun testDeleteBoardSuccess() =
runTest(testDispatcher) {
coEvery { mockRepository.deleteBoard("old-board", delete = true) } returns
coEvery { mockRepository.deleteBoard("old-board", delete = false) } returns
NetworkResult.Success(DeleteBoardResponse(current = null))
coEvery { mockRepository.getBoards() } returns
coEvery { mockRepository.getBoards(any()) } returns
NetworkResult.Success(
com.m57.hermescontrol.data.model
.KanbanBoardsResponse(emptyList(), null),
)
val vm = createViewModel()
preferences.setSelectedBoard("http://127.0.0.1:9119", "old-board")
vm.deleteBoard("old-board")
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockRepository.deleteBoard("old-board", delete = true) }
assertEquals("Board deleted", vm.uiState.value.toastMessage)
coVerify { mockRepository.deleteBoard("old-board", delete = false) }
assertEquals("Board archived", vm.uiState.value.toastMessage)
assertEquals(null, preferences.getSelectedBoard("http://127.0.0.1:9119"))
}
@Test
fun testDeleteDefaultBoardRefused() =
runTest(testDispatcher) {
val vm = createViewModel()
vm.deleteBoard("default")
testDispatcher.scheduler.advanceUntilIdle()
coVerify(exactly = 0) { mockRepository.deleteBoard(any(), any()) }
assertTrue(
vm.uiState.value.toastMessage
?.contains("default board") == true,
)
}
@Test
fun testSetIncludeArchivedTogglesAndReloadsBoard() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev")
coEvery { mockRepository.getBoard(board = "dev", includeArchived = any(), any()) } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
vm.setIncludeArchived(true)
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(vm.uiState.value.includeArchived)
coVerify { mockRepository.getBoard(board = "dev", includeArchived = true, any()) }
vm.setIncludeArchived(false)
testDispatcher.scheduler.advanceUntilIdle()
assertFalse(vm.uiState.value.includeArchived)
coVerify { mockRepository.getBoard(board = "dev", includeArchived = false, any()) }
}
@Test
fun testBulkMovePartialFailureKeepsFailedIds() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "dev", name = "Dev")
coEvery { mockRepository.getBoard("dev", any(), any()) } returns
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("done", emptyList()))))
coEvery { mockRepository.bulkTasks("dev", any()) } returns
NetworkResult.Success(
BulkTasksResponse(
listOf(
BulkTaskResult("t_1", true),
BulkTaskResult("t_2", false, "Conflict"),
),
),
)
val vm = createViewModel()
vm.selectBoard(board)
testDispatcher.scheduler.advanceUntilIdle()
var reportedFailed: Set<String>? = null
vm.bulkMove(listOf("t_1", "t_2"), "done") { failed ->
reportedFailed = failed
}
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(setOf("t_2"), reportedFailed)
assertTrue(
vm.uiState.value.toastMessage
?.contains("1 failed") == true,
)
}
@Test
fun testCreateBoardWithProject() =
runTest(testDispatcher) {
val board = KanbanBoard(id = "scoped", name = "Scoped", projectId = "proj-123")
coEvery { mockRepository.createBoard(any()) } returns
NetworkResult.Success(CreateBoardResponse(board = board))
coEvery { mockRepository.getBoards(any()) } returns
NetworkResult.Success(
com.m57.hermescontrol.data.model
.KanbanBoardsResponse(listOf(board), "scoped"),
)
val vm = createViewModel()
vm.createBoard(slug = "scoped", name = "Scoped", projectId = "proj-123")
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
mockRepository.createBoard(
match { it.slug == "scoped" && it.projectId == "proj-123" },
)
}
}
@Test
@@ -0,0 +1,71 @@
package com.m57.hermescontrol.ui.kanban
import com.m57.hermescontrol.data.model.KanbanTask
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class KanbanRuntimeHelperTest {
@Test
fun testRunningTaskWithFreshHeartbeat() {
val now = 1000L
val task = KanbanTask(id = "t1", title = "Task 1", status = "running", lastHeartbeatAt = now - 30)
val state = KanbanRuntimeHelper.arcState(task, nowSeconds = now)
assertEquals(KanbanArcState.RUNNING, state)
}
@Test
fun testRunningTaskWithStaleHeartbeat() {
val now = 1000L
val task = KanbanTask(id = "t1", title = "Task 1", status = "running", lastHeartbeatAt = now - 150)
val state = KanbanRuntimeHelper.arcState(task, nowSeconds = now)
assertEquals(KanbanArcState.STALE, state)
}
@Test
fun testQueuedTask() {
val triageTask = KanbanTask(id = "t1", title = "Task 1", status = "triage")
assertEquals(KanbanArcState.QUEUED, KanbanRuntimeHelper.arcState(triageTask))
val reviewTask = KanbanTask(id = "t2", title = "Task 2", status = "review")
assertEquals(KanbanArcState.QUEUED, KanbanRuntimeHelper.arcState(reviewTask))
val readyAssigned = KanbanTask(id = "t3", title = "Task 3", status = "ready", assignee = "coder")
assertEquals(KanbanArcState.QUEUED, KanbanRuntimeHelper.arcState(readyAssigned))
val readyWithFallback = KanbanTask(id = "t4", title = "Task 4", status = "ready", assignee = null)
assertEquals(
KanbanArcState.QUEUED,
KanbanRuntimeHelper.arcState(readyWithFallback, fallbackAssignee = "default"),
)
val readyUnassigned = KanbanTask(id = "t5", title = "Task 5", status = "ready", assignee = null)
assertNull(KanbanRuntimeHelper.arcState(readyUnassigned, fallbackAssignee = null))
}
@Test
fun testIsWontRun() {
val readyUnassigned = KanbanTask(id = "t1", title = "Task 1", status = "ready", assignee = null)
assertTrue(KanbanRuntimeHelper.isWontRun(readyUnassigned, fallbackAssignee = null))
assertFalse(KanbanRuntimeHelper.isWontRun(readyUnassigned, fallbackAssignee = "default"))
val readyAssigned = KanbanTask(id = "t2", title = "Task 2", status = "ready", assignee = "coder")
assertFalse(KanbanRuntimeHelper.isWontRun(readyAssigned, fallbackAssignee = null))
val todoUnassigned = KanbanTask(id = "t3", title = "Task 3", status = "todo", assignee = null)
assertFalse(KanbanRuntimeHelper.isWontRun(todoUnassigned))
}
@Test
fun testFormatElapsed() {
val now = 200_000L
assertEquals("45s", KanbanRuntimeHelper.formatElapsed(now - 45, nowSeconds = now))
assertEquals("5m", KanbanRuntimeHelper.formatElapsed(now - 300, nowSeconds = now))
assertEquals("2h", KanbanRuntimeHelper.formatElapsed(now - 7200, nowSeconds = now))
assertEquals("1d", KanbanRuntimeHelper.formatElapsed(now - 90000, nowSeconds = now))
assertNull(KanbanRuntimeHelper.formatElapsed(null))
assertNull(KanbanRuntimeHelper.formatElapsed(now + 10, nowSeconds = now))
}
}
@@ -8,12 +8,14 @@ import com.m57.hermescontrol.data.model.UpdateTaskResponse
import com.m57.hermescontrol.data.model.WorkerLog
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.repository.KanbanRepository
import com.m57.hermescontrol.data.ws.KanbanEventsClient
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
@@ -28,10 +30,13 @@ import org.junit.Test
class KanbanTaskViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
private fun createViewModel(): KanbanTaskViewModel =
private fun createViewModel(enableFallbackPolling: Boolean = false): KanbanTaskViewModel =
KanbanTaskViewModel(
repository = mockRepository,
eventsClientProvider = { mockEventsClient },
enableFallbackPolling = enableFallbackPolling,
)
@Before
@@ -75,7 +80,7 @@ class KanbanTaskViewModelTest {
val vm = createViewModel()
vm.loadTask("dev", "t_1")
testDispatcher.scheduler.advanceUntilIdle()
testDispatcher.scheduler.runCurrent()
assertEquals(
"Log line 1",
@@ -83,6 +88,7 @@ class KanbanTaskViewModelTest {
?.content,
)
coVerify { mockRepository.getTaskLog("t_1", "dev", any()) }
vm.onCleared()
}
@Test
@@ -133,7 +139,7 @@ class KanbanTaskViewModelTest {
@Test
fun testPostNoteAndRequeue() =
runTest(testDispatcher) {
val task = KanbanTaskFull(id = "t_1", title = "Running task", status = "running")
val task = KanbanTaskFull(id = "t_1", title = "Task 1", status = "todo")
coEvery { mockRepository.getTask("t_1", "dev") } returns
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
coEvery { mockRepository.addComment("t_1", "dev", "Stop now") } returns
@@ -197,4 +203,122 @@ class KanbanTaskViewModelTest {
coVerify { mockRepository.uploadAttachment("t_1", "dev", any()) }
assertEquals("Attachment uploaded", vm.uiState.value.toastMessage)
}
@Test
fun testUpdateModelOverride() =
runTest(testDispatcher) {
val task = KanbanTaskFull(id = "t_1", title = "Task 1", status = "todo")
coEvery { mockRepository.getTask("t_1", "dev") } returns
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
coEvery { mockRepository.updateTask("t_1", "dev", any()) } returns
NetworkResult.Success(UpdateTaskResponse())
val vm = createViewModel()
vm.loadTask("dev", "t_1")
testDispatcher.scheduler.advanceUntilIdle()
val override = KanbanModelOverride(model = "claude-3-5-sonnet", provider = "anthropic", effort = "high")
vm.updateModelOverride("dev", "t_1", override)
testDispatcher.scheduler.advanceUntilIdle()
coVerify {
mockRepository.updateTask(
"t_1",
"dev",
match {
it.modelOverride == "claude-3-5-sonnet" &&
it.providerOverride == "anthropic" &&
it.reasoningEffort == "high" &&
!it.clearModelOverride
},
)
}
assertEquals("Model override updated", vm.uiState.value.toastMessage)
}
@Test
fun testEstimateTask() =
runTest(testDispatcher) {
val task = KanbanTaskFull(id = "t_1", title = "Task 1", status = "todo")
coEvery { mockRepository.getTask("t_1", "dev") } returns
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
coEvery { mockRepository.estimateTask("t_1", "dev") } returns
NetworkResult.Success(
com.m57.hermescontrol.data.model.TaskEstimate(
ok = true,
estTokens = 1500,
complexity = "medium",
),
)
val vm = createViewModel()
vm.loadTask("dev", "t_1")
testDispatcher.scheduler.advanceUntilIdle()
vm.estimateTask("dev", "t_1")
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockRepository.estimateTask("t_1", "dev") }
assertEquals(
1500,
vm.uiState.value.estimate
?.tokens,
)
assertEquals(
"medium",
vm.uiState.value.estimate
?.complexity,
)
}
@Test
fun testDeleteTask() =
runTest(testDispatcher) {
coEvery { mockRepository.deleteTask("t_1", "dev") } returns NetworkResult.Success(Unit)
val vm = createViewModel()
var deleted = false
vm.deleteTask("dev", "t_1") {
deleted = true
}
testDispatcher.scheduler.advanceUntilIdle()
coVerify { mockRepository.deleteTask("t_1", "dev") }
assertTrue(deleted)
assertEquals("Task deleted", vm.uiState.value.toastMessage)
}
@Test
fun testFallbackPollingReloadsTaskWithoutEvents() =
runTest(testDispatcher) {
val first = KanbanTaskFull(id = "t_1", title = "Before", status = "todo")
val second = KanbanTaskFull(id = "t_1", title = "After", status = "todo")
coEvery { mockRepository.getTask("t_1", "dev") } returnsMany
listOf(
NetworkResult.Success(KanbanTaskDetailResponse(task = first)),
NetworkResult.Success(KanbanTaskDetailResponse(task = second)),
)
val vm = createViewModel(enableFallbackPolling = true)
vm.loadTask("dev", "t_1")
testDispatcher.scheduler.runCurrent()
assertEquals(
"Before",
vm.uiState.value.detail
?.task
?.title,
)
testDispatcher.scheduler.advanceTimeBy(30_000L)
testDispatcher.scheduler.runCurrent()
assertEquals(
"After",
vm.uiState.value.detail
?.task
?.title,
)
coVerify(exactly = 2) { mockRepository.getTask("t_1", "dev") }
vm.onCleared()
}
}
@@ -0,0 +1,68 @@
package com.m57.hermescontrol.util
import android.content.ContentResolver
import android.net.Uri
import io.mockk.every
import io.mockk.mockk
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okio.Buffer
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.IOException
class StreamingUriRequestBodyTest {
@Test
fun testStreamsDirectlyIntoSink() {
val testData = "test archive data streaming content".toByteArray(Charsets.UTF_8)
val mockResolver = mockk<ContentResolver>()
val mockUri = mockk<Uri>()
every { mockResolver.openInputStream(mockUri) } answers {
ByteArrayInputStream(testData)
}
val mediaType = "application/gzip".toMediaTypeOrNull()
val requestBody =
StreamingUriRequestBody(
contentResolver = mockResolver,
uri = mockUri,
contentType = mediaType,
contentLength = testData.size.toLong(),
)
assertEquals(mediaType, requestBody.contentType())
assertEquals(testData.size.toLong(), requestBody.contentLength())
val buffer = Buffer()
requestBody.writeTo(buffer)
assertEquals("test archive data streaming content", buffer.readUtf8())
}
@Test
fun testThrowsWhenInputStreamCannotBeOpened() {
val mockResolver = mockk<ContentResolver>()
val mockUri = mockk<Uri>()
every { mockResolver.openInputStream(mockUri) } returns null
val requestBody =
StreamingUriRequestBody(
contentResolver = mockResolver,
uri = mockUri,
contentType = null,
)
var thrown = false
try {
val buffer = Buffer()
requestBody.writeTo(buffer)
} catch (e: IOException) {
thrown = true
}
assertTrue(thrown)
}
}