feat(kanban): implement board parity, orchestration and task editing (#1137)

This commit is contained in:
M57 committed 2026-09-16 18:14:59 +03:00
1 parent dd11f19a1c
commit c97d7127fb
9 files changed
+2670 -550

No files matched your search

File diff suppressed because it is too large. Load diff
@@ -5,9 +5,11 @@ import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize 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.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer 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.ChatBubbleOutline
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close 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.Description
import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -43,9 +51,11 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField 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.Tab
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -56,11 +66,21 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel 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.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.MarkdownText import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.common.ErrorState 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.NavIcon
import com.m57.hermescontrol.ui.common.SkeletonListState import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.kanban.components.KanbanModelOverrideEditor
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -81,6 +102,7 @@ fun KanbanTaskScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
var selectedTabIndex by remember { mutableIntStateOf(0) } var selectedTabIndex by remember { mutableIntStateOf(0) }
val tabs = listOf("Overview", "Discussion", "Runs", "Activity", "Files") val tabs = listOf("Overview", "Discussion", "Runs", "Activity", "Files")
var showDeleteDialog by remember { mutableStateOf(false) }
LaunchedEffect(boardSlug, taskId) { LaunchedEffect(boardSlug, taskId) {
viewModel.loadTask(boardSlug, taskId) viewModel.loadTask(boardSlug, taskId)
@@ -105,6 +127,15 @@ fun KanbanTaskScreen(
} }
}, },
navigationIcon = NavIcon.Back(onBack), 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, drawerGesturesEnabled = false,
isRefreshing = state.isLoading, isRefreshing = state.isLoading,
onRefresh = { viewModel.loadTask(boardSlug, taskId) }, onRefresh = { viewModel.loadTask(boardSlug, taskId) },
@@ -127,7 +158,10 @@ fun KanbanTaskScreen(
val task = detail.task val task = detail.task
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
PrimaryTabRow(selectedTabIndex = selectedTabIndex) { PrimaryScrollableTabRow(
selectedTabIndex = selectedTabIndex,
edgePadding = 16.dp,
) {
tabs.forEachIndexed { index, title -> tabs.forEachIndexed { index, title ->
Tab( Tab(
selected = selectedTabIndex == index, selected = selectedTabIndex == index,
@@ -141,6 +175,26 @@ fun KanbanTaskScreen(
0 -> { 0 -> {
TaskOverviewTab( TaskOverviewTab(
task = task, 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 -> onSaveDescription = { newBody ->
viewModel.updateDescription(boardSlug, taskId, 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 @Composable
private fun TaskOverviewTab( private fun TaskOverviewTab(
task: com.m57.hermescontrol.data.model.KanbanTaskFull, task: KanbanTaskFull,
links: TaskLinks,
profiles: List<KanbanProfile>,
modelProviders: List<ModelProvider>,
pinnedModels: List<PinnedModel>,
estimate: TaskEstimate?,
isEstimating: Boolean,
onReassign: (String?) -> Unit,
onUpdateModel: (KanbanModelOverride) -> Unit,
onEstimate: () -> Unit,
onNavigateToTask: (String) -> Unit,
onSaveDescription: (String) -> Unit, onSaveDescription: (String) -> Unit,
onReclaim: () -> Unit, onReclaim: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
var isEditingDescription by remember { mutableStateOf(false) } var isEditingDescription by remember { mutableStateOf(false) }
var descriptionDraft by remember(task.body) { mutableStateOf(task.body ?: "") } var descriptionDraft by remember(task.body) { mutableStateOf(task.body ?: "") }
var assigneeMenuExpanded by remember { mutableStateOf(false) }
val statusColors = LocalHermesStatusColors.current val statusColors = LocalHermesStatusColors.current
val currentModelOverride =
remember(task.modelOverride, task.providerOverride, task.reasoningEffort) {
KanbanModelOverride.fromTask(task.modelOverride, task.providerOverride, task.reasoningEffort)
}
LazyColumn( LazyColumn(
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
@@ -260,7 +354,7 @@ private fun TaskOverviewTab(
} }
} }
// Metadata card // Metadata / Attributes card
Card( Card(
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(8.dp),
modifier = modifier =
@@ -277,13 +371,199 @@ private fun TaskOverviewTab(
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
MetaRow(label = "Status", value = task.status.uppercase()) 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.priority?.let { MetaRow(label = "Priority", value = it.toString()) }
task.tenant?.let { MetaRow(label = "Tenant", value = it) } 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.workspaceKind?.let { MetaRow(label = "Workspace", value = "$it ${task.workspacePath ?: ""}") }
task.workerPid?.let { MetaRow(label = "Worker PID", value = it.toString()) } 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, color = MaterialTheme.colorScheme.primary,
) )
Spacer(modifier = Modifier.height(6.dp)) Spacer(modifier = Modifier.height(6.dp))
SelectionContainer { MarkdownText(
MarkdownText( text = summary,
text = summary, textColor = MaterialTheme.colorScheme.onSurface,
textColor = MaterialTheme.colorScheme.onSurface, )
)
}
} }
} }
} }
Spacer(modifier = Modifier.height(24.dp))
} }
} }
} }
@@ -390,29 +667,22 @@ private fun TaskDiscussionTab(
onNoteAndRequeue: (String) -> Unit, onNoteAndRequeue: (String) -> Unit,
modifier: Modifier = Modifier, 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( LazyColumn(
modifier = modifier = Modifier.weight(1f),
Modifier
.weight(1f)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
if (comments.isEmpty()) { if (comments.isEmpty()) {
item { item {
Box(modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { Text("No comments yet.", style = MaterialTheme.typography.bodySmall)
Text(
"No comments yet. Post a note to steer the agent.",
style = MaterialTheme.typography.bodySmall,
)
}
} }
} else { } else {
items(comments, key = { it.id }) { comment -> items(comments, key = { it.id }) { comment ->
Card( Card(
shape = RoundedCornerShape(8.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Column(modifier = Modifier.padding(10.dp)) { Column(modifier = Modifier.padding(10.dp)) {
@@ -422,65 +692,69 @@ private fun TaskDiscussionTab(
) { ) {
Text( Text(
text = comment.author, text = comment.author,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
} }
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
SelectionContainer { MarkdownText(
MarkdownText( text = comment.body,
text = comment.body, textColor = MaterialTheme.colorScheme.onSurfaceVariant,
textColor = MaterialTheme.colorScheme.onSurface, )
)
}
} }
} }
} }
} }
} }
HorizontalDivider() Spacer(modifier = Modifier.height(8.dp))
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) { OutlinedTextField(
OutlinedTextField( value = newCommentText,
value = commentText, onValueChange = { newCommentText = it },
onValueChange = { commentText = it }, placeholder = { Text("Write a comment...") },
placeholder = { Text("Write a steering note...") }, modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(), maxLines = 4,
maxLines = 3, enabled = !isSubmitting,
) )
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
if (isRunning) {
OutlinedButton(
onClick = {
onNoteAndRequeue(commentText)
commentText = ""
},
enabled = commentText.isNotBlank() && !isSubmitting,
) {
Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Note & Requeue")
}
Spacer(modifier = Modifier.width(8.dp))
}
Button( Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
if (isRunning) {
OutlinedButton(
onClick = { onClick = {
onAddComment(commentText) if (newCommentText.isNotBlank()) {
commentText = "" onNoteAndRequeue(newCommentText.trim())
newCommentText = ""
}
}, },
enabled = commentText.isNotBlank() && !isSubmitting, enabled = newCommentText.isNotBlank() && !isSubmitting,
) { ) {
if (isSubmitting) { Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(16.dp))
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) Spacer(modifier = Modifier.width(4.dp))
} else { Text("Note & Requeue")
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Post")
}
} }
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( LazyColumn(
modifier = modifier.fillMaxSize().padding(16.dp), modifier = modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
item { item {
Text("Attempt History", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) Text("Execution Runs", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
} }
if (runs.isEmpty()) { if (runs.isEmpty()) {
item { item {
Text("No worker attempts recorded yet.", style = MaterialTheme.typography.bodySmall) Text("No run history available.", style = MaterialTheme.typography.bodySmall)
} }
} else { } else {
items(runs, key = { it.id }) { run -> items(runs, key = { it.id }) { run ->
Card( Card(
shape = RoundedCornerShape(8.dp), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(10.dp)) {
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text( Text(
"Run #${run.id}", text = "Run #${run.id}",
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Text( Text(
text = (run.outcome ?: run.status).uppercase(), text = run.status.uppercase(),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = color =
if (run.outcome == "completed") { if (run.status.equals("done", ignoreCase = true)) {
LocalHermesStatusColors.current.success MaterialTheme.colorScheme.primary
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.colorScheme.outline
}, },
) )
} }
run.profile?.let { run.profile?.let { Text("Profile: $it", style = MaterialTheme.typography.bodySmall) }
Text("Profile: $it", style = MaterialTheme.typography.bodySmall) run.outcome?.let { Text("Outcome: $it", style = MaterialTheme.typography.bodySmall) }
} run.error?.let {
val summary = run.error ?: run.summary Text(
if (!summary.isNullOrBlank()) { "Error: $it",
Spacer(modifier = Modifier.height(4.dp)) color = MaterialTheme.colorScheme.error,
Text(text = summary, style = MaterialTheme.typography.bodySmall) style = MaterialTheme.typography.bodySmall,
)
} }
} }
} }
} }
} }
// Live Log Output
if (workerLog != null && workerLog.exists && workerLog.content.isNotBlank()) { if (workerLog != null && workerLog.exists && workerLog.content.isNotBlank()) {
item { item {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -588,19 +861,33 @@ private fun TaskActivityTab(
} }
} else { } else {
items(events, key = { it.id }) { event -> 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( Box(
modifier = modifier =
Modifier Modifier
.size(8.dp) .padding(top = 5.dp)
.size(6.dp)
.background(MaterialTheme.colorScheme.primary, shape = CircleShape), .background(MaterialTheme.colorScheme.primary, shape = CircleShape),
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(10.dp))
Text( Column(modifier = Modifier.weight(1f)) {
text = event.kind, Text(
style = MaterialTheme.typography.bodySmall, text = formatted.label,
fontWeight = FontWeight.SemiBold, 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 mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } val bytes =
if (bytes != null) { context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: byteArrayOf()
if (bytes.isNotEmpty()) {
onUpload(filename, mimeType, bytes) onUpload(filename, mimeType, bytes)
} }
} }
} }
LazyColumn( Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
modifier = Row(
modifier horizontalArrangement = Arrangement.SpaceBetween,
.fillMaxSize() verticalAlignment = Alignment.CenterVertically,
.padding(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp), ) {
) { Text("Attachments", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
item { Button(
Row( onClick = { launcher.launch("*/*") },
horizontalArrangement = Arrangement.SpaceBetween, enabled = !isUploading,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) { ) {
Text( if (isUploading) {
text = "Attached Files (${attachments.size})", CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
style = MaterialTheme.typography.titleSmall, Spacer(modifier = Modifier.width(6.dp))
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")
} }
Text("Upload")
} }
} }
if (attachments.isEmpty()) { Spacer(modifier = Modifier.height(12.dp))
item {
Box( LazyColumn(
modifier = modifier = Modifier.weight(1f),
Modifier verticalArrangement = Arrangement.spacedBy(8.dp),
.fillMaxWidth() ) {
.padding(vertical = 32.dp), if (attachments.isEmpty()) {
contentAlignment = Alignment.Center, item {
) { Text("No attachments uploaded.", style = MaterialTheme.typography.bodySmall)
Text(
text = "No files attached to this task.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} } else {
} else { items(attachments, key = { it.id }) { att ->
items(attachments, key = { it.id }) { att -> Card(
Card( colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(8.dp), shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(12.dp)
.fillMaxWidth(),
) { ) {
Icon( Row(
imageVector = Icons.Filled.Description, verticalAlignment = Alignment.CenterVertically,
contentDescription = null, modifier = Modifier.padding(12.dp),
modifier = Modifier.size(24.dp), ) {
tint = MaterialTheme.colorScheme.primary, Icon(
) Icons.Filled.Description,
Spacer(modifier = Modifier.width(12.dp)) contentDescription = null,
Column(modifier = Modifier.weight(1f)) { modifier = Modifier.size(24.dp),
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,
) )
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,
)
}
}
} }
} }
} }
@@ -2,19 +2,28 @@ package com.m57.hermescontrol.ui.kanban
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope 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.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.ReassignTaskBody import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.ReclaimTaskBody 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.UpdateTaskBody
import com.m57.hermescontrol.data.model.WorkerLog 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.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.repository.KanbanRepository import com.m57.hermescontrol.data.repository.KanbanRepository
import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
import com.m57.hermescontrol.data.ws.KanbanEventsClient
import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody import okhttp3.MultipartBody
@@ -24,43 +33,70 @@ data class KanbanTaskUiState(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val detail: KanbanTaskDetailResponse? = null, val detail: KanbanTaskDetailResponse? = null,
val workerLog: WorkerLog? = null, val workerLog: WorkerLog? = null,
val profiles: List<KanbanProfile> = emptyList(),
val modelProviders: List<ModelProvider> = emptyList(),
val pinnedModels: List<PinnedModel> = emptyList(),
val estimate: TaskEstimate? = null,
val isEstimating: Boolean = false,
val isDeleting: Boolean = false,
val errorMessage: String? = null, val errorMessage: String? = null,
val toastMessage: String? = null, val toastMessage: String? = null,
val isSubmittingComment: Boolean = false, val isSubmittingComment: Boolean = false,
val isSavingDescription: Boolean = false, val isSavingDescription: Boolean = false,
val isReclaiming: Boolean = false, val isReclaiming: Boolean = false,
val isReassigning: Boolean = false,
val isUpdatingModel: Boolean = false,
val isUploadingAttachment: Boolean = false, val isUploadingAttachment: Boolean = false,
) )
class KanbanTaskViewModel( class KanbanTaskViewModel(
private val repository: KanbanRepository = KanbanRepositoryImpl(), private val repository: KanbanRepository = KanbanRepositoryImpl(),
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
) : ViewModel(), ) : ViewModel(),
ToastHost { ToastHost {
private val _uiState = MutableStateFlow(KanbanTaskUiState()) private val _uiState = MutableStateFlow(KanbanTaskUiState())
val uiState: StateFlow<KanbanTaskUiState> = _uiState.asStateFlow() val uiState: StateFlow<KanbanTaskUiState> = _uiState.asStateFlow()
private var eventsClient: KanbanEventsClient? = null
private var eventsBoard: String? = null
private var activeTaskId: String? = null
private var currentLoadGen: Int = 0
private var reloadJob: Job? = null
private var logPollJob: Job? = null
fun loadTask( fun loadTask(
board: String, board: String,
taskId: String, taskId: String,
) { ) {
val gen = ++currentLoadGen
activeTaskId = taskId
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
loadProfiles()
loadModelOptions()
connectEvents(board)
viewModelScope.launch { viewModelScope.launch {
when (val result = repository.getTask(taskId = taskId, board = board)) { when (val result = repository.getTask(taskId = taskId, board = board)) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
if (gen != currentLoadGen) return@launch
val taskData = result.data
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
detail = result.data, detail = taskData,
) )
} }
if (result.data.task.status if (taskData.task.status.equals("running", ignoreCase = true)) {
.equals("running", ignoreCase = true) startLogPolling(board, taskId)
) { } else {
stopLogPolling()
loadLog(board, taskId) loadLog(board, taskId)
} }
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
if (gen != currentLoadGen) return@launch
stopLogPolling()
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, 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( fun loadLog(
board: String, board: String,
taskId: String, taskId: String,
) { ) {
viewModelScope.launch { viewModelScope.launch {
when (val result = repository.getTaskLog(taskId = taskId, board = board)) { fetchLogInternal(board, taskId)
is NetworkResult.Success -> { }
_uiState.update { it.copy(workerLog = result.data) } }
}
is NetworkResult.Failure -> { private suspend fun fetchLogInternal(
// Failures to read log don't blank the task screen 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( fun addComment(
board: String, board: String,
taskId: 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( fun uploadAttachment(
board: String, board: String,
taskId: String, taskId: String,
@@ -295,7 +534,18 @@ class KanbanTaskViewModel(
} }
} }
public override fun onCleared() {
stopLogPolling()
eventsClient?.disconnect()
super.onCleared()
}
override fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
private companion object {
const val RELOAD_DEBOUNCE_MS = 250L
const val POLL_INTERVAL_MS = 3000L
}
} }
@@ -5,17 +5,28 @@ import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore
import com.m57.hermescontrol.data.local.KanbanPreferencesStore 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.BulkTasksBody
import com.m57.hermescontrol.data.model.CreateBoardBody import com.m57.hermescontrol.data.model.CreateBoardBody
import com.m57.hermescontrol.data.model.CreateTaskBody 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.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanColumn import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanProfile 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.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.RenameBoardBody
import com.m57.hermescontrol.data.model.TaskEstimate import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.UpdateTaskBody 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.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.repository.KanbanRepository import com.m57.hermescontrol.data.repository.KanbanRepository
import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
import com.m57.hermescontrol.data.ws.KanbanEventsClient import com.m57.hermescontrol.data.ws.KanbanEventsClient
@@ -38,7 +49,14 @@ data class KanbanUiState(
val columns: List<KanbanColumn> = emptyList(), val columns: List<KanbanColumn> = emptyList(),
val tasks: List<KanbanTask> = emptyList(), val tasks: List<KanbanTask> = emptyList(),
val profiles: List<KanbanProfile> = emptyList(), val profiles: List<KanbanProfile> = emptyList(),
val projects: List<KanbanProject> = emptyList(),
val orchestration: OrchestrationSettings? = null,
val modelProviders: List<ModelProvider> = emptyList(),
val pinnedModels: List<PinnedModel> = emptyList(),
val isLive: Boolean = false, val isLive: Boolean = false,
val includeArchived: Boolean = false,
val groupRunning: Boolean = false,
val columnCollapseOverrides: Map<String, Boolean> = emptyMap(),
val errorMessage: String? = null, val errorMessage: String? = null,
val toastMessage: String? = null, val toastMessage: String? = null,
) )
@@ -131,7 +149,13 @@ class KanbanViewModel(
}, },
) : ViewModel(), ) : ViewModel(),
ToastHost { ToastHost {
private val _uiState = MutableStateFlow(KanbanUiState()) private val _uiState =
MutableStateFlow(
KanbanUiState(
includeArchived = preferences.getIncludeArchived(),
groupRunning = preferences.getGroupRunning(),
),
)
val uiState: StateFlow<KanbanUiState> = _uiState.asStateFlow() val uiState: StateFlow<KanbanUiState> = _uiState.asStateFlow()
private var eventsClient: KanbanEventsClient? = null private var eventsClient: KanbanEventsClient? = null
@@ -146,8 +170,11 @@ class KanbanViewModel(
_uiState.update { it.copy(isLoading = true, errorMessage = null) } _uiState.update { it.copy(isLoading = true, errorMessage = null) }
loadProfiles() loadProfiles()
loadProjects()
loadOrchestration()
loadModelOptions()
viewModelScope.launch { viewModelScope.launch {
when (val result = repository.getBoards()) { when (val result = repository.getBoards(includeArchived = _uiState.value.includeArchived)) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
val boards = result.data.boards val boards = result.data.boards
_uiState.update { it.copy(isLoading = false, boards = 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() { fun loadProfiles() {
viewModelScope.launch { viewModelScope.launch {
when (val result = repository.getProfiles()) { 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( fun createTask(
body: CreateTaskBody, body: CreateTaskBody,
targetStatus: String = "todo", targetStatus: String = "todo",
@@ -254,11 +432,16 @@ class KanbanViewModel(
description: String?, description: String?,
status: String = "todo", status: String = "todo",
) { ) {
val defaultAssignee =
_uiState.value.orchestration
?.resolvedDefaultAssignee
?.ifBlank { null } ?: "default"
createTask( createTask(
body = body =
CreateTaskBody( CreateTaskBody(
title = title, title = title,
body = description, body = description,
assignee = defaultAssignee,
), ),
targetStatus = status, targetStatus = status,
) )
@@ -277,6 +460,7 @@ class KanbanViewModel(
slug: String, slug: String,
name: String? = null, name: String? = null,
description: String? = null, description: String? = null,
projectId: String? = null,
) { ) {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) } _uiState.update { it.copy(isLoading = true) }
@@ -285,6 +469,7 @@ class KanbanViewModel(
slug = slug, slug = slug,
name = name, name = name,
description = description, description = description,
projectId = projectId?.ifBlank { null },
) )
when (val res = repository.createBoard(body)) { when (val res = repository.createBoard(body)) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
@@ -305,16 +490,16 @@ class KanbanViewModel(
} }
} }
fun renameBoard( fun updateBoard(
slug: String, slug: String,
newName: String, body: RenameBoardBody,
) { ) {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) } _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 -> { is NetworkResult.Success -> {
val boardName = res.data.board?.name ?: newName val boardName = res.data.board?.displayName ?: slug
_uiState.update { it.copy(toastMessage = "Board renamed to $boardName") } _uiState.update { it.copy(toastMessage = "Board updated: $boardName") }
loadBoards() loadBoards()
} }
@@ -322,7 +507,7 @@ class KanbanViewModel(
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, 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) { 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 { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) } _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 -> { is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Board deleted") } _uiState.update { it.copy(toastMessage = "Board archived") }
loadBoards() loadBoards()
} }
@@ -343,7 +539,29 @@ class KanbanViewModel(
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, 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( fun bulkMove(
taskIds: List<String>, taskIds: List<String>,
targetStatus: String, targetStatus: String,
onComplete: ((failedIds: Set<String>) -> Unit)? = null,
) { ) {
val board = _uiState.value.selectedBoard ?: return val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) } _uiState.update { it.copy(isLoading = true) }
when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, status = targetStatus))) { when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, status = targetStatus))) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
val count = res.data.results.count { it.ok } val results = res.data.results
_uiState.update { it.copy(toastMessage = "Moved $count tasks to $targetStatus") } 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() reloadBoardSilently()
onComplete?.invoke(failed)
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -372,20 +601,34 @@ class KanbanViewModel(
toastMessage = "Bulk move failed: ${res.error.message}", toastMessage = "Bulk move failed: ${res.error.message}",
) )
} }
onComplete?.invoke(taskIds.toSet())
} }
} }
} }
} }
fun bulkArchive(taskIds: List<String>) { fun bulkArchive(
taskIds: List<String>,
onComplete: ((failedIds: Set<String>) -> Unit)? = null,
) {
val board = _uiState.value.selectedBoard ?: return val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) } _uiState.update { it.copy(isLoading = true) }
when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, archive = true))) { when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, archive = true))) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
val count = res.data.results.count { it.ok } val results = res.data.results
_uiState.update { it.copy(toastMessage = "Archived $count tasks") } 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() reloadBoardSilently()
onComplete?.invoke(failed)
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -395,6 +638,7 @@ class KanbanViewModel(
toastMessage = "Bulk archive failed: ${res.error.message}", toastMessage = "Bulk archive failed: ${res.error.message}",
) )
} }
onComplete?.invoke(taskIds.toSet())
} }
} }
} }
@@ -403,15 +647,27 @@ class KanbanViewModel(
fun bulkAssign( fun bulkAssign(
taskIds: List<String>, taskIds: List<String>,
assignee: String?, assignee: String?,
onComplete: ((failedIds: Set<String>) -> Unit)? = null,
) { ) {
val board = _uiState.value.selectedBoard ?: return val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) } _uiState.update { it.copy(isLoading = true) }
when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, assignee = assignee))) { when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, assignee = assignee))) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
val count = res.data.results.count { it.ok } val results = res.data.results
_uiState.update { it.copy(toastMessage = "Assigned $count tasks") } 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() reloadBoardSilently()
onComplete?.invoke(failed)
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
@@ -421,11 +677,54 @@ class KanbanViewModel(
toastMessage = "Bulk assign failed: ${res.error.message}", toastMessage = "Bulk assign failed: ${res.error.message}",
) )
} }
onComplete?.invoke(taskIds.toSet())
} }
} }
} }
} }
fun bulkDelete(
taskIds: List<String>,
onComplete: ((failedIds: Set<String>) -> Unit)? = null,
) {
val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val succeeded = mutableSetOf<String>()
val failed = mutableSetOf<String>()
var firstError: String? = null
taskIds.forEach { taskId ->
when (val res = repository.deleteTask(taskId = taskId, board = board.id)) {
is NetworkResult.Success -> {
succeeded.add(taskId)
}
is NetworkResult.Failure -> {
failed.add(taskId)
if (firstError == null) firstError = res.error.message
}
}
}
val msg =
when {
failed.isEmpty() -> "Deleted ${succeeded.size} tasks"
succeeded.isEmpty() -> "Failed to delete tasks: ${firstError ?: "unknown error"}"
else -> "Deleted ${succeeded.size} tasks, ${failed.size} failed: ${firstError ?: ""}"
}
_uiState.update { it.copy(toastMessage = msg) }
reloadBoardSilently()
onComplete?.invoke(failed)
}
}
suspend fun exportBoard(slug: String): NetworkResult<BoardExportResult> =
repository.exportBoard(slug, ExportBoardBody(output = ""))
suspend fun importBoard(serverArchive: String): NetworkResult<BoardImportResult> =
repository.importBoard(ImportBoardBody(archive = serverArchive))
fun moveTask( fun moveTask(
task: KanbanTask, task: KanbanTask,
action: KanbanTaskAction, action: KanbanTaskAction,
@@ -576,7 +875,11 @@ class KanbanViewModel(
val board = _uiState.value.selectedBoard ?: return val board = _uiState.value.selectedBoard ?: return
val gen = currentLoadGen val gen = currentLoadGen
viewModelScope.launch { 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 (gen != currentLoadGen) return@launch
if (result is NetworkResult.Success) { if (result is NetworkResult.Success) {
val body = result.data val body = result.data
@@ -598,7 +901,11 @@ class KanbanViewModel(
board: KanbanBoard, board: KanbanBoard,
gen: Int = currentLoadGen, 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 if (gen != currentLoadGen) return
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
@@ -632,6 +939,10 @@ class KanbanViewModel(
} }
} }
fun showToast(message: String) {
_uiState.update { it.copy(toastMessage = message) }
}
override fun clearToast() { override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) } _uiState.update { it.copy(toastMessage = null) }
} }
@@ -0,0 +1,155 @@
package com.m57.hermescontrol.ui.kanban.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanProject
import com.m57.hermescontrol.data.model.RenameBoardBody
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KanbanBoardSettingsDialog(
board: KanbanBoard,
projects: List<KanbanProject>,
onSave: (RenameBoardBody) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
var name by remember(board) { mutableStateOf(board.name ?: "") }
var description by remember(board) { mutableStateOf(board.description ?: "") }
var defaultWorkdir by remember(board) { mutableStateOf(board.defaultWorkdir ?: "") }
var selectedProjectId by remember(board) { mutableStateOf(board.projectId) }
var projectExpanded by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
modifier = modifier,
title = { Text(stringResource(R.string.kanban_board_settings)) },
text = {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("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))
}
},
)
}
@@ -29,6 +29,7 @@ import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChip
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text 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.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanProfile import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanTask 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.data.model.TaskEstimate
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState 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 import kotlinx.coroutines.launch
private const val PARKED_VALUE = "__parked__" private const val PARKED_VALUE = "__parked__"
@@ -66,13 +71,20 @@ fun KanbanCreateTaskDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onConfirm: (body: CreateTaskBody, targetStatus: String) -> Unit, onConfirm: (body: CreateTaskBody, targetStatus: String) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
defaultAssignee: String? = null,
boardDefaultWorkspaceKind: String? = null,
boardDefaultWorkdir: String? = null,
modelProviders: List<ModelProvider> = emptyList(),
pinnedModels: List<PinnedModel> = emptyList(),
onEstimate: (suspend (title: String, body: String?) -> TaskEstimate?)? = null, onEstimate: (suspend (title: String, body: String?) -> TaskEstimate?)? = null,
) { ) {
var title by remember { mutableStateOf("") } var title by remember { mutableStateOf("") }
var desc by remember { mutableStateOf("") } var desc by remember { mutableStateOf("") }
val descTextFieldState = rememberSyncedTextFieldState(desc) { desc = it } val descTextFieldState = rememberSyncedTextFieldState(desc) { desc = it }
var selectedColumn by remember(defaultColumn) { mutableStateOf(defaultColumn) } var selectedColumn by remember(defaultColumn) { mutableStateOf(defaultColumn) }
var selectedAssignee by remember { mutableStateOf<String?>(null) } var selectedAssignee by remember(defaultAssignee) {
mutableStateOf(defaultAssignee ?: profiles.firstOrNull()?.name ?: "default")
}
var priority by remember { mutableIntStateOf(0) } var priority by remember { mutableIntStateOf(0) }
var goalMode by remember { mutableStateOf(false) } var goalMode by remember { mutableStateOf(false) }
@@ -84,16 +96,22 @@ fun KanbanCreateTaskDialog(
// Advanced options // Advanced options
var showAdvanced by remember { mutableStateOf(false) } var showAdvanced by remember { mutableStateOf(false) }
var parentTaskId by remember { mutableStateOf<String?>(null) } var parentTaskId by remember { mutableStateOf<String?>(null) }
var modelOverride by remember { mutableStateOf("") } var modelOverride by remember { mutableStateOf(KanbanModelOverride.EMPTY) }
var providerOverride by remember { mutableStateOf("") } var workspaceKind by remember(boardDefaultWorkspaceKind) {
var reasoningEffort by remember { mutableStateOf<String?>(null) } mutableStateOf(boardDefaultWorkspaceKind ?: "scratch")
var workspaceKind by remember { mutableStateOf<String?>(null) } }
var workspacePath by remember { mutableStateOf("") }
var skillsText by remember { mutableStateOf("") } var skillsText by remember { mutableStateOf("") }
var assigneeExpanded by remember { mutableStateOf(false) } var assigneeExpanded by remember { mutableStateOf(false) }
var columnExpanded by remember { mutableStateOf(false) } var columnExpanded by remember { mutableStateOf(false) }
var parentExpanded by remember { mutableStateOf(false) } var parentExpanded by remember { mutableStateOf(false) }
val writableColumns =
remember(columns) {
columns.filter { KanbanStatusConstraints.isUserWritableTarget(it.name) }
}
AlertDialog( AlertDialog(
onDismissRequest = { if (!isCreating) onDismiss() }, onDismissRequest = { if (!isCreating) onDismiss() },
modifier = modifier, modifier = modifier,
@@ -127,7 +145,7 @@ fun KanbanCreateTaskDialog(
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
// Target Column selector // Target Column selector
if (columns.isNotEmpty()) { if (writableColumns.isNotEmpty()) {
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
expanded = columnExpanded, expanded = columnExpanded,
onExpandedChange = { if (!isCreating) columnExpanded = it }, onExpandedChange = { if (!isCreating) columnExpanded = it },
@@ -149,7 +167,7 @@ fun KanbanCreateTaskDialog(
expanded = columnExpanded, expanded = columnExpanded,
onDismissRequest = { columnExpanded = false }, onDismissRequest = { columnExpanded = false },
) { ) {
columns.forEach { col -> writableColumns.forEach { col ->
DropdownMenuItem( DropdownMenuItem(
text = { Text(col.name.uppercase()) }, text = { Text(col.name.uppercase()) },
onClick = { onClick = {
@@ -169,10 +187,22 @@ fun KanbanCreateTaskDialog(
onExpandedChange = { if (!isCreating) assigneeExpanded = it }, onExpandedChange = { if (!isCreating) assigneeExpanded = it },
) { ) {
val displayAssignee = val displayAssignee =
when (selectedAssignee) { when {
null -> stringResource(R.string.kanban_assignee_unassigned) selectedAssignee == PARKED_VALUE -> {
PARKED_VALUE -> stringResource(R.string.kanban_assignee_parked) stringResource(R.string.kanban_assignee_parked)
else -> selectedAssignee ?: "" }
selectedAssignee == defaultAssignee && !defaultAssignee.isNullOrBlank() -> {
"$defaultAssignee (Default)"
}
selectedAssignee.isNullOrBlank() -> {
stringResource(R.string.kanban_assignee_unassigned)
}
else -> {
selectedAssignee
}
} }
OutlinedTextField( OutlinedTextField(
value = displayAssignee, value = displayAssignee,
@@ -187,18 +217,12 @@ fun KanbanCreateTaskDialog(
expanded = assigneeExpanded, expanded = assigneeExpanded,
onDismissRequest = { assigneeExpanded = false }, onDismissRequest = { assigneeExpanded = false },
) { ) {
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_assignee_unassigned)) },
onClick = {
selectedAssignee = null
assigneeExpanded = false
},
)
profiles.forEach { profile -> profiles.forEach { profile ->
val isDef = profile.name == defaultAssignee
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Column { Column {
Text(profile.name) Text(if (isDef) "${profile.name} (Default)" else profile.name)
if (profile.model != null) { if (profile.model != null) {
Text( Text(
profile.model, profile.model,
@@ -258,11 +282,7 @@ fun KanbanCreateTaskDialog(
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier = Modifier.fillMaxWidth(),
Modifier
.fillMaxWidth()
.clickable(enabled = !isCreating) { goalMode = !goalMode }
.padding(vertical = 4.dp),
) { ) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
@@ -271,11 +291,10 @@ fun KanbanCreateTaskDialog(
) )
Text( Text(
text = stringResource(R.string.kanban_goal_mode_desc), text = stringResource(R.string.kanban_goal_mode_desc),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
Spacer(modifier = Modifier.width(8.dp))
Switch( Switch(
checked = goalMode, checked = goalMode,
onCheckedChange = { goalMode = it }, onCheckedChange = { goalMode = it },
@@ -286,34 +305,42 @@ fun KanbanCreateTaskDialog(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Advanced Options Toggle // Advanced Options Toggle
TextButton( Row(
onClick = { showAdvanced = !showAdvanced }, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.align(Alignment.Start), 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( Icon(
imageVector = if (showAdvanced) Icons.Default.ExpandLess else Icons.Default.ExpandMore, imageVector = if (showAdvanced) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null, 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) { AnimatedVisibility(visible = showAdvanced) {
Column(modifier = Modifier.fillMaxWidth()) { Column {
// Parent Task // Parent Task
if (existingTasks.isNotEmpty()) { if (existingTasks.isNotEmpty()) {
ExposedDropdownMenuBox( ExposedDropdownMenuBox(
expanded = parentExpanded, expanded = parentExpanded,
onExpandedChange = { if (!isCreating) parentExpanded = it }, onExpandedChange = { if (!isCreating) parentExpanded = it },
) { ) {
val displayParent = val parentDisplay =
existingTasks.find { it.id == parentTaskId }?.let { parentTaskId?.let { pid ->
"#${it.id.removePrefix("t_").take(6)} • ${it.title}" existingTasks.find { it.id == pid }?.title ?: pid
} ?: stringResource(R.string.kanban_parent_none) } ?: stringResource(R.string.kanban_parent_none)
OutlinedTextField( OutlinedTextField(
value = displayParent, value = parentDisplay,
onValueChange = {}, onValueChange = {},
readOnly = true, readOnly = true,
label = { Text(stringResource(R.string.kanban_parent_task)) }, label = { Text(stringResource(R.string.kanban_parent_task)) },
@@ -342,7 +369,12 @@ fun KanbanCreateTaskDialog(
) )
existingTasks.forEach { task -> existingTasks.forEach { task ->
DropdownMenuItem( DropdownMenuItem(
text = { Text("#${task.id.removePrefix("t_").take(6)} • ${task.title}") }, text = {
Text(
task.title,
maxLines = 1,
)
},
onClick = { onClick = {
parentTaskId = task.id parentTaskId = task.id
parentExpanded = false parentExpanded = false
@@ -354,60 +386,17 @@ fun KanbanCreateTaskDialog(
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
} }
// Model Override // Model Override via shared ModelPickerDialog
OutlinedTextField( KanbanModelOverrideEditor(
value = modelOverride, override = modelOverride,
onValueChange = { modelOverride = it }, onOverrideChange = { modelOverride = it },
label = { Text(stringResource(R.string.kanban_model_override)) }, modelProviders = modelProviders,
placeholder = { Text("e.g. claude-3-5-sonnet") }, pinnedModels = pinnedModels,
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isCreating, enabled = !isCreating,
) )
Spacer(modifier = Modifier.height(8.dp)) 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 // Workspace Kind
Text( Text(
text = stringResource(R.string.kanban_workspace_kind), text = stringResource(R.string.kanban_workspace_kind),
@@ -420,7 +409,7 @@ fun KanbanCreateTaskDialog(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()), modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
) { ) {
listOf( listOf(
null to "Scratch (Default)", "scratch" to "Scratch (Default)",
"worktree" to "Worktree", "worktree" to "Worktree",
"dir" to "Dir", "dir" to "Dir",
).forEach { (kind, label) -> ).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)) Spacer(modifier = Modifier.height(8.dp))
// Skills // Skills
@@ -448,40 +456,57 @@ fun KanbanCreateTaskDialog(
} }
} }
// Estimation Section
if (onEstimate != null) { if (onEstimate != null) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Row( if (estimate != null) {
verticalAlignment = Alignment.CenterVertically, estimate?.let { est ->
horizontalArrangement = Arrangement.SpaceBetween, 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(), modifier = Modifier.fillMaxWidth(),
) { ) {
if (estimate != null && estimate?.ok == true) { if (isEstimating) {
Text( CircularProgressIndicator(modifier = Modifier.size(12.dp), strokeWidth = 1.5.dp)
text = "~${estimate?.estTokens ?: 0} tokens · ${estimate?.complexity ?: "normal"}", Spacer(modifier = Modifier.width(4.dp))
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")
} }
Text(if (estimate != null) "Re-estimate" else "Estimate tokens")
} }
} }
} }
@@ -497,6 +522,23 @@ fun KanbanCreateTaskDialog(
.filter { it.isNotEmpty() } .filter { it.isNotEmpty() }
.takeIf { 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 = val body =
CreateTaskBody( CreateTaskBody(
title = title.trim(), title = title.trim(),
@@ -505,10 +547,11 @@ fun KanbanCreateTaskDialog(
priority = priority, priority = priority,
goalMode = goalMode, goalMode = goalMode,
parents = parentTaskId?.let { listOf(it) } ?: emptyList(), parents = parentTaskId?.let { listOf(it) } ?: emptyList(),
modelOverride = modelOverride.trim().ifBlank { null }, modelOverride = finalModel,
providerOverride = providerOverride.trim().ifBlank { null }, providerOverride = finalProvider,
reasoningEffort = reasoningEffort, reasoningEffort = finalEffort,
workspaceKind = workspaceKind, workspaceKind = finalWorkspaceKind,
workspacePath = finalWorkspacePath,
skills = skillsList, skills = skillsList,
triage = selectedColumn.equals("triage", ignoreCase = true), triage = selectedColumn.equals("triage", ignoreCase = true),
) )
@@ -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<ModelProvider>,
pinnedModels: List<PinnedModel> = emptyList(),
enabled: Boolean = true,
modifier: Modifier = Modifier,
) {
var showPickerDialog by remember { mutableStateOf(false) }
Column(modifier = modifier.fillMaxWidth()) {
Text(
text = stringResource(R.string.kanban_model_override),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
OutlinedCard(
onClick = { if (enabled) showPickerDialog = true },
modifier = Modifier.fillMaxWidth(),
enabled = enabled,
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.height(48.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.weight(1f)
.padding(horizontal = 12.dp),
) {
Icon(
imageVector = Icons.Default.Tune,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint =
if (override.isInherited) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.primary
},
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = override.displayLabel(stringResource(R.string.kanban_model_inherit)),
style = MaterialTheme.typography.bodyMedium,
color =
if (override.isInherited) {
MaterialTheme.colorScheme.onSurfaceVariant
} else {
MaterialTheme.colorScheme.onSurface
},
maxLines = 1,
)
}
if (!override.isInherited && enabled) {
IconButton(
onClick = { onOverrideChange(override.copy(model = "", provider = "")) },
modifier = Modifier.size(40.dp),
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = stringResource(R.string.kanban_model_clear),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = stringResource(R.string.kanban_reasoning_effort),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
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 },
)
}
}
@@ -0,0 +1,324 @@
package com.m57.hermescontrol.ui.kanban.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.OrchestrationSettings
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun KanbanOrchestrationDialog(
settings: OrchestrationSettings?,
profiles: List<KanbanProfile>,
onSaveSettings: (OrchestrationSettingsUpdate) -> Unit,
onSaveProfileDescription: (profile: String, description: String) -> Unit,
onAutoDescribe: (profile: String, onComplete: (String?) -> Unit) -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
var orchestratorProfile by remember(settings) {
mutableStateOf(settings?.orchestratorProfile ?: "")
}
var defaultAssignee by remember(settings) {
mutableStateOf(settings?.defaultAssignee ?: "")
}
var autoDecompose by remember(settings) {
mutableStateOf(settings?.autoDecompose ?: true)
}
var orchestratorExpanded by remember { mutableStateOf(false) }
var defaultAssigneeExpanded by remember { mutableStateOf(false) }
AlertDialog(
onDismissRequest = onDismiss,
modifier = modifier,
title = { Text(stringResource(R.string.kanban_orchestration_settings)) },
text = {
Column(
modifier =
Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Orchestrator Profile
ExposedDropdownMenuBox(
expanded = orchestratorExpanded,
onExpandedChange = { orchestratorExpanded = it },
) {
val display =
if (orchestratorProfile.isBlank()) {
stringResource(R.string.kanban_assignee_unassigned)
} else {
orchestratorProfile
}
OutlinedTextField(
value = display,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.kanban_orchestrator_profile)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = orchestratorExpanded) },
modifier =
Modifier
.menuAnchor(
ExposedDropdownMenuAnchorType.PrimaryNotEditable,
).fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = orchestratorExpanded,
onDismissRequest = { orchestratorExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_assignee_unassigned)) },
onClick = {
orchestratorProfile = ""
orchestratorExpanded = false
},
)
profiles.forEach { p ->
DropdownMenuItem(
text = { Text(p.name) },
onClick = {
orchestratorProfile = p.name
orchestratorExpanded = false
},
)
}
}
}
// Default Assignee
ExposedDropdownMenuBox(
expanded = defaultAssigneeExpanded,
onExpandedChange = { defaultAssigneeExpanded = it },
) {
val display =
if (defaultAssignee.isBlank()) {
stringResource(R.string.kanban_assignee_unassigned)
} else {
defaultAssignee
}
OutlinedTextField(
value = display,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.kanban_default_assignee)) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = defaultAssigneeExpanded) },
modifier =
Modifier
.menuAnchor(
ExposedDropdownMenuAnchorType.PrimaryNotEditable,
).fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = defaultAssigneeExpanded,
onDismissRequest = { defaultAssigneeExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_assignee_unassigned)) },
onClick = {
defaultAssignee = ""
defaultAssigneeExpanded = false
},
)
profiles.forEach { p ->
DropdownMenuItem(
text = { Text(p.name) },
onClick = {
defaultAssignee = p.name
defaultAssigneeExpanded = false
},
)
}
}
}
// Auto-decompose Switch
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.kanban_auto_decompose),
style = MaterialTheme.typography.bodyMedium,
)
Switch(
checked = autoDecompose,
onCheckedChange = { autoDecompose = it },
)
}
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
// Profile Descriptions
Text(
text = stringResource(R.string.kanban_profile_descriptions),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
profiles.forEach { profile ->
ProfileDescriptionEditor(
profile = profile,
onSave = { onSaveProfileDescription(profile.name, it) },
onAutoDescribe = { cb -> onAutoDescribe(profile.name, cb) },
)
}
}
},
confirmButton = {
Button(
onClick = {
onSaveSettings(
OrchestrationSettingsUpdate(
orchestratorProfile = orchestratorProfile,
defaultAssignee = defaultAssignee,
autoDecompose = autoDecompose,
),
)
onDismiss()
},
) {
Text(stringResource(R.string.action_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
@Composable
private fun ProfileDescriptionEditor(
profile: KanbanProfile,
onSave: (String) -> Unit,
onAutoDescribe: ((String?) -> Unit) -> Unit,
modifier: Modifier = Modifier,
) {
var draft by remember(profile.description) { mutableStateOf(profile.description) }
var isDescribing by remember { mutableStateOf(false) }
OutlinedCard(modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(10.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth(),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = profile.name,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
if (profile.isDefault) {
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "(Default)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
)
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (isDescribing) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
IconButton(
onClick = {
isDescribing = true
onAutoDescribe { desc ->
isDescribing = false
if (desc != null) draft = desc
}
},
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Default.AutoAwesome,
contentDescription = stringResource(R.string.kanban_auto_describe),
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
IconButton(
onClick = { onSave(draft.trim()) },
enabled = draft.trim() != profile.description.trim(),
modifier = Modifier.size(32.dp),
) {
Icon(
imageVector = Icons.Default.Save,
contentDescription = stringResource(R.string.action_save),
modifier = Modifier.size(16.dp),
tint =
if (draft.trim() != profile.description.trim()) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f)
},
)
}
}
}
Spacer(modifier = Modifier.height(6.dp))
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
modifier = Modifier.fillMaxWidth(),
minLines = 2,
maxLines = 4,
placeholder = { Text("Profile description for routing") },
)
}
}
}
@@ -37,6 +37,8 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.data.model.KanbanTask import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.kanban.KanbanArcState
import com.m57.hermescontrol.ui.kanban.KanbanRuntimeHelper
@Composable @Composable
fun KanbanTaskCard( fun KanbanTaskCard(
@@ -44,6 +46,7 @@ fun KanbanTaskCard(
onTaskClick: (KanbanTask) -> Unit, onTaskClick: (KanbanTask) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isSelected: Boolean = false, isSelected: Boolean = false,
defaultAssignee: String? = null,
onActionClick: ((KanbanTask) -> Unit)? = null, onActionClick: ((KanbanTask) -> Unit)? = null,
) { ) {
val statusColors = LocalHermesStatusColors.current val statusColors = LocalHermesStatusColors.current
@@ -57,6 +60,10 @@ fun KanbanTaskCard(
else -> MaterialTheme.colorScheme.outline else -> MaterialTheme.colorScheme.outline
} }
val arcState = KanbanRuntimeHelper.arcState(task, defaultAssignee)
val isWontRun = KanbanRuntimeHelper.isWontRun(task, defaultAssignee)
val elapsed = KanbanRuntimeHelper.formatElapsed(task.startedAt)
val cardColors = val cardColors =
if (isSelected) { if (isSelected) {
CardDefaults.cardColors( CardDefaults.cardColors(
@@ -92,6 +99,37 @@ fun KanbanTaskCard(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = statusTone, 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) { Row(verticalAlignment = Alignment.CenterVertically) {
Text( Text(
@@ -145,7 +183,14 @@ fun KanbanTaskCard(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(end = 8.dp), modifier = Modifier.padding(end = 8.dp),