From 11512aeed46bbccdea0b8f9734d7d43590d4d0b4 Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:34:26 +0300 Subject: [PATCH] feat(kanban): add rich create task dialog and quick card action menu --- .../hermescontrol/ui/kanban/KanbanScreen.kt | 60 +-- .../ui/kanban/KanbanViewModel.kt | 54 +- .../components/KanbanCreateTaskDialog.kt | 489 ++++++++++++++++++ .../ui/kanban/components/KanbanTaskCard.kt | 31 +- app/src/main/res/values/strings.xml | 19 + .../ui/kanban/KanbanMutationTest.kt | 97 ++++ 6 files changed, 688 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt index 6a8edecb..46397afc 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt @@ -57,6 +57,7 @@ import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.ToastEffect +import com.m57.hermescontrol.ui.kanban.components.KanbanCreateTaskDialog import com.m57.hermescontrol.ui.kanban.components.KanbanFilterSheet import com.m57.hermescontrol.ui.kanban.components.KanbanTaskCard @@ -265,6 +266,9 @@ fun KanbanScreen( ) } }, + onActionClick = { clickedTask -> + taskForActions = clickedTask + }, ) } } @@ -277,10 +281,15 @@ fun KanbanScreen( } if (showAddTaskDialog) { - AddTaskDialog( + KanbanCreateTaskDialog( + columns = state.columns, + defaultColumn = state.columns.firstOrNull()?.name ?: DEFAULT_COLUMN, + profiles = state.profiles, + existingTasks = state.tasks, + isCreating = state.isCreatingTask, onDismiss = { showAddTaskDialog = false }, - onConfirm = { title, desc -> - viewModel.createTask(title, desc, state.columns.firstOrNull()?.name ?: DEFAULT_COLUMN) + onConfirm = { body, targetStatus -> + viewModel.createTask(body, targetStatus) showAddTaskDialog = false }, ) @@ -433,51 +442,6 @@ private fun LiveStatusPill(isLive: Boolean) { } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AddTaskDialog( - onDismiss: () -> Unit, - onConfirm: (title: String, desc: String?) -> Unit, -) { - var title by remember { mutableStateOf("") } - var desc by remember { mutableStateOf("") } - - androidx.compose.material3.AlertDialog( - onDismissRequest = onDismiss, - title = { Text(stringResource(R.string.kanban_add_new_task)) }, - text = { - Column { - OutlinedTextField( - value = title, - onValueChange = { title = it }, - label = { Text(stringResource(R.string.kanban_task_title)) }, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = desc, - onValueChange = { desc = it }, - label = { Text(stringResource(R.string.kanban_task_desc)) }, - modifier = Modifier.fillMaxWidth(), - ) - } - }, - confirmButton = { - Button( - onClick = { if (title.isNotBlank()) onConfirm(title, desc.ifBlank { null }) }, - enabled = title.isNotBlank(), - ) { - Text(stringResource(R.string.action_add)) - } - }, - dismissButton = { - Button(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - }, - ) -} - private fun KanbanTaskAction.labelRes(): Int = when (this) { KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt index d63c67fd..a79b3db8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt @@ -8,6 +8,7 @@ import com.m57.hermescontrol.data.local.KanbanPreferencesStore import com.m57.hermescontrol.data.model.CreateTaskBody import com.m57.hermescontrol.data.model.KanbanBoard import com.m57.hermescontrol.data.model.KanbanColumn +import com.m57.hermescontrol.data.model.KanbanProfile import com.m57.hermescontrol.data.model.KanbanTask import com.m57.hermescontrol.data.model.UpdateTaskBody import com.m57.hermescontrol.data.remote.NetworkResult @@ -32,6 +33,7 @@ data class KanbanUiState( val selectedBoard: KanbanBoard? = null, val columns: List = emptyList(), val tasks: List = emptyList(), + val profiles: List = emptyList(), val isLive: Boolean = false, val errorMessage: String? = null, val toastMessage: String? = null, @@ -139,6 +141,7 @@ class KanbanViewModel( val previouslySelectedId = _uiState.value.selectedBoard?.id ?: savedSlug _uiState.update { it.copy(isLoading = true, errorMessage = null) } + loadProfiles() viewModelScope.launch { when (val result = repository.getBoards()) { is NetworkResult.Success -> { @@ -179,10 +182,23 @@ class KanbanViewModel( } } + fun loadProfiles() { + viewModelScope.launch { + when (val result = repository.getProfiles()) { + is NetworkResult.Success -> { + _uiState.update { it.copy(profiles = result.data.profiles) } + } + + is NetworkResult.Failure -> { + // Do not block UI if profiles fetch fails + } + } + } + } + fun createTask( - title: String, - description: String?, - status: String = "todo", + body: CreateTaskBody, + targetStatus: String = "todo", ) { val board = _uiState.value.selectedBoard ?: return if (_uiState.value.isCreatingTask) return @@ -191,14 +207,21 @@ class KanbanViewModel( val result = repository.createTask( board = board.id, - body = - CreateTaskBody( - title = title, - body = description, - ), + body = body, ) when (result) { is NetworkResult.Success -> { + val createdTask = result.data.task + if (createdTask != null && + targetStatus.isNotBlank() && + !createdTask.status.equals(targetStatus, ignoreCase = true) + ) { + repository.updateTask( + taskId = createdTask.id, + board = board.id, + body = UpdateTaskBody(status = targetStatus), + ) + } val warning = result.data.warning val msg = if (!warning.isNullOrBlank()) { @@ -222,6 +245,21 @@ class KanbanViewModel( } } + fun createTask( + title: String, + description: String?, + status: String = "todo", + ) { + createTask( + body = + CreateTaskBody( + title = title, + body = description, + ), + targetStatus = status, + ) + } + fun moveTask( task: KanbanTask, action: KanbanTaskAction, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt new file mode 100644 index 00000000..4b57f9c5 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt @@ -0,0 +1,489 @@ +package com.m57.hermescontrol.ui.kanban.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.CreateTaskBody +import com.m57.hermescontrol.data.model.KanbanColumn +import com.m57.hermescontrol.data.model.KanbanProfile +import com.m57.hermescontrol.data.model.KanbanTask + +private const val PARKED_VALUE = "__parked__" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun KanbanCreateTaskDialog( + columns: List, + defaultColumn: String, + profiles: List, + existingTasks: List, + isCreating: Boolean, + onDismiss: () -> Unit, + onConfirm: (body: CreateTaskBody, targetStatus: String) -> Unit, + modifier: Modifier = Modifier, +) { + var title by remember { mutableStateOf("") } + var desc by remember { mutableStateOf("") } + var selectedColumn by remember(defaultColumn) { mutableStateOf(defaultColumn) } + var selectedAssignee by remember { mutableStateOf(null) } + var priority by remember { mutableIntStateOf(0) } + var goalMode by remember { mutableStateOf(false) } + + // Advanced options + var showAdvanced by remember { mutableStateOf(false) } + var parentTaskId by remember { mutableStateOf(null) } + var modelOverride by remember { mutableStateOf("") } + var providerOverride by remember { mutableStateOf("") } + var reasoningEffort by remember { mutableStateOf(null) } + var workspaceKind by remember { mutableStateOf(null) } + var skillsText by remember { mutableStateOf("") } + + var assigneeExpanded by remember { mutableStateOf(false) } + var columnExpanded by remember { mutableStateOf(false) } + var parentExpanded by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = { if (!isCreating) onDismiss() }, + modifier = modifier, + title = { Text(stringResource(R.string.kanban_add_new_task)) }, + text = { + Column( + modifier = + Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + ) { + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text(stringResource(R.string.kanban_task_title)) }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isCreating, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = desc, + onValueChange = { desc = it }, + label = { Text(stringResource(R.string.kanban_task_desc)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 3, + maxLines = 5, + enabled = !isCreating, + ) + + Spacer(modifier = Modifier.height(12.dp)) + + // Target Column selector + if (columns.isNotEmpty()) { + ExposedDropdownMenuBox( + expanded = columnExpanded, + onExpandedChange = { if (!isCreating) columnExpanded = it }, + ) { + OutlinedTextField( + value = selectedColumn.uppercase(), + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.kanban_target_column)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = columnExpanded) }, + modifier = + Modifier + .menuAnchor( + ExposedDropdownMenuAnchorType.PrimaryNotEditable, + ).fillMaxWidth(), + enabled = !isCreating, + ) + ExposedDropdownMenu( + expanded = columnExpanded, + onDismissRequest = { columnExpanded = false }, + ) { + columns.forEach { col -> + DropdownMenuItem( + text = { Text(col.name.uppercase()) }, + onClick = { + selectedColumn = col.name + columnExpanded = false + }, + ) + } + } + } + Spacer(modifier = Modifier.height(12.dp)) + } + + // Assignee dropdown + ExposedDropdownMenuBox( + expanded = assigneeExpanded, + onExpandedChange = { if (!isCreating) assigneeExpanded = it }, + ) { + val displayAssignee = + when (selectedAssignee) { + null -> stringResource(R.string.kanban_assignee_unassigned) + PARKED_VALUE -> stringResource(R.string.kanban_assignee_parked) + else -> selectedAssignee ?: "" + } + OutlinedTextField( + value = displayAssignee, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.kanban_assignee)) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = assigneeExpanded) }, + modifier = Modifier.menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable).fillMaxWidth(), + enabled = !isCreating, + ) + ExposedDropdownMenu( + expanded = assigneeExpanded, + onDismissRequest = { assigneeExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.kanban_assignee_unassigned)) }, + onClick = { + selectedAssignee = null + assigneeExpanded = false + }, + ) + profiles.forEach { profile -> + DropdownMenuItem( + text = { + Column { + Text(profile.name) + if (profile.model != null) { + Text( + profile.model, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + onClick = { + selectedAssignee = profile.name + assigneeExpanded = false + }, + ) + } + DropdownMenuItem( + text = { Text(stringResource(R.string.kanban_assignee_parked)) }, + onClick = { + selectedAssignee = PARKED_VALUE + assigneeExpanded = false + }, + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Priority chips + Text( + text = stringResource(R.string.kanban_priority), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + ) { + listOf( + 0 to stringResource(R.string.kanban_priority_normal), + 1 to stringResource(R.string.kanban_priority_med), + 2 to stringResource(R.string.kanban_priority_high), + 3 to stringResource(R.string.kanban_priority_urgent), + ).forEach { (pVal, label) -> + FilterChip( + selected = priority == pVal, + onClick = { priority = pVal }, + label = { Text(label) }, + enabled = !isCreating, + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Autonomous Goal Mode switch + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = + Modifier + .fillMaxWidth() + .clickable(enabled = !isCreating) { goalMode = !goalMode } + .padding(vertical = 4.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.kanban_goal_mode), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = stringResource(R.string.kanban_goal_mode_desc), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Switch( + checked = goalMode, + onCheckedChange = { goalMode = it }, + enabled = !isCreating, + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Advanced Options Toggle + TextButton( + onClick = { showAdvanced = !showAdvanced }, + modifier = Modifier.align(Alignment.Start), + ) { + Icon( + imageVector = if (showAdvanced) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.kanban_advanced_options)) + } + + AnimatedVisibility(visible = showAdvanced) { + Column(modifier = Modifier.fillMaxWidth()) { + // Parent Task + if (existingTasks.isNotEmpty()) { + ExposedDropdownMenuBox( + expanded = parentExpanded, + onExpandedChange = { if (!isCreating) parentExpanded = it }, + ) { + val displayParent = + existingTasks.find { it.id == parentTaskId }?.let { + "#${it.id.removePrefix("t_").take(6)} • ${it.title}" + } ?: stringResource(R.string.kanban_parent_none) + + OutlinedTextField( + value = displayParent, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.kanban_parent_task)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = parentExpanded, + ) + }, + modifier = + Modifier + .menuAnchor( + ExposedDropdownMenuAnchorType.PrimaryNotEditable, + ).fillMaxWidth(), + enabled = !isCreating, + ) + ExposedDropdownMenu( + expanded = parentExpanded, + onDismissRequest = { parentExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.kanban_parent_none)) }, + onClick = { + parentTaskId = null + parentExpanded = false + }, + ) + existingTasks.forEach { task -> + DropdownMenuItem( + text = { Text("#${task.id.removePrefix("t_").take(6)} • ${task.title}") }, + onClick = { + parentTaskId = task.id + parentExpanded = false + }, + ) + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + + // Model Override + OutlinedTextField( + value = modelOverride, + onValueChange = { modelOverride = it }, + label = { Text(stringResource(R.string.kanban_model_override)) }, + placeholder = { Text("e.g. claude-3-5-sonnet") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isCreating, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Provider Override + OutlinedTextField( + value = providerOverride, + onValueChange = { providerOverride = it }, + label = { Text(stringResource(R.string.kanban_provider_override)) }, + placeholder = { Text("e.g. anthropic") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isCreating, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Reasoning Effort + Text( + text = stringResource(R.string.kanban_reasoning_effort), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + ) { + listOf( + null to "Inherit", + "low" to "Low", + "medium" to "Medium", + "high" to "High", + ).forEach { (effort, label) -> + FilterChip( + selected = reasoningEffort == effort, + onClick = { reasoningEffort = effort }, + label = { Text(label) }, + enabled = !isCreating, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Workspace Kind + Text( + text = stringResource(R.string.kanban_workspace_kind), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + ) { + listOf( + null to "Scratch (Default)", + "worktree" to "Worktree", + "dir" to "Dir", + ).forEach { (kind, label) -> + FilterChip( + selected = workspaceKind == kind, + onClick = { workspaceKind = kind }, + label = { Text(label) }, + enabled = !isCreating, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // Skills + OutlinedTextField( + value = skillsText, + onValueChange = { skillsText = it }, + label = { Text(stringResource(R.string.kanban_skills)) }, + placeholder = { Text("e.g. git, coding, tests") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + enabled = !isCreating, + ) + } + } + } + }, + confirmButton = { + Button( + onClick = { + if (title.isNotBlank()) { + val skillsList = + skillsText + .split(",") + .map { it.trim() } + .filter { it.isNotEmpty() } + .takeIf { it.isNotEmpty() } + + val body = + CreateTaskBody( + title = title.trim(), + body = desc.trim().ifBlank { null }, + assignee = if (selectedAssignee == PARKED_VALUE) null else selectedAssignee, + priority = priority, + goalMode = goalMode, + parents = parentTaskId?.let { listOf(it) } ?: emptyList(), + modelOverride = modelOverride.trim().ifBlank { null }, + providerOverride = providerOverride.trim().ifBlank { null }, + reasoningEffort = reasoningEffort, + workspaceKind = workspaceKind, + skills = skillsList, + triage = selectedColumn.equals("triage", ignoreCase = true), + ) + onConfirm(body, selectedColumn) + } + }, + enabled = title.isNotBlank() && !isCreating, + ) { + if (isCreating) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(8.dp)) + } + Text(stringResource(R.string.action_add)) + } + }, + dismissButton = { + TextButton( + onClick = onDismiss, + enabled = !isCreating, + ) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt index a60ac957..959f6ab3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanTaskCard.kt @@ -18,11 +18,13 @@ import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.ChatBubbleOutline import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Hub +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -42,6 +44,7 @@ fun KanbanTaskCard( onTaskClick: (KanbanTask) -> Unit, modifier: Modifier = Modifier, isSelected: Boolean = false, + onActionClick: ((KanbanTask) -> Unit)? = null, ) { val statusColors = LocalHermesStatusColors.current val statusTone = @@ -90,12 +93,28 @@ fun KanbanTaskCard( color = statusTone, ) } - Text( - text = "#${task.id.removePrefix("t_").take(6)}", - style = MaterialTheme.typography.labelSmall, - fontFamily = FontFamily.Monospace, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "#${task.id.removePrefix("t_").take(6)}", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (onActionClick != null) { + Spacer(modifier = Modifier.width(4.dp)) + IconButton( + onClick = { onActionClick(task) }, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = "Task actions", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } } Spacer(modifier = Modifier.height(6.dp)) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9e0a66b6..338bf909 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -342,6 +342,25 @@ Live Offline No tasks + Priority + 0 (Normal) + 1 (Med) + 2 (High) + 3 (Urgent) + Assignee + Default (Unassigned) + Parked (No worker) + Autonomous Goal Mode + Judge validates task completion across multi-turn iterations + Advanced Options + Parent Task + None + Model Override + Provider Override + Reasoning Effort + Workspace Kind + Skills + Column Add All Statuses diff --git a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt index 10b75c0b..6d4d96f2 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/kanban/KanbanMutationTest.kt @@ -244,4 +244,101 @@ class KanbanMutationTest { // Only 1 updateTask call initiated coVerify(exactly = 1) { mockRepository.updateTask("t_1", "dev", any()) } } + + @Test + fun testCreateTaskWithFullOptionsAndStatusAlignment() = + runTest(testDispatcher) { + val board = KanbanBoard(id = "dev", name = "Dev") + val returnedTask = KanbanTask(id = "t_101", title = "Complex Task", status = "ready") + + coEvery { mockRepository.getBoard("dev") } returns + NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList())))) + coEvery { mockRepository.createTask("dev", any()) } returns + NetworkResult.Success(CreateTaskResponse(task = returnedTask)) + coEvery { mockRepository.updateTask("t_101", "dev", any()) } returns + NetworkResult.Success(UpdateTaskResponse(task = returnedTask.copy(status = "todo"))) + + val vm = createViewModel() + vm.selectBoard(board) + testDispatcher.scheduler.advanceUntilIdle() + + val body = + CreateTaskBody( + title = "Complex Task", + body = "Rich spec", + assignee = "reviewer", + priority = 2, + goalMode = true, + modelOverride = "claude-3-5-sonnet", + providerOverride = "anthropic", + reasoningEffort = "high", + skills = listOf("kotlin", "compose"), + parents = listOf("t_parent"), + workspaceKind = "worktree", + ) + + vm.createTask(body = body, targetStatus = "todo") + testDispatcher.scheduler.advanceUntilIdle() + + coVerify { + mockRepository.createTask( + "dev", + match { + it.title == "Complex Task" && + it.body == "Rich spec" && + it.assignee == "reviewer" && + it.priority == 2 && + it.goalMode && + it.modelOverride == "claude-3-5-sonnet" && + it.providerOverride == "anthropic" && + it.reasoningEffort == "high" && + it.skills == listOf("kotlin", "compose") && + it.parents == listOf("t_parent") && + it.workspaceKind == "worktree" + }, + ) + } + + // Since backend returned "ready" but user wanted "todo", status alignment update is sent + coVerify { + mockRepository.updateTask( + "t_101", + "dev", + match { it.status == "todo" }, + ) + } + } + + @Test + fun testLoadProfilesPopulatesState() = + runTest(testDispatcher) { + val profiles = + listOf( + com.m57.hermescontrol.data.model + .KanbanProfile(name = "researcher", model = "gpt-4o"), + com.m57.hermescontrol.data.model + .KanbanProfile(name = "reviewer", model = "claude-3-5-sonnet"), + ) + coEvery { mockRepository.getProfiles() } returns + NetworkResult.Success( + com.m57.hermescontrol.data.model + .KanbanProfilesResponse(profiles), + ) + + val vm = createViewModel() + vm.loadProfiles() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(2, vm.uiState.value.profiles.size) + assertEquals( + "researcher", + vm.uiState.value.profiles[0] + .name, + ) + assertEquals( + "reviewer", + vm.uiState.value.profiles[1] + .name, + ) + } }