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 34a09bef..9001d6fc 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 @@ -1,12 +1,18 @@ package com.m57.hermescontrol.ui.kanban +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -19,6 +25,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.FilterList @@ -33,6 +41,9 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu 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.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -46,12 +57,13 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel @@ -59,15 +71,31 @@ import com.m57.hermescontrol.KanbanTaskDetailKey import com.m57.hermescontrol.NavigationController import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.KanbanTask +import com.m57.hermescontrol.data.model.ManagedFileDelete +import com.m57.hermescontrol.data.remote.ApiClient +import com.m57.hermescontrol.data.remote.GatewayFileClient +import com.m57.hermescontrol.data.remote.GatewayFileResult +import com.m57.hermescontrol.data.remote.NetworkResult +import com.m57.hermescontrol.data.remote.safeApiCall +import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.common.ErrorState import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.ToastEffect +import com.m57.hermescontrol.ui.kanban.components.KanbanBoardSettingsDialog import com.m57.hermescontrol.ui.kanban.components.KanbanCreateTaskDialog import com.m57.hermescontrol.ui.kanban.components.KanbanFilterSheet +import com.m57.hermescontrol.ui.kanban.components.KanbanOrchestrationDialog import com.m57.hermescontrol.ui.kanban.components.KanbanTaskCard +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody +import java.io.File private const val DEFAULT_COLUMN = "todo" @@ -79,12 +107,12 @@ fun KanbanScreen( viewModel: KanbanViewModel = viewModel { KanbanViewModel() }, ) { val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() var query by remember { mutableStateOf("") } var selectedAssignee by remember { mutableStateOf(null) } var selectedTenant by remember { mutableStateOf(null) } - var includeArchived by remember { mutableStateOf(false) } - var groupRunning by remember { mutableStateOf(false) } var showFilterSheet by remember { mutableStateOf(false) } val filteredTasks = @@ -109,6 +137,7 @@ fun KanbanScreen( filteredTasks.groupBy { it.status.lowercase() } } var showAddTaskDialog by remember { mutableStateOf(false) } + var createTaskDefaultColumn by remember { mutableStateOf(DEFAULT_COLUMN) } var taskForActions by remember { mutableStateOf(null) } var confirmTarget by remember { mutableStateOf?>(null) } var summaryTarget by remember { mutableStateOf?>(null) } @@ -117,12 +146,116 @@ fun KanbanScreen( var isMultiSelectMode by remember { mutableStateOf(false) } var selectedTaskIds by remember { mutableStateOf(setOf()) } var showBulkMoveDialog by remember { mutableStateOf(false) } + var showBulkAssignDialog by remember { mutableStateOf(false) } + var showBulkDeleteDialog by remember { mutableStateOf(false) } // Board management dialogs var showBoardMenu by remember { mutableStateOf(false) } var showCreateBoardDialog by remember { mutableStateOf(false) } - var showRenameBoardDialog by remember { mutableStateOf(false) } + var showBoardSettingsDialog by remember { mutableStateOf(false) } var showDeleteBoardDialog by remember { mutableStateOf(false) } + var showOrchestrationDialog by remember { mutableStateOf(false) } + + // Board Transfer (Export / Import) + var pendingExportPath by remember { mutableStateOf(null) } + + val exportLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("application/gzip"), + ) { uri -> + if (uri != null && pendingExportPath != null) { + coroutineScope.launch { + try { + val cachedFile = File(pendingExportPath!!) + if (cachedFile.exists()) { + context.contentResolver.openOutputStream(uri)?.use { output -> + cachedFile.inputStream().use { input -> + input.copyTo(output) + } + } + viewModel.showToast("Board exported successfully") + } + } catch (e: Exception) { + viewModel.showToast("Failed to save export: ${e.message}") + } finally { + pendingExportPath = null + } + } + } + } + + val importLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument(), + ) { uri: Uri? -> + if (uri != null) { + coroutineScope.launch { + try { + var fileName = "board.tar.gz" + context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> + val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (nameIndex != -1 && cursor.moveToFirst()) { + fileName = cursor.getString(nameIndex) ?: "board.tar.gz" + } + } + + val inputStream = context.contentResolver.openInputStream(uri) + if (inputStream != null) { + val bytes = withContext(Dispatchers.IO) { inputStream.use { it.readBytes() } } + val targetPath = "kanban-imports/$fileName" + val pathBody = targetPath.toRequestBody("text/plain".toMediaTypeOrNull()) + val overwriteBody = "true".toRequestBody("text/plain".toMediaTypeOrNull()) + val filePart = + MultipartBody.Part.createFormData( + "file", + fileName, + bytes.toRequestBody("application/gzip".toMediaTypeOrNull()), + ) + val uploadResult = + withContext(Dispatchers.IO) { + safeApiCall { + ApiClient.hermesApi.uploadManagedFileStream(pathBody, overwriteBody, filePart) + } + } + when (uploadResult) { + is NetworkResult.Success -> { + val serverPath = + uploadResult.data.entry?.path ?: uploadResult.data.path ?: targetPath + val importResult = viewModel.importBoard(serverPath) + when (importResult) { + is NetworkResult.Success -> { + val res = importResult.data + var msg = "Board '${res.name}' imported" + if (res.renamed) msg += " (as ${res.board})" + viewModel.showToast(msg) + viewModel.loadBoards() + } + + is NetworkResult.Failure -> { + viewModel.showToast("Import failed: ${importResult.error.message}") + } + } + runCatching { + ApiClient.hermesApi.deleteManagedFile(ManagedFileDelete(serverPath)) + } + } + + is NetworkResult.Failure -> { + viewModel.showToast("Failed to upload archive: ${uploadResult.error.message}") + } + } + } + } catch (e: Exception) { + viewModel.showToast("Import failed: ${e.message}") + } + } + } + } + + LaunchedEffect(state.tasks) { + val existingIds = state.tasks.map { it.id }.toSet() + selectedTaskIds = selectedTaskIds.intersect(existingIds) + } LaunchedEffect(Unit) { viewModel.loadBoards() @@ -160,22 +293,67 @@ fun KanbanScreen( ) if (state.selectedBoard != null) { DropdownMenuItem( - text = { Text("Rename Board") }, + text = { Text(stringResource(R.string.kanban_board_settings)) }, onClick = { showBoardMenu = false - showRenameBoardDialog = true + showBoardSettingsDialog = true }, ) - if (state.boards.size > 1) { + if (state.boards.size > 1 && !state.selectedBoard?.id.equals("default", ignoreCase = true)) { DropdownMenuItem( - text = { Text("Delete Board") }, + text = { Text(stringResource(R.string.kanban_archive_board)) }, onClick = { showBoardMenu = false showDeleteBoardDialog = true }, ) } + DropdownMenuItem( + text = { Text(stringResource(R.string.kanban_export_board)) }, + onClick = { + showBoardMenu = false + state.selectedBoard?.let { b -> + coroutineScope.launch { + when (val res = viewModel.exportBoard(b.id)) { + is NetworkResult.Success -> { + val download = + GatewayFileClient.fetch( + res.data.archive, + context.cacheDir, + ) + if (download is GatewayFileResult.Success) { + pendingExportPath = download.file.cacheFile.absolutePath + exportLauncher.launch("${b.slug}.tar.gz") + } else { + viewModel.showToast("Failed to download board export") + } + } + + is NetworkResult.Failure -> { + viewModel.showToast("Export failed: ${res.error.message}") + } + } + } + } + }, + ) } + DropdownMenuItem( + text = { Text(stringResource(R.string.kanban_import_board)) }, + onClick = { + showBoardMenu = false + importLauncher.launch( + arrayOf("application/gzip", "application/x-gzip", "application/octet-stream", "*/*"), + ) + }, + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.kanban_orchestration_settings)) }, + onClick = { + showBoardMenu = false + showOrchestrationDialog = true + }, + ) DropdownMenuItem( text = { Text(if (isMultiSelectMode) "Exit Multi-Select" else "Select Multiple") }, onClick = { @@ -260,20 +438,29 @@ fun KanbanScreen( Spacer(modifier = Modifier.width(8.dp)) IconButton(onClick = { showFilterSheet = true }) { Icon( - imageVector = Icons.Filled.FilterList, + imageVector = Icons.Default.FilterList, contentDescription = "Filters", tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } Spacer(modifier = Modifier.width(4.dp)) - IconButton(onClick = { showAddTaskDialog = true }) { + IconButton( + onClick = { + createTaskDefaultColumn = DEFAULT_COLUMN + showAddTaskDialog = true + }, + ) { Icon( - imageVector = Icons.Filled.Add, + imageVector = Icons.Default.Add, contentDescription = stringResource(R.string.kanban_add_task), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } } + + val boardHasWork = state.tasks.isNotEmpty() + val statusColors = LocalHermesStatusColors.current + LazyRow( modifier = Modifier.fillMaxSize(), contentPadding = @@ -291,79 +478,310 @@ fun KanbanScreen( val column = state.columns[columnIndex] val colName = column.name val colTasks = tasksByColumn[colName.lowercase()] ?: emptyList() - - Column( - modifier = - Modifier - .width(280.dp) - .fillMaxSize(), - ) { - Text( - text = "${colName.replaceFirstChar { it.uppercase() }} (${ - colTasks.size - })", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(bottom = 8.dp), + val isCollapsed = + KanbanColumnCollapseHelper.isColumnCollapsed( + columnName = colName, + columnTaskCount = colTasks.size, + boardHasWork = boardHasWork, + overrides = state.columnCollapseOverrides, ) + val statusTone = + when (colName.lowercase()) { + "ready" -> statusColors.info + "running" -> statusColors.success + "blocked" -> statusColors.error + "review" -> statusColors.warning + "done" -> statusColors.success + else -> MaterialTheme.colorScheme.outline + } - if (colTasks.isEmpty()) { - Box( + if (isCollapsed) { + Card( + modifier = + Modifier + .width(56.dp) + .fillMaxHeight() + .clickable { + viewModel.toggleColumnCollapse( + colName, + boardHasWork, + colTasks.size, + ) + }, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.fillMaxSize().padding(vertical = 12.dp), + ) { + Box( + modifier = + Modifier + .size(8.dp) + .background(statusTone, shape = CircleShape), + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = colTasks.size.toString(), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = colName.take(3).uppercase(), + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.weight(1f)) + if (KanbanStatusConstraints.isUserWritableTarget(colName)) { + IconButton( + onClick = { + createTaskDefaultColumn = colName + showAddTaskDialog = true + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = + stringResource( + R.string.kanban_add_to_column, + colName, + ), + modifier = Modifier.size(16.dp), + ) + } + } + IconButton( + onClick = { + viewModel.toggleColumnCollapse( + colName, + boardHasWork, + colTasks.size, + ) + }, + modifier = Modifier.size(32.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = + stringResource( + R.string.kanban_expand_column, + colName, + ), + modifier = Modifier.size(16.dp), + ) + } + } + } + } else { + Column( + modifier = + Modifier + .width(280.dp) + .fillMaxSize(), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier .fillMaxWidth() - .padding(vertical = 16.dp), - contentAlignment = Alignment.Center, + .padding(bottom = 8.dp), ) { Text( - text = stringResource(R.string.kanban_no_tasks), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + text = "${colName.replaceFirstChar { + it.uppercase() + }} (${colTasks.size})", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, ) + Row(verticalAlignment = Alignment.CenterVertically) { + if (KanbanStatusConstraints.isUserWritableTarget(colName)) { + IconButton( + onClick = { + createTaskDefaultColumn = colName + showAddTaskDialog = true + }, + modifier = Modifier.size(28.dp), + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = + stringResource( + R.string.kanban_add_to_column, + colName, + ), + modifier = Modifier.size(18.dp), + ) + } + } + IconButton( + onClick = { + viewModel.toggleColumnCollapse( + colName, + boardHasWork, + colTasks.size, + ) + }, + modifier = Modifier.size(28.dp), + ) { + Icon( + imageVector = + Icons.AutoMirrored.Filled.KeyboardArrowLeft, + contentDescription = + stringResource( + R.string.kanban_collapse_column, + colName, + ), + modifier = Modifier.size(18.dp), + ) + } + } } - } else { - LazyColumn( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - items(colTasks, key = { it.id }) { task -> - KanbanTaskCard( - task = task, - isSelected = - isMultiSelectMode && task.id in selectedTaskIds, - onTaskClick = { clickedTask -> - if (isMultiSelectMode) { - selectedTaskIds = - if (clickedTask.id in selectedTaskIds) { - selectedTaskIds - clickedTask.id - } else { - selectedTaskIds + clickedTask.id - } - } else { - state.selectedBoard?.let { board -> - NavigationController.navigateTo( - KanbanTaskDetailKey( - boardSlug = board.id, - taskId = clickedTask.id, - ), - ) - } - } - }, - onActionClick = { clickedTask -> - if (isMultiSelectMode) { - selectedTaskIds = - if (clickedTask.id in selectedTaskIds) { - selectedTaskIds - clickedTask.id - } else { - selectedTaskIds + clickedTask.id - } - } else { - taskForActions = clickedTask - } - }, + + if (colTasks.isEmpty()) { + Box( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = stringResource(R.string.kanban_no_tasks), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } + } else if (colName.equals("running", ignoreCase = true) && + state.groupRunning + ) { + val runningGroups = + remember(colTasks) { + KanbanLaneGrouping.groupRunningTasks(colTasks) + } + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + runningGroups.forEach { group -> + item(key = "group_${group.assignee ?: "unassigned"}") { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier.fillMaxWidth().padding( + vertical = 4.dp, + ), + ) { + Icon( + imageVector = Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = + "${group.assignee ?: stringResource( + R.string.kanban_unassigned_group, + )} (${group.tasks.size})", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + items(group.tasks, key = { it.id }) { task -> + KanbanTaskCard( + task = task, + defaultAssignee = + state.orchestration + ?.resolvedDefaultAssignee, + isSelected = + isMultiSelectMode && task.id in selectedTaskIds, + onTaskClick = { clickedTask -> + if (isMultiSelectMode) { + selectedTaskIds = + if (clickedTask.id in selectedTaskIds) { + selectedTaskIds - clickedTask.id + } else { + selectedTaskIds + clickedTask.id + } + } else { + state.selectedBoard?.let { board -> + NavigationController.navigateTo( + KanbanTaskDetailKey( + boardSlug = board.id, + taskId = clickedTask.id, + ), + ) + } + } + }, + onActionClick = { clickedTask -> + if (isMultiSelectMode) { + selectedTaskIds = + if (clickedTask.id in selectedTaskIds) { + selectedTaskIds - clickedTask.id + } else { + selectedTaskIds + clickedTask.id + } + } else { + taskForActions = clickedTask + } + }, + ) + } + } + } + } else { + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(colTasks, key = { it.id }) { task -> + KanbanTaskCard( + task = task, + defaultAssignee = + state.orchestration + ?.resolvedDefaultAssignee, + isSelected = + isMultiSelectMode && task.id in selectedTaskIds, + onTaskClick = { clickedTask -> + if (isMultiSelectMode) { + selectedTaskIds = + if (clickedTask.id in selectedTaskIds) { + selectedTaskIds - clickedTask.id + } else { + selectedTaskIds + clickedTask.id + } + } else { + state.selectedBoard?.let { board -> + NavigationController.navigateTo( + KanbanTaskDetailKey( + boardSlug = board.id, + taskId = clickedTask.id, + ), + ) + } + } + }, + onActionClick = { clickedTask -> + if (isMultiSelectMode) { + selectedTaskIds = + if (clickedTask.id in selectedTaskIds) { + selectedTaskIds - clickedTask.id + } else { + selectedTaskIds + clickedTask.id + } + } else { + taskForActions = clickedTask + } + }, + ) + } + } } } } @@ -376,10 +794,15 @@ fun KanbanScreen( if (showAddTaskDialog) { KanbanCreateTaskDialog( columns = state.columns, - defaultColumn = state.columns.firstOrNull()?.name ?: DEFAULT_COLUMN, + defaultColumn = createTaskDefaultColumn, profiles = state.profiles, existingTasks = state.tasks, isCreating = state.isCreatingTask, + defaultAssignee = state.orchestration?.resolvedDefaultAssignee, + boardDefaultWorkspaceKind = state.selectedBoard?.defaultWorkspaceKind, + boardDefaultWorkdir = state.selectedBoard?.defaultWorkdir, + modelProviders = state.modelProviders, + pinnedModels = state.pinnedModels, onEstimate = viewModel::estimateNewTask, onDismiss = { showAddTaskDialog = false }, onConfirm = { body, targetStatus -> @@ -453,17 +876,17 @@ fun KanbanScreen( tenants = tenants, selectedAssignee = selectedAssignee, selectedTenant = selectedTenant, - includeArchived = includeArchived, - groupRunning = groupRunning, + includeArchived = state.includeArchived, + groupRunning = state.groupRunning, onSelectAssignee = { selectedAssignee = it }, onSelectTenant = { selectedTenant = it }, - onToggleIncludeArchived = { includeArchived = it }, - onToggleGroupRunning = { groupRunning = it }, + onToggleIncludeArchived = viewModel::setIncludeArchived, + onToggleGroupRunning = viewModel::setGroupRunning, onClearFilters = { selectedAssignee = null selectedTenant = null - includeArchived = false - groupRunning = false + viewModel.setIncludeArchived(false) + viewModel.setGroupRunning(false) }, onDismiss = { showFilterSheet = false }, ) @@ -484,7 +907,10 @@ fun KanbanScreen( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp).fillMaxWidth(), + modifier = + Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .fillMaxWidth(), ) { Text( text = "${selectedTaskIds.size} selected", @@ -513,16 +939,44 @@ fun KanbanScreen( ) { Text("Move") } + Button( + onClick = { showBulkAssignDialog = true }, + enabled = selectedTaskIds.isNotEmpty(), + ) { + Text(stringResource(R.string.kanban_bulk_assign)) + } Button( onClick = { - viewModel.bulkArchive(selectedTaskIds.toList()) - isMultiSelectMode = false - selectedTaskIds = emptySet() + viewModel.bulkAssign(selectedTaskIds.toList(), null) { failed -> + selectedTaskIds = failed + if (failed.isEmpty()) isMultiSelectMode = false + } + }, + enabled = selectedTaskIds.isNotEmpty(), + ) { + Text(stringResource(R.string.kanban_bulk_unassign)) + } + Button( + onClick = { + viewModel.bulkArchive(selectedTaskIds.toList()) { failed -> + selectedTaskIds = failed + if (failed.isEmpty()) isMultiSelectMode = false + } }, enabled = selectedTaskIds.isNotEmpty(), ) { Text("Archive") } + Button( + onClick = { showBulkDeleteDialog = true }, + enabled = selectedTaskIds.isNotEmpty(), + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.kanban_bulk_delete)) + } IconButton( onClick = { isMultiSelectMode = false @@ -537,18 +991,23 @@ fun KanbanScreen( } if (showBulkMoveDialog) { + val writableMoveColumns = + remember(state.columns) { + state.columns.filter { KanbanStatusConstraints.isUserWritableTarget(it.name) } + } AlertDialog( onDismissRequest = { showBulkMoveDialog = false }, title = { Text("Move ${selectedTaskIds.size} tasks") }, text = { Column { - state.columns.forEach { col -> + writableMoveColumns.forEach { col -> TextButton( onClick = { - viewModel.bulkMove(selectedTaskIds.toList(), col.name) + viewModel.bulkMove(selectedTaskIds.toList(), col.name) { failed -> + selectedTaskIds = failed + if (failed.isEmpty()) isMultiSelectMode = false + } showBulkMoveDialog = false - isMultiSelectMode = false - selectedTaskIds = emptySet() }, modifier = Modifier.fillMaxWidth(), ) { @@ -560,7 +1019,70 @@ fun KanbanScreen( confirmButton = {}, dismissButton = { TextButton(onClick = { showBulkMoveDialog = false }) { - Text("Cancel") + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + if (showBulkAssignDialog) { + AlertDialog( + onDismissRequest = { showBulkAssignDialog = false }, + title = { Text(stringResource(R.string.kanban_bulk_assign)) }, + text = { + Column { + state.profiles.forEach { profile -> + TextButton( + onClick = { + viewModel.bulkAssign(selectedTaskIds.toList(), profile.name) { failed -> + selectedTaskIds = failed + if (failed.isEmpty()) isMultiSelectMode = false + } + showBulkAssignDialog = false + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(profile.name, modifier = Modifier.fillMaxWidth()) + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = { showBulkAssignDialog = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } + + if (showBulkDeleteDialog) { + AlertDialog( + onDismissRequest = { showBulkDeleteDialog = false }, + title = { Text(stringResource(R.string.kanban_bulk_delete)) }, + text = { + Text(stringResource(R.string.kanban_bulk_delete_confirm, selectedTaskIds.size)) + }, + confirmButton = { + Button( + onClick = { + viewModel.bulkDelete(selectedTaskIds.toList()) { failed -> + selectedTaskIds = failed + if (failed.isEmpty()) isMultiSelectMode = false + } + showBulkDeleteDialog = false + }, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error, + ), + ) { + Text(stringResource(R.string.kanban_bulk_delete)) + } + }, + dismissButton = { + TextButton(onClick = { showBulkDeleteDialog = false }) { + Text(stringResource(R.string.action_cancel)) } }, ) @@ -570,6 +1092,9 @@ fun KanbanScreen( var boardSlug by remember { mutableStateOf("") } var boardName by remember { mutableStateOf("") } var boardDesc by remember { mutableStateOf("") } + var selectedProjId by remember { mutableStateOf(null) } + var projectDropdownExpanded by remember { mutableStateOf(false) } + AlertDialog( onDismissRequest = { showCreateBoardDialog = false }, title = { Text("Create New Board") }, @@ -577,14 +1102,8 @@ fun KanbanScreen( Column { OutlinedTextField( value = boardSlug, - onValueChange = { - boardSlug = - it.lowercase().filter { c -> - c.isLetterOrDigit() || c == '-' || c == '_' - } - }, - label = { Text("Board ID / Slug") }, - placeholder = { Text("e.g. sprint-3") }, + onValueChange = { boardSlug = it }, + label = { Text("Slug (id)") }, modifier = Modifier.fillMaxWidth(), singleLine = true, ) @@ -593,7 +1112,6 @@ fun KanbanScreen( value = boardName, onValueChange = { boardName = it }, label = { Text("Display Name") }, - placeholder = { Text("e.g. Sprint 3") }, modifier = Modifier.fillMaxWidth(), singleLine = true, ) @@ -604,6 +1122,51 @@ fun KanbanScreen( label = { Text("Description") }, modifier = Modifier.fillMaxWidth(), ) + Spacer(modifier = Modifier.height(8.dp)) + ExposedDropdownMenuBox( + expanded = projectDropdownExpanded, + onExpandedChange = { projectDropdownExpanded = it }, + ) { + val displayProj = + state.projects.find { it.id == selectedProjId }?.name + ?: stringResource(R.string.kanban_no_project) + OutlinedTextField( + value = displayProj, + onValueChange = {}, + readOnly = true, + label = { Text(stringResource(R.string.kanban_project)) }, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon( + expanded = projectDropdownExpanded, + ) + }, + modifier = + Modifier + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable) + .fillMaxWidth(), + ) + ExposedDropdownMenu( + expanded = projectDropdownExpanded, + onDismissRequest = { projectDropdownExpanded = false }, + ) { + DropdownMenuItem( + text = { Text(stringResource(R.string.kanban_no_project)) }, + onClick = { + selectedProjId = null + projectDropdownExpanded = false + }, + ) + state.projects.forEach { proj -> + DropdownMenuItem( + text = { Text(proj.name) }, + onClick = { + selectedProjId = proj.id + projectDropdownExpanded = false + }, + ) + } + } + } } }, confirmButton = { @@ -614,6 +1177,7 @@ fun KanbanScreen( slug = boardSlug.trim(), name = boardName.trim().ifBlank { null }, description = boardDesc.trim().ifBlank { null }, + projectId = selectedProjId, ) showCreateBoardDialog = false } @@ -625,56 +1189,46 @@ fun KanbanScreen( }, dismissButton = { TextButton(onClick = { showCreateBoardDialog = false }) { - Text("Cancel") + Text(stringResource(R.string.action_cancel)) } }, ) } - if (showRenameBoardDialog && state.selectedBoard != null) { - var newName by remember { mutableStateOf(state.selectedBoard?.name ?: "") } - AlertDialog( - onDismissRequest = { showRenameBoardDialog = false }, - title = { Text("Rename Board") }, - text = { - OutlinedTextField( - value = newName, - onValueChange = { newName = it }, - label = { Text("Board Name") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - }, - confirmButton = { - Button( - onClick = { - if (newName.isNotBlank()) { - state.selectedBoard?.let { board -> - viewModel.renameBoard(board.id, newName.trim()) - } - showRenameBoardDialog = false - } - }, - enabled = newName.isNotBlank(), - ) { - Text("Save") - } - }, - dismissButton = { - TextButton(onClick = { showRenameBoardDialog = false }) { - Text("Cancel") + if (showBoardSettingsDialog && state.selectedBoard != null) { + KanbanBoardSettingsDialog( + board = state.selectedBoard!!, + projects = state.projects, + onSave = { body -> + state.selectedBoard?.let { b -> + viewModel.updateBoard(b.id, body) } }, + onDismiss = { showBoardSettingsDialog = false }, + ) + } + + if (showOrchestrationDialog) { + KanbanOrchestrationDialog( + settings = state.orchestration, + profiles = state.profiles, + onSaveSettings = viewModel::updateOrchestration, + onSaveProfileDescription = viewModel::updateProfileDescription, + onAutoDescribe = viewModel::autoDescribeProfile, + onDismiss = { showOrchestrationDialog = false }, ) } if (showDeleteBoardDialog && state.selectedBoard != null) { AlertDialog( onDismissRequest = { showDeleteBoardDialog = false }, - title = { Text("Delete Board") }, + title = { Text(stringResource(R.string.kanban_archive_board)) }, text = { Text( - "Are you sure you want to delete board '${state.selectedBoard?.displayName}'? This action cannot be undone.", + stringResource( + R.string.kanban_archive_board_confirm, + state.selectedBoard?.displayName.orEmpty(), + ), ) }, confirmButton = { @@ -690,12 +1244,12 @@ fun KanbanScreen( containerColor = MaterialTheme.colorScheme.error, ), ) { - Text("Delete") + Text(stringResource(R.string.kanban_action_archive)) } }, dismissButton = { TextButton(onClick = { showDeleteBoardDialog = false }) { - Text("Cancel") + Text(stringResource(R.string.action_cancel)) } }, ) @@ -718,36 +1272,19 @@ private fun LiveStatusPill(isLive: Boolean) { Box( modifier = Modifier - .size(8.dp) - .background(color = color, shape = CircleShape), + .size(6.dp) + .background(color, CircleShape), ) - Spacer(modifier = Modifier.width(6.dp)) + Spacer(modifier = Modifier.width(4.dp)) Text( - text = stringResource(if (isLive) R.string.kanban_live else R.string.kanban_offline), - style = MaterialTheme.typography.labelMedium, + text = if (isLive) stringResource(R.string.kanban_live) else stringResource(R.string.kanban_offline), + style = MaterialTheme.typography.labelSmall, color = color, ) } } -private fun KanbanTaskAction.labelRes(): Int = - when (this) { - KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage - KanbanTaskAction.READY -> R.string.kanban_action_ready - KanbanTaskAction.UNBLOCK -> R.string.kanban_action_unblock - KanbanTaskAction.BLOCK -> R.string.kanban_action_block - KanbanTaskAction.COMPLETE -> R.string.kanban_action_complete - KanbanTaskAction.ARCHIVE -> R.string.kanban_action_archive - } - -private fun KanbanTaskAction.confirmRes(): Int = - when (this) { - KanbanTaskAction.BLOCK -> R.string.kanban_confirm_blocked - KanbanTaskAction.COMPLETE -> R.string.kanban_confirm_done - KanbanTaskAction.ARCHIVE -> R.string.kanban_confirm_archive - else -> error("Action $this has no confirm message") - } - +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun TaskActionSheet( task: KanbanTask, @@ -755,31 +1292,37 @@ private fun TaskActionSheet( onAction: (KanbanTaskAction) -> Unit, onDismiss: () -> Unit, ) { - AlertDialog( + androidx.compose.material3.ModalBottomSheet( onDismissRequest = onDismiss, - title = { Text(task.title, maxLines = 2, overflow = TextOverflow.Ellipsis) }, - text = { - Column { - actions.forEach { action -> - Button( - onClick = { onAction(action) }, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - ) { - Text(stringResource(action.labelRes())) - } + ) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(bottom = 32.dp), + ) { + Text( + text = task.title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + modifier = Modifier.padding(bottom = 12.dp), + ) + actions.forEach { action -> + TextButton( + onClick = { onAction(action) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(action.labelRes()), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.fillMaxWidth(), + ) } } - }, - confirmButton = {}, - dismissButton = { - Button(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) - } - }, - ) + } + } } @Composable @@ -790,15 +1333,16 @@ private fun ConfirmActionDialog( ) { AlertDialog( onDismissRequest = onDismiss, + title = { Text("Confirm action") }, text = { Text(message) }, confirmButton = { Button(onClick = onConfirm) { - Text(stringResource(R.string.action_confirm)) + Text("Confirm") } }, dismissButton = { - Button(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) + TextButton(onClick = onDismiss) { + Text("Cancel") } }, ) @@ -806,7 +1350,7 @@ private fun ConfirmActionDialog( @Composable private fun CompleteTaskDialog( - onConfirm: (String) -> Unit, + onConfirm: (String?) -> Unit, onDismiss: () -> Unit, ) { var summary by remember { mutableStateOf("") } @@ -814,26 +1358,44 @@ private fun CompleteTaskDialog( onDismissRequest = onDismiss, title = { Text(stringResource(R.string.kanban_complete_title)) }, text = { - OutlinedTextField( - value = summary, - onValueChange = { summary = it }, - label = { Text(stringResource(R.string.kanban_complete_summary_label)) }, - supportingText = { Text(stringResource(R.string.kanban_complete_summary_hint)) }, - modifier = Modifier.fillMaxWidth(), - ) + Column { + OutlinedTextField( + value = summary, + onValueChange = { summary = it }, + label = { Text(stringResource(R.string.kanban_complete_summary_label)) }, + placeholder = { Text(stringResource(R.string.kanban_complete_summary_hint)) }, + modifier = Modifier.fillMaxWidth(), + minLines = 2, + ) + } }, confirmButton = { - Button( - onClick = { onConfirm(summary.trim()) }, - enabled = summary.isNotBlank(), - ) { + Button(onClick = { onConfirm(summary.trim().ifBlank { null }) }) { Text(stringResource(R.string.kanban_action_complete)) } }, dismissButton = { - Button(onClick = onDismiss) { - Text(stringResource(R.string.action_cancel)) + TextButton(onClick = onDismiss) { + Text("Cancel") } }, ) } + +private fun KanbanTaskAction.labelRes(): Int = + when (this) { + KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage + KanbanTaskAction.READY -> R.string.kanban_action_ready + KanbanTaskAction.BLOCK -> R.string.kanban_action_block + KanbanTaskAction.UNBLOCK -> R.string.kanban_action_unblock + KanbanTaskAction.COMPLETE -> R.string.kanban_action_complete + KanbanTaskAction.ARCHIVE -> R.string.kanban_action_archive + } + +private fun KanbanTaskAction.confirmRes(): Int = + when (this) { + KanbanTaskAction.BLOCK -> R.string.kanban_confirm_blocked + KanbanTaskAction.COMPLETE -> R.string.kanban_confirm_done + KanbanTaskAction.ARCHIVE -> R.string.kanban_confirm_archive + else -> R.string.kanban_confirm_blocked + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt index 87d796ae..ef23a3eb 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskScreen.kt @@ -5,9 +5,11 @@ import android.provider.OpenableColumns import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -18,6 +20,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer @@ -26,16 +29,21 @@ import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.ChatBubbleOutline import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -43,9 +51,11 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Tab import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -56,11 +66,21 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import com.m57.hermescontrol.KanbanTaskDetailKey +import com.m57.hermescontrol.NavigationController +import com.m57.hermescontrol.R +import com.m57.hermescontrol.data.model.KanbanProfile +import com.m57.hermescontrol.data.model.KanbanTaskFull +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.TaskLinks import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.chat.MarkdownText import com.m57.hermescontrol.ui.common.ErrorState @@ -68,6 +88,7 @@ import com.m57.hermescontrol.ui.common.HermesScaffold import com.m57.hermescontrol.ui.common.NavIcon import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.ToastEffect +import com.m57.hermescontrol.ui.kanban.components.KanbanModelOverrideEditor @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -81,6 +102,7 @@ fun KanbanTaskScreen( val state by viewModel.uiState.collectAsStateWithLifecycle() var selectedTabIndex by remember { mutableIntStateOf(0) } val tabs = listOf("Overview", "Discussion", "Runs", "Activity", "Files") + var showDeleteDialog by remember { mutableStateOf(false) } LaunchedEffect(boardSlug, taskId) { viewModel.loadTask(boardSlug, taskId) @@ -105,6 +127,15 @@ fun KanbanTaskScreen( } }, navigationIcon = NavIcon.Back(onBack), + actions = { + IconButton(onClick = { showDeleteDialog = true }) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringResource(R.string.kanban_delete_task), + tint = MaterialTheme.colorScheme.error, + ) + } + }, drawerGesturesEnabled = false, isRefreshing = state.isLoading, onRefresh = { viewModel.loadTask(boardSlug, taskId) }, @@ -127,7 +158,10 @@ fun KanbanTaskScreen( val task = detail.task Column(modifier = Modifier.fillMaxSize()) { - PrimaryTabRow(selectedTabIndex = selectedTabIndex) { + PrimaryScrollableTabRow( + selectedTabIndex = selectedTabIndex, + edgePadding = 16.dp, + ) { tabs.forEachIndexed { index, title -> Tab( selected = selectedTabIndex == index, @@ -141,6 +175,26 @@ fun KanbanTaskScreen( 0 -> { TaskOverviewTab( task = task, + links = detail.links, + profiles = state.profiles, + modelProviders = state.modelProviders, + pinnedModels = state.pinnedModels, + estimate = state.estimate, + isEstimating = state.isEstimating, + onReassign = { newProfile -> + viewModel.reassign(boardSlug, taskId, newProfile) + }, + onUpdateModel = { override -> + viewModel.updateModelOverride(boardSlug, taskId, override) + }, + onEstimate = { + viewModel.estimateTask(boardSlug, taskId) + }, + onNavigateToTask = { targetId -> + NavigationController.navigateTo( + KanbanTaskDetailKey(boardSlug = boardSlug, taskId = targetId), + ) + }, onSaveDescription = { newBody -> viewModel.updateDescription(boardSlug, taskId, newBody) }, @@ -191,19 +245,59 @@ fun KanbanTaskScreen( } } } + + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text(stringResource(R.string.kanban_delete_task)) }, + text = { Text(stringResource(R.string.kanban_delete_task_confirm)) }, + confirmButton = { + Button( + onClick = { + showDeleteDialog = false + viewModel.deleteTask(boardSlug, taskId, onSuccess = onBack) + }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(R.string.kanban_delete_task)) + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text(stringResource(R.string.action_cancel)) + } + }, + ) + } } @Composable private fun TaskOverviewTab( - task: com.m57.hermescontrol.data.model.KanbanTaskFull, + task: KanbanTaskFull, + links: TaskLinks, + profiles: List, + modelProviders: List, + pinnedModels: List, + estimate: TaskEstimate?, + isEstimating: Boolean, + onReassign: (String?) -> Unit, + onUpdateModel: (KanbanModelOverride) -> Unit, + onEstimate: () -> Unit, + onNavigateToTask: (String) -> Unit, onSaveDescription: (String) -> Unit, onReclaim: () -> Unit, modifier: Modifier = Modifier, ) { var isEditingDescription by remember { mutableStateOf(false) } var descriptionDraft by remember(task.body) { mutableStateOf(task.body ?: "") } + var assigneeMenuExpanded by remember { mutableStateOf(false) } val statusColors = LocalHermesStatusColors.current + val currentModelOverride = + remember(task.modelOverride, task.providerOverride, task.reasoningEffort) { + KanbanModelOverride.fromTask(task.modelOverride, task.providerOverride, task.reasoningEffort) + } + LazyColumn( modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp), @@ -260,7 +354,7 @@ private fun TaskOverviewTab( } } - // Metadata card + // Metadata / Attributes card Card( shape = RoundedCornerShape(8.dp), modifier = @@ -277,13 +371,199 @@ private fun TaskOverviewTab( ) Spacer(modifier = Modifier.height(8.dp)) MetaRow(label = "Status", value = task.status.uppercase()) - task.assignee?.let { MetaRow(label = "Assignee", value = it) } + + // Editable Assignee + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + ) { + Text( + text = "Assignee", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Box { + TextButton( + onClick = { assigneeMenuExpanded = true }, + contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp), + modifier = Modifier.height(32.dp), + ) { + Text( + text = task.assignee ?: "Unassigned", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + ) + } + DropdownMenu( + expanded = assigneeMenuExpanded, + onDismissRequest = { assigneeMenuExpanded = false }, + ) { + DropdownMenuItem( + text = { Text("Unassigned") }, + onClick = { + onReassign(null) + assigneeMenuExpanded = false + }, + ) + profiles.forEach { p -> + DropdownMenuItem( + text = { Text(p.name) }, + onClick = { + onReassign(p.name) + assigneeMenuExpanded = false + }, + ) + } + } + } + } + task.priority?.let { MetaRow(label = "Priority", value = it.toString()) } task.tenant?.let { MetaRow(label = "Tenant", value = it) } - task.modelOverride?.let { MetaRow(label = "Model Override", value = it) } - task.reasoningEffort?.let { MetaRow(label = "Reasoning Effort", value = it) } task.workspaceKind?.let { MetaRow(label = "Workspace", value = "$it ${task.workspacePath ?: ""}") } task.workerPid?.let { MetaRow(label = "Worker PID", value = it.toString()) } + + Spacer(modifier = Modifier.height(8.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) + + // Editable Model & Reasoning Override + KanbanModelOverrideEditor( + override = currentModelOverride, + onOverrideChange = onUpdateModel, + modelProviders = modelProviders, + pinnedModels = pinnedModels, + ) + } + } + + // Dependencies Section + if (links.parents.isNotEmpty() || links.children.isNotEmpty()) { + Card( + shape = RoundedCornerShape(8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = stringResource(R.string.kanban_dependencies), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + if (links.parents.isNotEmpty()) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = stringResource(R.string.kanban_blocked_by), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + ) { + links.parents.forEach { parentId -> + SuggestionChip( + onClick = { onNavigateToTask(parentId) }, + label = { Text("#${parentId.removePrefix("t_").take(6)}") }, + ) + } + } + } + if (links.children.isNotEmpty()) { + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = stringResource(R.string.kanban_blocks), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), + ) { + links.children.forEach { childId -> + SuggestionChip( + onClick = { onNavigateToTask(childId) }, + label = { Text("#${childId.removePrefix("t_").take(6)}") }, + ) + } + } + } + } + } + } + + // Effort Estimation Section + Card( + shape = RoundedCornerShape(8.dp), + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = stringResource(R.string.kanban_estimate), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + if (isEstimating) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + } + } + val currentEst = estimate + if (currentEst != null) { + Spacer(modifier = Modifier.height(6.dp)) + currentEst.tokens?.let { tokensCount -> + Text( + text = stringResource(R.string.kanban_estimated_tokens, tokensCount), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + ) + } + if (!currentEst.complexity.isNullOrBlank()) { + Text( + text = stringResource(R.string.kanban_complexity, currentEst.complexity), + style = MaterialTheme.typography.bodySmall, + ) + } + if (!currentEst.rationale.isNullOrBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = currentEst.rationale, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(modifier = Modifier.height(8.dp)) + OutlinedButton( + onClick = onEstimate, + enabled = !isEstimating, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.kanban_reestimate_action)) + } + } else { + Spacer(modifier = Modifier.height(6.dp)) + Button( + onClick = onEstimate, + enabled = !isEstimating, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.kanban_estimate_action)) + } + } } } @@ -367,16 +647,13 @@ private fun TaskOverviewTab( color = MaterialTheme.colorScheme.primary, ) Spacer(modifier = Modifier.height(6.dp)) - SelectionContainer { - MarkdownText( - text = summary, - textColor = MaterialTheme.colorScheme.onSurface, - ) - } + MarkdownText( + text = summary, + textColor = MaterialTheme.colorScheme.onSurface, + ) } } } - Spacer(modifier = Modifier.height(24.dp)) } } } @@ -390,29 +667,22 @@ private fun TaskDiscussionTab( onNoteAndRequeue: (String) -> Unit, modifier: Modifier = Modifier, ) { - var commentText by remember { mutableStateOf("") } + var newCommentText by remember { mutableStateOf("") } - Column(modifier = modifier.fillMaxSize()) { + Column(modifier = modifier.fillMaxSize().padding(16.dp)) { LazyColumn( - modifier = - Modifier - .weight(1f) - .padding(horizontal = 16.dp, vertical = 8.dp), + modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(8.dp), ) { if (comments.isEmpty()) { item { - Box(modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { - Text( - "No comments yet. Post a note to steer the agent.", - style = MaterialTheme.typography.bodySmall, - ) - } + Text("No comments yet.", style = MaterialTheme.typography.bodySmall) } } else { items(comments, key = { it.id }) { comment -> Card( - shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(6.dp), modifier = Modifier.fillMaxWidth(), ) { Column(modifier = Modifier.padding(10.dp)) { @@ -422,65 +692,69 @@ private fun TaskDiscussionTab( ) { Text( text = comment.author, - style = MaterialTheme.typography.labelMedium, + style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, ) } Spacer(modifier = Modifier.height(4.dp)) - SelectionContainer { - MarkdownText( - text = comment.body, - textColor = MaterialTheme.colorScheme.onSurface, - ) - } + MarkdownText( + text = comment.body, + textColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) } } } } } - HorizontalDivider() + Spacer(modifier = Modifier.height(8.dp)) - Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { - OutlinedTextField( - value = commentText, - onValueChange = { commentText = it }, - placeholder = { Text("Write a steering note...") }, - modifier = Modifier.fillMaxWidth(), - maxLines = 3, - ) - Spacer(modifier = Modifier.height(8.dp)) - Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { - if (isRunning) { - OutlinedButton( - onClick = { - onNoteAndRequeue(commentText) - commentText = "" - }, - enabled = commentText.isNotBlank() && !isSubmitting, - ) { - Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.width(4.dp)) - Text("Note & Requeue") - } - Spacer(modifier = Modifier.width(8.dp)) - } + OutlinedTextField( + value = newCommentText, + onValueChange = { newCommentText = it }, + placeholder = { Text("Write a comment...") }, + modifier = Modifier.fillMaxWidth(), + maxLines = 4, + enabled = !isSubmitting, + ) - Button( + Spacer(modifier = Modifier.height(8.dp)) + + Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) { + if (isRunning) { + OutlinedButton( onClick = { - onAddComment(commentText) - commentText = "" + if (newCommentText.isNotBlank()) { + onNoteAndRequeue(newCommentText.trim()) + newCommentText = "" + } }, - enabled = commentText.isNotBlank() && !isSubmitting, + enabled = newCommentText.isNotBlank() && !isSubmitting, ) { - if (isSubmitting) { - CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) - } else { - Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp)) - Spacer(modifier = Modifier.width(4.dp)) - Text("Post") - } + Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(4.dp)) + Text("Note & Requeue") } + Spacer(modifier = Modifier.width(8.dp)) + } + + Button( + onClick = { + if (newCommentText.isNotBlank()) { + onAddComment(newCommentText.trim()) + newCommentText = "" + } + }, + enabled = newCommentText.isNotBlank() && !isSubmitting, + ) { + if (isSubmitting) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(6.dp)) + } else { + Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp)) + Spacer(modifier = Modifier.width(6.dp)) + } + Text("Comment") } } } @@ -494,59 +768,58 @@ private fun TaskRunsTab( ) { LazyColumn( modifier = modifier.fillMaxSize().padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { item { - Text("Attempt History", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + Text("Execution Runs", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) } if (runs.isEmpty()) { item { - Text("No worker attempts recorded yet.", style = MaterialTheme.typography.bodySmall) + Text("No run history available.", style = MaterialTheme.typography.bodySmall) } } else { items(runs, key = { it.id }) { run -> Card( - shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(6.dp), modifier = Modifier.fillMaxWidth(), ) { - Column(modifier = Modifier.padding(12.dp)) { + Column(modifier = Modifier.padding(10.dp)) { Row( horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth(), ) { Text( - "Run #${run.id}", - style = MaterialTheme.typography.labelMedium, + text = "Run #${run.id}", + style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, ) Text( - text = (run.outcome ?: run.status).uppercase(), + text = run.status.uppercase(), style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, color = - if (run.outcome == "completed") { - LocalHermesStatusColors.current.success + if (run.status.equals("done", ignoreCase = true)) { + MaterialTheme.colorScheme.primary } else { - MaterialTheme.colorScheme.onSurfaceVariant + MaterialTheme.colorScheme.outline }, ) } - run.profile?.let { - Text("Profile: $it", style = MaterialTheme.typography.bodySmall) - } - val summary = run.error ?: run.summary - if (!summary.isNullOrBlank()) { - Spacer(modifier = Modifier.height(4.dp)) - Text(text = summary, style = MaterialTheme.typography.bodySmall) + run.profile?.let { Text("Profile: $it", style = MaterialTheme.typography.bodySmall) } + run.outcome?.let { Text("Outcome: $it", style = MaterialTheme.typography.bodySmall) } + run.error?.let { + Text( + "Error: $it", + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) } } } } } - // Live Log Output if (workerLog != null && workerLog.exists && workerLog.content.isNotBlank()) { item { Spacer(modifier = Modifier.height(16.dp)) @@ -588,19 +861,33 @@ private fun TaskActivityTab( } } else { items(events, key = { it.id }) { event -> - Row(verticalAlignment = Alignment.CenterVertically) { + val formatted = remember(event) { KanbanEventFormatter.format(event) } + Row( + verticalAlignment = Alignment.Top, + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + ) { Box( modifier = Modifier - .size(8.dp) + .padding(top = 5.dp) + .size(6.dp) .background(MaterialTheme.colorScheme.primary, shape = CircleShape), ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = event.kind, - style = MaterialTheme.typography.bodySmall, - fontWeight = FontWeight.SemiBold, - ) + Spacer(modifier = Modifier.width(10.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = formatted.label, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold, + ) + if (!formatted.detail.isNullOrBlank()) { + Text( + text = formatted.detail, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } } } @@ -649,98 +936,75 @@ private fun TaskFilesTab( } } val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream" - val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - if (bytes != null) { + val bytes = + context.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: byteArrayOf() + if (bytes.isNotEmpty()) { onUpload(filename, mimeType, bytes) } } } - LazyColumn( - modifier = - modifier - .fillMaxSize() - .padding(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - item { - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), + Column(modifier = modifier.fillMaxSize().padding(16.dp)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Attachments", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + Button( + onClick = { launcher.launch("*/*") }, + enabled = !isUploading, ) { - Text( - text = "Attached Files (${attachments.size})", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - Button( - onClick = { launcher.launch("*/*") }, - enabled = !isUploading, - ) { - if (isUploading) { - CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) - Spacer(modifier = Modifier.width(6.dp)) - } - Text("Attach File") + if (isUploading) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(6.dp)) } + Text("Upload") } } - if (attachments.isEmpty()) { - item { - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = "No files attached to this task.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Spacer(modifier = Modifier.height(12.dp)) + + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (attachments.isEmpty()) { + item { + Text("No attachments uploaded.", style = MaterialTheme.typography.bodySmall) } - } - } else { - items(attachments, key = { it.id }) { att -> - Card( - shape = RoundedCornerShape(8.dp), - modifier = Modifier.fillMaxWidth(), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .padding(12.dp) - .fillMaxWidth(), + } else { + items(attachments, key = { it.id }) { att -> + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(6.dp), + modifier = Modifier.fillMaxWidth(), ) { - Icon( - imageVector = Icons.Filled.Description, - contentDescription = null, - modifier = Modifier.size(24.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(modifier = Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = att.filename, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - ) - val sizeStr = - att.size?.let { s -> - if (s > 1024 * 1024) "${s / (1024 * 1024)} MB" else "${s / 1024} KB" - } ?: "Unknown size" - Text( - text = "$sizeStr • ${att.uploadedBy ?: "unknown"}", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(12.dp), + ) { + Icon( + Icons.Filled.Description, + contentDescription = null, + modifier = Modifier.size(24.dp), ) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = att.filename?.ifBlank { "File #${att.id}" } ?: "File #${att.id}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + att.size?.let { + Text( + text = "${it / 1024} KB", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt index 8fe7cc1c..b188628b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanTaskViewModel.kt @@ -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,70 @@ data class KanbanTaskUiState( val isLoading: Boolean = false, val detail: KanbanTaskDetailResponse? = null, val workerLog: WorkerLog? = null, + val profiles: List = emptyList(), + val modelProviders: List = emptyList(), + val pinnedModels: List = 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() }, ) : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(KanbanTaskUiState()) val uiState: StateFlow = _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 + fun loadTask( board: String, taskId: String, ) { + val gen = ++currentLoadGen + activeTaskId = taskId _uiState.update { it.copy(isLoading = true, errorMessage = null) } + loadProfiles() + loadModelOptions() + connectEvents(board) + 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 +108,132 @@ 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 connectEvents(board: String) { + if (eventsBoard == board) return + 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 +277,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 +492,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 +534,18 @@ class KanbanTaskViewModel( } } + public override fun onCleared() { + stopLogPolling() + 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 + } } 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 5c9499f4..3f7e2157 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanViewModel.kt @@ -5,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 = emptyList(), val tasks: List = emptyList(), val profiles: List = emptyList(), + val projects: List = emptyList(), + val orchestration: OrchestrationSettings? = null, + val modelProviders: List = emptyList(), + val pinnedModels: List = emptyList(), val isLive: Boolean = false, + val includeArchived: Boolean = false, + val groupRunning: Boolean = false, + val columnCollapseOverrides: Map = 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 = _uiState.asStateFlow() private var eventsClient: KanbanEventsClient? = null @@ -146,8 +170,11 @@ 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 = _uiState.value.includeArchived)) { is NetworkResult.Success -> { val boards = result.data.boards _uiState.update { it.copy(isLoading = false, boards = boards) } @@ -186,6 +213,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 +261,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 +432,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 +460,7 @@ class KanbanViewModel( slug: String, name: String? = null, description: String? = null, + projectId: String? = null, ) { viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } @@ -285,6 +469,7 @@ class KanbanViewModel( slug = slug, name = name, description = description, + projectId = projectId?.ifBlank { null }, ) when (val res = repository.createBoard(body)) { is NetworkResult.Success -> { @@ -305,16 +490,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 +507,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 +515,23 @@ 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") } + _uiState.update { it.copy(toastMessage = "Board archived") } loadBoards() } @@ -343,7 +539,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 +572,26 @@ class KanbanViewModel( fun bulkMove( taskIds: List, targetStatus: String, + onComplete: ((failedIds: Set) -> 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 +601,34 @@ class KanbanViewModel( toastMessage = "Bulk move failed: ${res.error.message}", ) } + onComplete?.invoke(taskIds.toSet()) } } } } - fun bulkArchive(taskIds: List) { + fun bulkArchive( + taskIds: List, + onComplete: ((failedIds: Set) -> 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 +638,7 @@ class KanbanViewModel( toastMessage = "Bulk archive failed: ${res.error.message}", ) } + onComplete?.invoke(taskIds.toSet()) } } } @@ -403,15 +647,27 @@ class KanbanViewModel( fun bulkAssign( taskIds: List, assignee: String?, + onComplete: ((failedIds: Set) -> 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))) { 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 != null) "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 +677,54 @@ class KanbanViewModel( toastMessage = "Bulk assign failed: ${res.error.message}", ) } + onComplete?.invoke(taskIds.toSet()) } } } } + fun bulkDelete( + taskIds: List, + onComplete: ((failedIds: Set) -> Unit)? = null, + ) { + val board = _uiState.value.selectedBoard ?: return + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true) } + val succeeded = mutableSetOf() + val failed = mutableSetOf() + 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 = + repository.exportBoard(slug, ExportBoardBody(output = "")) + + suspend fun importBoard(serverArchive: String): NetworkResult = + repository.importBoard(ImportBoardBody(archive = serverArchive)) + fun moveTask( task: KanbanTask, action: KanbanTaskAction, @@ -576,7 +875,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 +901,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 +939,10 @@ class KanbanViewModel( } } + fun showToast(message: String) { + _uiState.update { it.copy(toastMessage = message) } + } + override fun clearToast() { _uiState.update { it.copy(toastMessage = null) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanBoardSettingsDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanBoardSettingsDialog.kt new file mode 100644 index 00000000..907b8f0f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanBoardSettingsDialog.kt @@ -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, + 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("Board Name") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + OutlinedTextField( + value = description, + onValueChange = { description = it }, + label = { Text("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("Default Workdir") }, + placeholder = { Text("e.g. /home/user/project") }, + 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)) + } + }, + ) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt index f2a3ed35..d8586762 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanCreateTaskDialog.kt @@ -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 @@ -49,8 +50,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 +71,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 = emptyList(), + pinnedModels: List = 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(null) } + var selectedAssignee by remember(defaultAssignee) { + mutableStateOf(defaultAssignee ?: profiles.firstOrNull()?.name ?: "default") + } var priority by remember { mutableIntStateOf(0) } var goalMode by remember { mutableStateOf(false) } @@ -84,16 +96,22 @@ fun KanbanCreateTaskDialog( // 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 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 +145,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 +167,7 @@ fun KanbanCreateTaskDialog( expanded = columnExpanded, onDismissRequest = { columnExpanded = false }, ) { - columns.forEach { col -> + writableColumns.forEach { col -> DropdownMenuItem( text = { Text(col.name.uppercase()) }, onClick = { @@ -169,10 +187,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 +217,12 @@ 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) "${profile.name} (Default)" else profile.name) if (profile.model != null) { Text( profile.model, @@ -258,11 +282,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 +291,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 +305,42 @@ 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 } + .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 +369,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 +386,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), @@ -420,7 +409,7 @@ fun KanbanCreateTaskDialog( modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), ) { listOf( - null to "Scratch (Default)", + "scratch" to "Scratch (Default)", "worktree" to "Worktree", "dir" to "Dir", ).forEach { (kind, label) -> @@ -433,6 +422,25 @@ fun KanbanCreateTaskDialog( } } + 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(), + singleLine = true, + enabled = !isCreating, + ) + } + Spacer(modifier = Modifier.height(8.dp)) // Skills @@ -448,40 +456,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 +522,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 +547,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), ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanModelOverrideEditor.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanModelOverrideEditor.kt new file mode 100644 index 00000000..c04d3f7e --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanModelOverrideEditor.kt @@ -0,0 +1,166 @@ +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.model.components.ModelPickerDialog + +@Composable +fun KanbanModelOverrideEditor( + override: KanbanModelOverride, + onOverrideChange: (KanbanModelOverride) -> Unit, + modelProviders: List, + pinnedModels: List = 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)) + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = + Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + ) { + listOf( + "" to stringResource(R.string.kanban_effort_inherit), + "none" to stringResource(R.string.kanban_effort_none), + "low" to stringResource(R.string.kanban_effort_low), + "medium" to stringResource(R.string.kanban_effort_medium), + "high" to stringResource(R.string.kanban_effort_high), + ).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 -> + onOverrideChange(override.copy(provider = provider, model = model)) + showPickerDialog = false + }, + onDismiss = { showPickerDialog = false }, + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanOrchestrationDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanOrchestrationDialog.kt new file mode 100644 index 00000000..29dc7929 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/components/KanbanOrchestrationDialog.kt @@ -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, + 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("Profile description for routing") }, + ) + } + } +} 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 959f6ab3..0f725509 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 @@ -37,6 +37,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.m57.hermescontrol.data.model.KanbanTask import com.m57.hermescontrol.theme.LocalHermesStatusColors +import com.m57.hermescontrol.ui.kanban.KanbanArcState +import com.m57.hermescontrol.ui.kanban.KanbanRuntimeHelper @Composable fun KanbanTaskCard( @@ -44,6 +46,7 @@ fun KanbanTaskCard( onTaskClick: (KanbanTask) -> Unit, modifier: Modifier = Modifier, isSelected: Boolean = false, + defaultAssignee: String? = null, onActionClick: ((KanbanTask) -> Unit)? = null, ) { val statusColors = LocalHermesStatusColors.current @@ -57,6 +60,10 @@ fun KanbanTaskCard( else -> MaterialTheme.colorScheme.outline } + val arcState = KanbanRuntimeHelper.arcState(task, defaultAssignee) + val isWontRun = KanbanRuntimeHelper.isWontRun(task, defaultAssignee) + val elapsed = KanbanRuntimeHelper.formatElapsed(task.startedAt) + val cardColors = if (isSelected) { CardDefaults.cardColors( @@ -92,6 +99,37 @@ fun KanbanTaskCard( fontWeight = FontWeight.Bold, color = statusTone, ) + when { + arcState == KanbanArcState.RUNNING -> { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "· ${elapsed ?: "working"}", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = statusColors.success, + ) + } + + arcState == KanbanArcState.STALE -> { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "· STALE", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = statusColors.warning, + ) + } + + isWontRun -> { + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = "· WON'T RUN", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = statusColors.error, + ) + } + } } Row(verticalAlignment = Alignment.CenterVertically) { Text( @@ -145,7 +183,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),