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.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -18,6 +20,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
@@ -26,16 +29,21 @@ import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.ChatBubbleOutline
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Description
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -43,9 +51,11 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.PrimaryTabRow
import androidx.compose.material3.PrimaryScrollableTabRow
import androidx.compose.material3.SuggestionChip
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -56,11 +66,21 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.m57.hermescontrol.KanbanTaskDetailKey
import com.m57.hermescontrol.NavigationController
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanTaskFull
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.TaskLinks
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.common.ErrorState
@@ -68,6 +88,7 @@ import com.m57.hermescontrol.ui.common.HermesScaffold
import com.m57.hermescontrol.ui.common.NavIcon
import com.m57.hermescontrol.ui.common.SkeletonListState
import com.m57.hermescontrol.ui.common.ToastEffect
import com.m57.hermescontrol.ui.kanban.components.KanbanModelOverrideEditor
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -81,6 +102,7 @@ fun KanbanTaskScreen(
val state by viewModel.uiState.collectAsStateWithLifecycle()
var selectedTabIndex by remember { mutableIntStateOf(0) }
val tabs = listOf("Overview", "Discussion", "Runs", "Activity", "Files")
var showDeleteDialog by remember { mutableStateOf(false) }
LaunchedEffect(boardSlug, taskId) {
viewModel.loadTask(boardSlug, taskId)
@@ -105,6 +127,15 @@ fun KanbanTaskScreen(
}
},
navigationIcon = NavIcon.Back(onBack),
actions = {
IconButton(onClick = { showDeleteDialog = true }) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.kanban_delete_task),
tint = MaterialTheme.colorScheme.error,
)
}
},
drawerGesturesEnabled = false,
isRefreshing = state.isLoading,
onRefresh = { viewModel.loadTask(boardSlug, taskId) },
@@ -127,7 +158,10 @@ fun KanbanTaskScreen(
val task = detail.task
Column(modifier = Modifier.fillMaxSize()) {
PrimaryTabRow(selectedTabIndex = selectedTabIndex) {
PrimaryScrollableTabRow(
selectedTabIndex = selectedTabIndex,
edgePadding = 16.dp,
) {
tabs.forEachIndexed { index, title ->
Tab(
selected = selectedTabIndex == index,
@@ -141,6 +175,26 @@ fun KanbanTaskScreen(
0 -> {
TaskOverviewTab(
task = task,
links = detail.links,
profiles = state.profiles,
modelProviders = state.modelProviders,
pinnedModels = state.pinnedModels,
estimate = state.estimate,
isEstimating = state.isEstimating,
onReassign = { newProfile ->
viewModel.reassign(boardSlug, taskId, newProfile)
},
onUpdateModel = { override ->
viewModel.updateModelOverride(boardSlug, taskId, override)
},
onEstimate = {
viewModel.estimateTask(boardSlug, taskId)
},
onNavigateToTask = { targetId ->
NavigationController.navigateTo(
KanbanTaskDetailKey(boardSlug = boardSlug, taskId = targetId),
)
},
onSaveDescription = { newBody ->
viewModel.updateDescription(boardSlug, taskId, newBody)
},
@@ -191,19 +245,59 @@ fun KanbanTaskScreen(
}
}
}
if (showDeleteDialog) {
AlertDialog(
onDismissRequest = { showDeleteDialog = false },
title = { Text(stringResource(R.string.kanban_delete_task)) },
text = { Text(stringResource(R.string.kanban_delete_task_confirm)) },
confirmButton = {
Button(
onClick = {
showDeleteDialog = false
viewModel.deleteTask(boardSlug, taskId, onSuccess = onBack)
},
colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.kanban_delete_task))
}
},
dismissButton = {
TextButton(onClick = { showDeleteDialog = false }) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
}
@Composable
private fun TaskOverviewTab(
task: com.m57.hermescontrol.data.model.KanbanTaskFull,
task: KanbanTaskFull,
links: TaskLinks,
profiles: List<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,
onReclaim: () -> Unit,
modifier: Modifier = Modifier,
) {
var isEditingDescription by remember { mutableStateOf(false) }
var descriptionDraft by remember(task.body) { mutableStateOf(task.body ?: "") }
var assigneeMenuExpanded by remember { mutableStateOf(false) }
val statusColors = LocalHermesStatusColors.current
val currentModelOverride =
remember(task.modelOverride, task.providerOverride, task.reasoningEffort) {
KanbanModelOverride.fromTask(task.modelOverride, task.providerOverride, task.reasoningEffort)
}
LazyColumn(
modifier = modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(12.dp),
@@ -260,7 +354,7 @@ private fun TaskOverviewTab(
}
}
// Metadata card
// Metadata / Attributes card
Card(
shape = RoundedCornerShape(8.dp),
modifier =
@@ -277,13 +371,199 @@ private fun TaskOverviewTab(
)
Spacer(modifier = Modifier.height(8.dp))
MetaRow(label = "Status", value = task.status.uppercase())
task.assignee?.let { MetaRow(label = "Assignee", value = it) }
// Editable Assignee
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
) {
Text(
text = "Assignee",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Box {
TextButton(
onClick = { assigneeMenuExpanded = true },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
modifier = Modifier.height(32.dp),
) {
Text(
text = task.assignee ?: "Unassigned",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Medium,
)
}
DropdownMenu(
expanded = assigneeMenuExpanded,
onDismissRequest = { assigneeMenuExpanded = false },
) {
DropdownMenuItem(
text = { Text("Unassigned") },
onClick = {
onReassign(null)
assigneeMenuExpanded = false
},
)
profiles.forEach { p ->
DropdownMenuItem(
text = { Text(p.name) },
onClick = {
onReassign(p.name)
assigneeMenuExpanded = false
},
)
}
}
}
}
task.priority?.let { MetaRow(label = "Priority", value = it.toString()) }
task.tenant?.let { MetaRow(label = "Tenant", value = it) }
task.modelOverride?.let { MetaRow(label = "Model Override", value = it) }
task.reasoningEffort?.let { MetaRow(label = "Reasoning Effort", value = it) }
task.workspaceKind?.let { MetaRow(label = "Workspace", value = "$it ${task.workspacePath ?: ""}") }
task.workerPid?.let { MetaRow(label = "Worker PID", value = it.toString()) }
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
// Editable Model & Reasoning Override
KanbanModelOverrideEditor(
override = currentModelOverride,
onOverrideChange = onUpdateModel,
modelProviders = modelProviders,
pinnedModels = pinnedModels,
)
}
}
// Dependencies Section
if (links.parents.isNotEmpty() || links.children.isNotEmpty()) {
Card(
shape = RoundedCornerShape(8.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
text = stringResource(R.string.kanban_dependencies),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
if (links.parents.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = stringResource(R.string.kanban_blocked_by),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
) {
links.parents.forEach { parentId ->
SuggestionChip(
onClick = { onNavigateToTask(parentId) },
label = { Text("#${parentId.removePrefix("t_").take(6)}") },
)
}
}
}
if (links.children.isNotEmpty()) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = stringResource(R.string.kanban_blocks),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
) {
links.children.forEach { childId ->
SuggestionChip(
onClick = { onNavigateToTask(childId) },
label = { Text("#${childId.removePrefix("t_").take(6)}") },
)
}
}
}
}
}
}
// Effort Estimation Section
Card(
shape = RoundedCornerShape(8.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = stringResource(R.string.kanban_estimate),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
if (isEstimating) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
}
}
val currentEst = estimate
if (currentEst != null) {
Spacer(modifier = Modifier.height(6.dp))
currentEst.tokens?.let { tokensCount ->
Text(
text = stringResource(R.string.kanban_estimated_tokens, tokensCount),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
}
if (!currentEst.complexity.isNullOrBlank()) {
Text(
text = stringResource(R.string.kanban_complexity, currentEst.complexity),
style = MaterialTheme.typography.bodySmall,
)
}
if (!currentEst.rationale.isNullOrBlank()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = currentEst.rationale,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(
onClick = onEstimate,
enabled = !isEstimating,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.kanban_reestimate_action))
}
} else {
Spacer(modifier = Modifier.height(6.dp))
Button(
onClick = onEstimate,
enabled = !isEstimating,
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(R.string.kanban_estimate_action))
}
}
}
}
@@ -367,16 +647,13 @@ private fun TaskOverviewTab(
color = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.height(6.dp))
SelectionContainer {
MarkdownText(
text = summary,
textColor = MaterialTheme.colorScheme.onSurface,
)
}
MarkdownText(
text = summary,
textColor = MaterialTheme.colorScheme.onSurface,
)
}
}
}
Spacer(modifier = Modifier.height(24.dp))
}
}
}
@@ -390,29 +667,22 @@ private fun TaskDiscussionTab(
onNoteAndRequeue: (String) -> Unit,
modifier: Modifier = Modifier,
) {
var commentText by remember { mutableStateOf("") }
var newCommentText by remember { mutableStateOf("") }
Column(modifier = modifier.fillMaxSize()) {
Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
LazyColumn(
modifier =
Modifier
.weight(1f)
.padding(horizontal = 16.dp, vertical = 8.dp),
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (comments.isEmpty()) {
item {
Box(modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) {
Text(
"No comments yet. Post a note to steer the agent.",
style = MaterialTheme.typography.bodySmall,
)
}
Text("No comments yet.", style = MaterialTheme.typography.bodySmall)
}
} else {
items(comments, key = { it.id }) { comment ->
Card(
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(10.dp)) {
@@ -422,65 +692,69 @@ private fun TaskDiscussionTab(
) {
Text(
text = comment.author,
style = MaterialTheme.typography.labelMedium,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
)
}
Spacer(modifier = Modifier.height(4.dp))
SelectionContainer {
MarkdownText(
text = comment.body,
textColor = MaterialTheme.colorScheme.onSurface,
)
}
MarkdownText(
text = comment.body,
textColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
OutlinedTextField(
value = commentText,
onValueChange = { commentText = it },
placeholder = { Text("Write a steering note...") },
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
)
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
if (isRunning) {
OutlinedButton(
onClick = {
onNoteAndRequeue(commentText)
commentText = ""
},
enabled = commentText.isNotBlank() && !isSubmitting,
) {
Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Note & Requeue")
}
Spacer(modifier = Modifier.width(8.dp))
}
OutlinedTextField(
value = newCommentText,
onValueChange = { newCommentText = it },
placeholder = { Text("Write a comment...") },
modifier = Modifier.fillMaxWidth(),
maxLines = 4,
enabled = !isSubmitting,
)
Button(
Spacer(modifier = Modifier.height(8.dp))
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxWidth()) {
if (isRunning) {
OutlinedButton(
onClick = {
onAddComment(commentText)
commentText = ""
if (newCommentText.isNotBlank()) {
onNoteAndRequeue(newCommentText.trim())
newCommentText = ""
}
},
enabled = commentText.isNotBlank() && !isSubmitting,
enabled = newCommentText.isNotBlank() && !isSubmitting,
) {
if (isSubmitting) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
} else {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Post")
}
Icon(Icons.Filled.RestartAlt, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("Note & Requeue")
}
Spacer(modifier = Modifier.width(8.dp))
}
Button(
onClick = {
if (newCommentText.isNotBlank()) {
onAddComment(newCommentText.trim())
newCommentText = ""
}
},
enabled = newCommentText.isNotBlank() && !isSubmitting,
) {
if (isSubmitting) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(6.dp))
} else {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(6.dp))
}
Text("Comment")
}
}
}
@@ -494,59 +768,58 @@ private fun TaskRunsTab(
) {
LazyColumn(
modifier = modifier.fillMaxSize().padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Text("Attempt History", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
Text("Execution Runs", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
}
if (runs.isEmpty()) {
item {
Text("No worker attempts recorded yet.", style = MaterialTheme.typography.bodySmall)
Text("No run history available.", style = MaterialTheme.typography.bodySmall)
}
} else {
items(runs, key = { it.id }) { run ->
Card(
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(12.dp)) {
Column(modifier = Modifier.padding(10.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text(
"Run #${run.id}",
style = MaterialTheme.typography.labelMedium,
text = "Run #${run.id}",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
)
Text(
text = (run.outcome ?: run.status).uppercase(),
text = run.status.uppercase(),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color =
if (run.outcome == "completed") {
LocalHermesStatusColors.current.success
if (run.status.equals("done", ignoreCase = true)) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
MaterialTheme.colorScheme.outline
},
)
}
run.profile?.let {
Text("Profile: $it", style = MaterialTheme.typography.bodySmall)
}
val summary = run.error ?: run.summary
if (!summary.isNullOrBlank()) {
Spacer(modifier = Modifier.height(4.dp))
Text(text = summary, style = MaterialTheme.typography.bodySmall)
run.profile?.let { Text("Profile: $it", style = MaterialTheme.typography.bodySmall) }
run.outcome?.let { Text("Outcome: $it", style = MaterialTheme.typography.bodySmall) }
run.error?.let {
Text(
"Error: $it",
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
}
// Live Log Output
if (workerLog != null && workerLog.exists && workerLog.content.isNotBlank()) {
item {
Spacer(modifier = Modifier.height(16.dp))
@@ -588,19 +861,33 @@ private fun TaskActivityTab(
}
} else {
items(events, key = { it.id }) { event ->
Row(verticalAlignment = Alignment.CenterVertically) {
val formatted = remember(event) { KanbanEventFormatter.format(event) }
Row(
verticalAlignment = Alignment.Top,
modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp),
) {
Box(
modifier =
Modifier
.size(8.dp)
.padding(top = 5.dp)
.size(6.dp)
.background(MaterialTheme.colorScheme.primary, shape = CircleShape),
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = event.kind,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = formatted.label,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.SemiBold,
)
if (!formatted.detail.isNullOrBlank()) {
Text(
text = formatted.detail,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@@ -649,98 +936,75 @@ private fun TaskFilesTab(
}
}
val mimeType = context.contentResolver.getType(uri) ?: "application/octet-stream"
val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
if (bytes != null) {
val bytes =
context.contentResolver.openInputStream(uri)?.use { it.readBytes() }
?: byteArrayOf()
if (bytes.isNotEmpty()) {
onUpload(filename, mimeType, bytes)
}
}
}
LazyColumn(
modifier =
modifier
.fillMaxSize()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
Column(modifier = modifier.fillMaxSize().padding(16.dp)) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Text("Attachments", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
Button(
onClick = { launcher.launch("*/*") },
enabled = !isUploading,
) {
Text(
text = "Attached Files (${attachments.size})",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
)
Button(
onClick = { launcher.launch("*/*") },
enabled = !isUploading,
) {
if (isUploading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(6.dp))
}
Text("Attach File")
if (isUploading) {
CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(modifier = Modifier.width(6.dp))
}
Text("Upload")
}
}
if (attachments.isEmpty()) {
item {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = "No files attached to this task.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(12.dp))
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (attachments.isEmpty()) {
item {
Text("No attachments uploaded.", style = MaterialTheme.typography.bodySmall)
}
}
} else {
items(attachments, key = { it.id }) { att ->
Card(
shape = RoundedCornerShape(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.padding(12.dp)
.fillMaxWidth(),
} else {
items(attachments, key = { it.id }) { att ->
Card(
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
shape = RoundedCornerShape(6.dp),
modifier = Modifier.fillMaxWidth(),
) {
Icon(
imageVector = Icons.Filled.Description,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = att.filename,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
val sizeStr =
att.size?.let { s ->
if (s > 1024 * 1024) "${s / (1024 * 1024)} MB" else "${s / 1024} KB"
} ?: "Unknown size"
Text(
text = "$sizeStr • ${att.uploadedBy ?: "unknown"}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(12.dp),
) {
Icon(
Icons.Filled.Description,
contentDescription = null,
modifier = Modifier.size(24.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = att.filename?.ifBlank { "File #${att.id}" } ?: "File #${att.id}",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
att.size?.let {
Text(
text = "${it / 1024} KB",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@@ -2,19 +2,28 @@ package com.m57.hermescontrol.ui.kanban
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanTaskDetailResponse
import com.m57.hermescontrol.data.model.ReassignTaskBody
import com.m57.hermescontrol.data.model.ReclaimTaskBody
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.model.WorkerLog
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.repository.KanbanRepository
import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
import com.m57.hermescontrol.data.ws.KanbanEventsClient
import com.m57.hermescontrol.ui.common.ToastHost
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.MultipartBody
@@ -24,43 +33,70 @@ data class KanbanTaskUiState(
val isLoading: Boolean = false,
val detail: KanbanTaskDetailResponse? = null,
val workerLog: WorkerLog? = null,
val profiles: List<KanbanProfile> = emptyList(),
val modelProviders: List<ModelProvider> = emptyList(),
val pinnedModels: List<PinnedModel> = emptyList(),
val estimate: TaskEstimate? = null,
val isEstimating: Boolean = false,
val isDeleting: Boolean = false,
val errorMessage: String? = null,
val toastMessage: String? = null,
val isSubmittingComment: Boolean = false,
val isSavingDescription: Boolean = false,
val isReclaiming: Boolean = false,
val isReassigning: Boolean = false,
val isUpdatingModel: Boolean = false,
val isUploadingAttachment: Boolean = false,
)
class KanbanTaskViewModel(
private val repository: KanbanRepository = KanbanRepositoryImpl(),
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(KanbanTaskUiState())
val uiState: StateFlow<KanbanTaskUiState> = _uiState.asStateFlow()
private var eventsClient: KanbanEventsClient? = null
private var eventsBoard: String? = null
private var activeTaskId: String? = null
private var currentLoadGen: Int = 0
private var reloadJob: Job? = null
private var logPollJob: Job? = null
fun loadTask(
board: String,
taskId: String,
) {
val gen = ++currentLoadGen
activeTaskId = taskId
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
loadProfiles()
loadModelOptions()
connectEvents(board)
viewModelScope.launch {
when (val result = repository.getTask(taskId = taskId, board = board)) {
is NetworkResult.Success -> {
if (gen != currentLoadGen) return@launch
val taskData = result.data
_uiState.update {
it.copy(
isLoading = false,
detail = result.data,
detail = taskData,
)
}
if (result.data.task.status
.equals("running", ignoreCase = true)
) {
if (taskData.task.status.equals("running", ignoreCase = true)) {
startLogPolling(board, taskId)
} else {
stopLogPolling()
loadLog(board, taskId)
}
}
is NetworkResult.Failure -> {
if (gen != currentLoadGen) return@launch
stopLogPolling()
_uiState.update {
it.copy(
isLoading = false,
@@ -72,19 +108,132 @@ class KanbanTaskViewModel(
}
}
fun loadProfiles() {
viewModelScope.launch {
when (val result = repository.getProfiles()) {
is NetworkResult.Success -> {
_uiState.update { it.copy(profiles = result.data.profiles) }
}
is NetworkResult.Failure -> {
// Failures to read profiles don't block detail
}
}
}
}
fun loadModelOptions() {
viewModelScope.launch {
when (val res = safeApiCall { ApiClient.hermesApi.getModelOptions() }) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
modelProviders = res.data.providers,
pinnedModels = AuthManager.getPinnedModels(),
)
}
}
is NetworkResult.Failure -> {
// Failures to read models don't block detail
}
}
}
}
fun loadLog(
board: String,
taskId: String,
) {
viewModelScope.launch {
when (val result = repository.getTaskLog(taskId = taskId, board = board)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(workerLog = result.data) }
}
fetchLogInternal(board, taskId)
}
}
is NetworkResult.Failure -> {
// Failures to read log don't blank the task screen
private suspend fun fetchLogInternal(
board: String,
taskId: String,
) {
when (val result = repository.getTaskLog(taskId = taskId, board = board)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(workerLog = result.data) }
}
is NetworkResult.Failure -> {
// Failures to read log don't blank the task screen
}
}
}
private fun startLogPolling(
board: String,
taskId: String,
) {
logPollJob?.cancel()
logPollJob =
viewModelScope.launch {
fetchLogInternal(board, taskId)
val isRunning = {
_uiState.value.detail
?.task
?.status
?.equals("running", ignoreCase = true) == true
}
while (isActive && isRunning()) {
delay(POLL_INTERVAL_MS)
fetchLogInternal(board, taskId)
}
}
}
private fun stopLogPolling() {
logPollJob?.cancel()
logPollJob = null
}
private fun connectEvents(board: String) {
if (eventsBoard == board) return
eventsBoard = board
val client = eventsClient ?: eventsClientProvider().also { eventsClient = it }
client.connect(
scope = viewModelScope,
board = board,
onEvents = { envelope ->
val current = activeTaskId
if (current != null && envelope.events.any { it.taskId == current }) {
scheduleTaskReload(board, current)
}
},
onStatus = {},
)
}
private fun scheduleTaskReload(
board: String,
taskId: String,
) {
reloadJob?.cancel()
reloadJob =
viewModelScope.launch {
delay(RELOAD_DEBOUNCE_MS)
loadTaskSilently(board, taskId)
}
}
private suspend fun loadTaskSilently(
board: String,
taskId: String,
) {
val gen = currentLoadGen
val result = repository.getTask(taskId = taskId, board = board)
if (gen != currentLoadGen || taskId != activeTaskId) return
if (result is NetworkResult.Success) {
val taskData = result.data
_uiState.update { it.copy(detail = taskData) }
if (taskData.task.status.equals("running", ignoreCase = true)) {
if (logPollJob == null) startLogPolling(board, taskId)
} else {
stopLogPolling()
}
}
}
@@ -128,6 +277,117 @@ class KanbanTaskViewModel(
}
}
fun reassign(
board: String,
taskId: String,
profile: String?,
) {
_uiState.update { it.copy(isReassigning = true) }
viewModelScope.launch {
when (
val result =
repository.reassignTask(
taskId = taskId,
board = board,
profile = profile,
reclaimFirst = true,
)
) {
is NetworkResult.Success -> {
val msg = if (profile != null) "Task reassigned to $profile" else "Task unassigned"
_uiState.update { it.copy(isReassigning = false, toastMessage = msg) }
loadTask(board, taskId)
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(isReassigning = false, toastMessage = "Failed to reassign: ${result.error.message}")
}
}
}
}
}
fun updateModelOverride(
board: String,
taskId: String,
override: KanbanModelOverride,
) {
_uiState.update { it.copy(isUpdatingModel = true) }
viewModelScope.launch {
when (val res = repository.updateTask(taskId = taskId, board = board, body = override.toUpdateTaskBody())) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(isUpdatingModel = false, toastMessage = "Model override updated")
}
loadTask(board, taskId)
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(isUpdatingModel = false, toastMessage = "Failed to update model: ${res.error.message}")
}
}
}
}
}
fun estimateTask(
board: String,
taskId: String,
) {
if (_uiState.value.isEstimating) return
_uiState.update { it.copy(isEstimating = true) }
viewModelScope.launch {
when (val res = repository.estimateTask(taskId = taskId, board = board)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
isEstimating = false,
estimate = res.data,
toastMessage = "Estimate completed",
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isEstimating = false,
toastMessage = "Estimation failed: ${res.error.message}",
)
}
}
}
}
}
fun deleteTask(
board: String,
taskId: String,
onSuccess: () -> Unit,
) {
if (_uiState.value.isDeleting) return
_uiState.update { it.copy(isDeleting = true) }
viewModelScope.launch {
when (val res = repository.deleteTask(taskId = taskId, board = board)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(isDeleting = false, toastMessage = "Task deleted") }
onSuccess()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isDeleting = false,
toastMessage = "Failed to delete task: ${res.error.message}",
)
}
}
}
}
}
fun addComment(
board: String,
taskId: String,
@@ -232,27 +492,6 @@ class KanbanTaskViewModel(
}
}
fun reassign(
board: String,
taskId: String,
profile: String,
) {
viewModelScope.launch {
when (val result = repository.reassignTask(taskId = taskId, board = board, profile = profile)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Task reassigned to $profile") }
loadTask(board, taskId)
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(toastMessage = "Failed to reassign: ${result.error.message}")
}
}
}
}
}
fun uploadAttachment(
board: String,
taskId: String,
@@ -295,7 +534,18 @@ class KanbanTaskViewModel(
}
}
public override fun onCleared() {
stopLogPolling()
eventsClient?.disconnect()
super.onCleared()
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
private companion object {
const val RELOAD_DEBOUNCE_MS = 250L
const val POLL_INTERVAL_MS = 3000L
}
}
@@ -5,17 +5,28 @@ import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore
import com.m57.hermescontrol.data.local.KanbanPreferencesStore
import com.m57.hermescontrol.data.model.BoardExportResult
import com.m57.hermescontrol.data.model.BoardImportResult
import com.m57.hermescontrol.data.model.BulkTasksBody
import com.m57.hermescontrol.data.model.CreateBoardBody
import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.ExportBoardBody
import com.m57.hermescontrol.data.model.ImportBoardBody
import com.m57.hermescontrol.data.model.KanbanBoard
import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanProject
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.OrchestrationSettings
import com.m57.hermescontrol.data.model.OrchestrationSettingsUpdate
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.RenameBoardBody
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.data.model.UpdateTaskBody
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.repository.KanbanRepository
import com.m57.hermescontrol.data.repository.KanbanRepositoryImpl
import com.m57.hermescontrol.data.ws.KanbanEventsClient
@@ -38,7 +49,14 @@ data class KanbanUiState(
val columns: List<KanbanColumn> = emptyList(),
val tasks: List<KanbanTask> = emptyList(),
val profiles: List<KanbanProfile> = emptyList(),
val projects: List<KanbanProject> = emptyList(),
val orchestration: OrchestrationSettings? = null,
val modelProviders: List<ModelProvider> = emptyList(),
val pinnedModels: List<PinnedModel> = emptyList(),
val isLive: Boolean = false,
val includeArchived: Boolean = false,
val groupRunning: Boolean = false,
val columnCollapseOverrides: Map<String, Boolean> = emptyMap(),
val errorMessage: String? = null,
val toastMessage: String? = null,
)
@@ -131,7 +149,13 @@ class KanbanViewModel(
},
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(KanbanUiState())
private val _uiState =
MutableStateFlow(
KanbanUiState(
includeArchived = preferences.getIncludeArchived(),
groupRunning = preferences.getGroupRunning(),
),
)
val uiState: StateFlow<KanbanUiState> = _uiState.asStateFlow()
private var eventsClient: KanbanEventsClient? = null
@@ -146,8 +170,11 @@ class KanbanViewModel(
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
loadProfiles()
loadProjects()
loadOrchestration()
loadModelOptions()
viewModelScope.launch {
when (val result = repository.getBoards()) {
when (val result = repository.getBoards(includeArchived = _uiState.value.includeArchived)) {
is NetworkResult.Success -> {
val boards = result.data.boards
_uiState.update { it.copy(isLoading = false, boards = boards) }
@@ -186,6 +213,40 @@ class KanbanViewModel(
}
}
fun setIncludeArchived(include: Boolean) {
preferences.setIncludeArchived(include)
_uiState.update { it.copy(includeArchived = include) }
val board = _uiState.value.selectedBoard ?: return
val gen = ++currentLoadGen
viewModelScope.launch {
loadBoardIntoState(board, gen)
}
}
fun setGroupRunning(group: Boolean) {
preferences.setGroupRunning(group)
_uiState.update { it.copy(groupRunning = group) }
}
fun toggleColumnCollapse(
columnName: String,
boardHasWork: Boolean,
currentCount: Int,
) {
_uiState.update { state ->
val currentlyCollapsed =
KanbanColumnCollapseHelper.isColumnCollapsed(
columnName = columnName,
columnTaskCount = currentCount,
boardHasWork = boardHasWork,
overrides = state.columnCollapseOverrides,
)
state.copy(
columnCollapseOverrides = state.columnCollapseOverrides + (columnName to !currentlyCollapsed),
)
}
}
fun loadProfiles() {
viewModelScope.launch {
when (val result = repository.getProfiles()) {
@@ -200,6 +261,123 @@ class KanbanViewModel(
}
}
fun loadProjects() {
viewModelScope.launch {
when (val result = repository.getProjects()) {
is NetworkResult.Success -> {
_uiState.update { it.copy(projects = result.data.projects) }
}
is NetworkResult.Failure -> {
// Do not block UI if projects fetch fails
}
}
}
}
fun loadOrchestration() {
viewModelScope.launch {
when (val result = repository.getOrchestration()) {
is NetworkResult.Success -> {
_uiState.update { it.copy(orchestration = result.data) }
}
is NetworkResult.Failure -> {
// Do not block UI if orchestration fetch fails
}
}
}
}
fun loadModelOptions() {
viewModelScope.launch {
when (val res = safeApiCall { ApiClient.hermesApi.getModelOptions() }) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
modelProviders = res.data.providers,
pinnedModels = AuthManager.getPinnedModels(),
)
}
}
is NetworkResult.Failure -> {
// Do not block UI if model options fetch fails
}
}
}
}
fun updateOrchestration(body: OrchestrationSettingsUpdate) {
viewModelScope.launch {
when (val res = repository.updateOrchestration(body)) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
orchestration = res.data,
toastMessage = "Orchestration settings saved",
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(toastMessage = "Failed to save orchestration: ${res.error.message}")
}
}
}
}
}
fun updateProfileDescription(
name: String,
description: String,
) {
viewModelScope.launch {
when (val res = repository.updateProfileDescription(name, description)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Profile description saved") }
loadProfiles()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(toastMessage = "Failed to save profile description: ${res.error.message}")
}
}
}
}
}
fun autoDescribeProfile(
name: String,
onComplete: ((String?) -> Unit)? = null,
) {
viewModelScope.launch {
when (val res = repository.autoDescribeProfile(name)) {
is NetworkResult.Success -> {
if (res.data.ok) {
_uiState.update { it.copy(toastMessage = "Profile auto-described") }
loadProfiles()
onComplete?.invoke(res.data.description)
} else {
_uiState.update {
it.copy(toastMessage = res.data.reason ?: "Auto-describe failed")
}
onComplete?.invoke(null)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(toastMessage = "Failed to auto-describe: ${res.error.message}")
}
onComplete?.invoke(null)
}
}
}
}
fun createTask(
body: CreateTaskBody,
targetStatus: String = "todo",
@@ -254,11 +432,16 @@ class KanbanViewModel(
description: String?,
status: String = "todo",
) {
val defaultAssignee =
_uiState.value.orchestration
?.resolvedDefaultAssignee
?.ifBlank { null } ?: "default"
createTask(
body =
CreateTaskBody(
title = title,
body = description,
assignee = defaultAssignee,
),
targetStatus = status,
)
@@ -277,6 +460,7 @@ class KanbanViewModel(
slug: String,
name: String? = null,
description: String? = null,
projectId: String? = null,
) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
@@ -285,6 +469,7 @@ class KanbanViewModel(
slug = slug,
name = name,
description = description,
projectId = projectId?.ifBlank { null },
)
when (val res = repository.createBoard(body)) {
is NetworkResult.Success -> {
@@ -305,16 +490,16 @@ class KanbanViewModel(
}
}
fun renameBoard(
fun updateBoard(
slug: String,
newName: String,
body: RenameBoardBody,
) {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.updateBoard(slug, RenameBoardBody(name = newName))) {
when (val res = repository.updateBoard(slug, body)) {
is NetworkResult.Success -> {
val boardName = res.data.board?.name ?: newName
_uiState.update { it.copy(toastMessage = "Board renamed to $boardName") }
val boardName = res.data.board?.displayName ?: slug
_uiState.update { it.copy(toastMessage = "Board updated: $boardName") }
loadBoards()
}
@@ -322,7 +507,7 @@ class KanbanViewModel(
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Failed to rename board: ${res.error.message}",
toastMessage = "Failed to update board: ${res.error.message}",
)
}
}
@@ -330,12 +515,23 @@ class KanbanViewModel(
}
}
fun renameBoard(
slug: String,
newName: String,
) {
updateBoard(slug, RenameBoardBody(name = newName))
}
fun deleteBoard(slug: String) {
if (slug.equals("default", ignoreCase = true)) {
_uiState.update { it.copy(toastMessage = "Cannot delete or archive the default board") }
return
}
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.deleteBoard(slug, delete = true)) {
when (val res = repository.deleteBoard(slug, delete = false)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Board deleted") }
_uiState.update { it.copy(toastMessage = "Board archived") }
loadBoards()
}
@@ -343,7 +539,29 @@ class KanbanViewModel(
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Failed to delete board: ${res.error.message}",
toastMessage = "Failed to archive board: ${res.error.message}",
)
}
}
}
}
}
fun deleteTask(taskId: String) {
val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.deleteTask(taskId = taskId, board = board.id)) {
is NetworkResult.Success -> {
_uiState.update { it.copy(toastMessage = "Task deleted") }
reloadBoardSilently()
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
toastMessage = "Failed to delete task: ${res.error.message}",
)
}
}
@@ -354,15 +572,26 @@ class KanbanViewModel(
fun bulkMove(
taskIds: List<String>,
targetStatus: String,
onComplete: ((failedIds: Set<String>) -> Unit)? = null,
) {
val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, status = targetStatus))) {
is NetworkResult.Success -> {
val count = res.data.results.count { it.ok }
_uiState.update { it.copy(toastMessage = "Moved $count tasks to $targetStatus") }
val results = res.data.results
val succeeded = results.filter { it.ok }.map { it.id }.toSet()
val failed = results.filter { !it.ok }.map { it.id }.toSet()
val firstError = results.firstOrNull { !it.ok }?.error
val msg =
when {
failed.isEmpty() -> "Moved ${succeeded.size} tasks to $targetStatus"
succeeded.isEmpty() -> "Failed to move tasks: ${firstError ?: "unknown error"}"
else -> "Moved ${succeeded.size} tasks, ${failed.size} failed: ${firstError ?: ""}"
}
_uiState.update { it.copy(toastMessage = msg) }
reloadBoardSilently()
onComplete?.invoke(failed)
}
is NetworkResult.Failure -> {
@@ -372,20 +601,34 @@ class KanbanViewModel(
toastMessage = "Bulk move failed: ${res.error.message}",
)
}
onComplete?.invoke(taskIds.toSet())
}
}
}
}
fun bulkArchive(taskIds: List<String>) {
fun bulkArchive(
taskIds: List<String>,
onComplete: ((failedIds: Set<String>) -> Unit)? = null,
) {
val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, archive = true))) {
is NetworkResult.Success -> {
val count = res.data.results.count { it.ok }
_uiState.update { it.copy(toastMessage = "Archived $count tasks") }
val results = res.data.results
val succeeded = results.filter { it.ok }.map { it.id }.toSet()
val failed = results.filter { !it.ok }.map { it.id }.toSet()
val firstError = results.firstOrNull { !it.ok }?.error
val msg =
when {
failed.isEmpty() -> "Archived ${succeeded.size} tasks"
succeeded.isEmpty() -> "Failed to archive tasks: ${firstError ?: "unknown error"}"
else -> "Archived ${succeeded.size} tasks, ${failed.size} failed: ${firstError ?: ""}"
}
_uiState.update { it.copy(toastMessage = msg) }
reloadBoardSilently()
onComplete?.invoke(failed)
}
is NetworkResult.Failure -> {
@@ -395,6 +638,7 @@ class KanbanViewModel(
toastMessage = "Bulk archive failed: ${res.error.message}",
)
}
onComplete?.invoke(taskIds.toSet())
}
}
}
@@ -403,15 +647,27 @@ class KanbanViewModel(
fun bulkAssign(
taskIds: List<String>,
assignee: String?,
onComplete: ((failedIds: Set<String>) -> Unit)? = null,
) {
val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val res = repository.bulkTasks(board.id, BulkTasksBody(ids = taskIds, assignee = assignee))) {
is NetworkResult.Success -> {
val count = res.data.results.count { it.ok }
_uiState.update { it.copy(toastMessage = "Assigned $count tasks") }
val results = res.data.results
val succeeded = results.filter { it.ok }.map { it.id }.toSet()
val failed = results.filter { !it.ok }.map { it.id }.toSet()
val firstError = results.firstOrNull { !it.ok }?.error
val actionName = if (assignee != null) "Assigned" else "Unassigned"
val msg =
when {
failed.isEmpty() -> "$actionName ${succeeded.size} tasks"
succeeded.isEmpty() -> "Failed to update assignment: ${firstError ?: "unknown error"}"
else -> "$actionName ${succeeded.size} tasks, ${failed.size} failed: ${firstError ?: ""}"
}
_uiState.update { it.copy(toastMessage = msg) }
reloadBoardSilently()
onComplete?.invoke(failed)
}
is NetworkResult.Failure -> {
@@ -421,11 +677,54 @@ class KanbanViewModel(
toastMessage = "Bulk assign failed: ${res.error.message}",
)
}
onComplete?.invoke(taskIds.toSet())
}
}
}
}
fun bulkDelete(
taskIds: List<String>,
onComplete: ((failedIds: Set<String>) -> Unit)? = null,
) {
val board = _uiState.value.selectedBoard ?: return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val succeeded = mutableSetOf<String>()
val failed = mutableSetOf<String>()
var firstError: String? = null
taskIds.forEach { taskId ->
when (val res = repository.deleteTask(taskId = taskId, board = board.id)) {
is NetworkResult.Success -> {
succeeded.add(taskId)
}
is NetworkResult.Failure -> {
failed.add(taskId)
if (firstError == null) firstError = res.error.message
}
}
}
val msg =
when {
failed.isEmpty() -> "Deleted ${succeeded.size} tasks"
succeeded.isEmpty() -> "Failed to delete tasks: ${firstError ?: "unknown error"}"
else -> "Deleted ${succeeded.size} tasks, ${failed.size} failed: ${firstError ?: ""}"
}
_uiState.update { it.copy(toastMessage = msg) }
reloadBoardSilently()
onComplete?.invoke(failed)
}
}
suspend fun exportBoard(slug: String): NetworkResult<BoardExportResult> =
repository.exportBoard(slug, ExportBoardBody(output = ""))
suspend fun importBoard(serverArchive: String): NetworkResult<BoardImportResult> =
repository.importBoard(ImportBoardBody(archive = serverArchive))
fun moveTask(
task: KanbanTask,
action: KanbanTaskAction,
@@ -576,7 +875,11 @@ class KanbanViewModel(
val board = _uiState.value.selectedBoard ?: return
val gen = currentLoadGen
viewModelScope.launch {
val result = repository.getBoard(board = board.id)
val result =
repository.getBoard(
board = board.id,
includeArchived = _uiState.value.includeArchived,
)
if (gen != currentLoadGen) return@launch
if (result is NetworkResult.Success) {
val body = result.data
@@ -598,7 +901,11 @@ class KanbanViewModel(
board: KanbanBoard,
gen: Int = currentLoadGen,
) {
val result = repository.getBoard(board = board.id)
val result =
repository.getBoard(
board = board.id,
includeArchived = _uiState.value.includeArchived,
)
if (gen != currentLoadGen) return
when (result) {
is NetworkResult.Success -> {
@@ -632,6 +939,10 @@ class KanbanViewModel(
}
}
fun showToast(message: String) {
_uiState.update { it.copy(toastMessage = message) }
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
@@ -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.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -49,8 +50,12 @@ import com.m57.hermescontrol.data.model.CreateTaskBody
import com.m57.hermescontrol.data.model.KanbanColumn
import com.m57.hermescontrol.data.model.KanbanProfile
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.data.model.TaskEstimate
import com.m57.hermescontrol.ui.common.rememberSyncedTextFieldState
import com.m57.hermescontrol.ui.kanban.KanbanModelOverride
import com.m57.hermescontrol.ui.kanban.KanbanStatusConstraints
import kotlinx.coroutines.launch
private const val PARKED_VALUE = "__parked__"
@@ -66,13 +71,20 @@ fun KanbanCreateTaskDialog(
onDismiss: () -> Unit,
onConfirm: (body: CreateTaskBody, targetStatus: String) -> Unit,
modifier: Modifier = Modifier,
defaultAssignee: String? = null,
boardDefaultWorkspaceKind: String? = null,
boardDefaultWorkdir: String? = null,
modelProviders: List<ModelProvider> = emptyList(),
pinnedModels: List<PinnedModel> = emptyList(),
onEstimate: (suspend (title: String, body: String?) -> TaskEstimate?)? = null,
) {
var title by remember { mutableStateOf("") }
var desc by remember { mutableStateOf("") }
val descTextFieldState = rememberSyncedTextFieldState(desc) { desc = it }
var selectedColumn by remember(defaultColumn) { mutableStateOf(defaultColumn) }
var selectedAssignee by remember { mutableStateOf<String?>(null) }
var selectedAssignee by remember(defaultAssignee) {
mutableStateOf(defaultAssignee ?: profiles.firstOrNull()?.name ?: "default")
}
var priority by remember { mutableIntStateOf(0) }
var goalMode by remember { mutableStateOf(false) }
@@ -84,16 +96,22 @@ fun KanbanCreateTaskDialog(
// Advanced options
var showAdvanced by remember { mutableStateOf(false) }
var parentTaskId by remember { mutableStateOf<String?>(null) }
var modelOverride by remember { mutableStateOf("") }
var providerOverride by remember { mutableStateOf("") }
var reasoningEffort by remember { mutableStateOf<String?>(null) }
var workspaceKind by remember { mutableStateOf<String?>(null) }
var modelOverride by remember { mutableStateOf(KanbanModelOverride.EMPTY) }
var workspaceKind by remember(boardDefaultWorkspaceKind) {
mutableStateOf(boardDefaultWorkspaceKind ?: "scratch")
}
var workspacePath by remember { mutableStateOf("") }
var skillsText by remember { mutableStateOf("") }
var assigneeExpanded by remember { mutableStateOf(false) }
var columnExpanded by remember { mutableStateOf(false) }
var parentExpanded by remember { mutableStateOf(false) }
val writableColumns =
remember(columns) {
columns.filter { KanbanStatusConstraints.isUserWritableTarget(it.name) }
}
AlertDialog(
onDismissRequest = { if (!isCreating) onDismiss() },
modifier = modifier,
@@ -127,7 +145,7 @@ fun KanbanCreateTaskDialog(
Spacer(modifier = Modifier.height(12.dp))
// Target Column selector
if (columns.isNotEmpty()) {
if (writableColumns.isNotEmpty()) {
ExposedDropdownMenuBox(
expanded = columnExpanded,
onExpandedChange = { if (!isCreating) columnExpanded = it },
@@ -149,7 +167,7 @@ fun KanbanCreateTaskDialog(
expanded = columnExpanded,
onDismissRequest = { columnExpanded = false },
) {
columns.forEach { col ->
writableColumns.forEach { col ->
DropdownMenuItem(
text = { Text(col.name.uppercase()) },
onClick = {
@@ -169,10 +187,22 @@ fun KanbanCreateTaskDialog(
onExpandedChange = { if (!isCreating) assigneeExpanded = it },
) {
val displayAssignee =
when (selectedAssignee) {
null -> stringResource(R.string.kanban_assignee_unassigned)
PARKED_VALUE -> stringResource(R.string.kanban_assignee_parked)
else -> selectedAssignee ?: ""
when {
selectedAssignee == PARKED_VALUE -> {
stringResource(R.string.kanban_assignee_parked)
}
selectedAssignee == defaultAssignee && !defaultAssignee.isNullOrBlank() -> {
"$defaultAssignee (Default)"
}
selectedAssignee.isNullOrBlank() -> {
stringResource(R.string.kanban_assignee_unassigned)
}
else -> {
selectedAssignee
}
}
OutlinedTextField(
value = displayAssignee,
@@ -187,18 +217,12 @@ fun KanbanCreateTaskDialog(
expanded = assigneeExpanded,
onDismissRequest = { assigneeExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.kanban_assignee_unassigned)) },
onClick = {
selectedAssignee = null
assigneeExpanded = false
},
)
profiles.forEach { profile ->
val isDef = profile.name == defaultAssignee
DropdownMenuItem(
text = {
Column {
Text(profile.name)
Text(if (isDef) "${profile.name} (Default)" else profile.name)
if (profile.model != null) {
Text(
profile.model,
@@ -258,11 +282,7 @@ fun KanbanCreateTaskDialog(
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier =
Modifier
.fillMaxWidth()
.clickable(enabled = !isCreating) { goalMode = !goalMode }
.padding(vertical = 4.dp),
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text(
@@ -271,11 +291,10 @@ fun KanbanCreateTaskDialog(
)
Text(
text = stringResource(R.string.kanban_goal_mode_desc),
style = MaterialTheme.typography.labelSmall,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.width(8.dp))
Switch(
checked = goalMode,
onCheckedChange = { goalMode = it },
@@ -286,34 +305,42 @@ fun KanbanCreateTaskDialog(
Spacer(modifier = Modifier.height(8.dp))
// Advanced Options Toggle
TextButton(
onClick = { showAdvanced = !showAdvanced },
modifier = Modifier.align(Alignment.Start),
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
Modifier
.fillMaxWidth()
.clickable { showAdvanced = !showAdvanced }
.padding(vertical = 8.dp),
) {
Text(
text = stringResource(R.string.kanban_advanced_options),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.weight(1f),
)
Icon(
imageVector = if (showAdvanced) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.kanban_advanced_options))
}
AnimatedVisibility(visible = showAdvanced) {
Column(modifier = Modifier.fillMaxWidth()) {
Column {
// Parent Task
if (existingTasks.isNotEmpty()) {
ExposedDropdownMenuBox(
expanded = parentExpanded,
onExpandedChange = { if (!isCreating) parentExpanded = it },
) {
val displayParent =
existingTasks.find { it.id == parentTaskId }?.let {
"#${it.id.removePrefix("t_").take(6)} • ${it.title}"
val parentDisplay =
parentTaskId?.let { pid ->
existingTasks.find { it.id == pid }?.title ?: pid
} ?: stringResource(R.string.kanban_parent_none)
OutlinedTextField(
value = displayParent,
value = parentDisplay,
onValueChange = {},
readOnly = true,
label = { Text(stringResource(R.string.kanban_parent_task)) },
@@ -342,7 +369,12 @@ fun KanbanCreateTaskDialog(
)
existingTasks.forEach { task ->
DropdownMenuItem(
text = { Text("#${task.id.removePrefix("t_").take(6)} • ${task.title}") },
text = {
Text(
task.title,
maxLines = 1,
)
},
onClick = {
parentTaskId = task.id
parentExpanded = false
@@ -354,60 +386,17 @@ fun KanbanCreateTaskDialog(
Spacer(modifier = Modifier.height(8.dp))
}
// Model Override
OutlinedTextField(
value = modelOverride,
onValueChange = { modelOverride = it },
label = { Text(stringResource(R.string.kanban_model_override)) },
placeholder = { Text("e.g. claude-3-5-sonnet") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
// Model Override via shared ModelPickerDialog
KanbanModelOverrideEditor(
override = modelOverride,
onOverrideChange = { modelOverride = it },
modelProviders = modelProviders,
pinnedModels = pinnedModels,
enabled = !isCreating,
)
Spacer(modifier = Modifier.height(8.dp))
// Provider Override
OutlinedTextField(
value = providerOverride,
onValueChange = { providerOverride = it },
label = { Text(stringResource(R.string.kanban_provider_override)) },
placeholder = { Text("e.g. anthropic") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isCreating,
)
Spacer(modifier = Modifier.height(8.dp))
// Reasoning Effort
Text(
text = stringResource(R.string.kanban_reasoning_effort),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
) {
listOf(
null to "Inherit",
"low" to "Low",
"medium" to "Medium",
"high" to "High",
).forEach { (effort, label) ->
FilterChip(
selected = reasoningEffort == effort,
onClick = { reasoningEffort = effort },
label = { Text(label) },
enabled = !isCreating,
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Workspace Kind
Text(
text = stringResource(R.string.kanban_workspace_kind),
@@ -420,7 +409,7 @@ fun KanbanCreateTaskDialog(
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
) {
listOf(
null to "Scratch (Default)",
"scratch" to "Scratch (Default)",
"worktree" to "Worktree",
"dir" to "Dir",
).forEach { (kind, label) ->
@@ -433,6 +422,25 @@ fun KanbanCreateTaskDialog(
}
}
if (workspaceKind == "worktree" || workspaceKind == "dir") {
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = workspacePath,
onValueChange = { workspacePath = it },
label = { Text(stringResource(R.string.kanban_workspace_path)) },
placeholder = {
if (!boardDefaultWorkdir.isNullOrBlank()) {
Text(boardDefaultWorkdir)
} else {
Text(stringResource(R.string.kanban_workspace_path_hint))
}
},
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isCreating,
)
}
Spacer(modifier = Modifier.height(8.dp))
// Skills
@@ -448,40 +456,57 @@ fun KanbanCreateTaskDialog(
}
}
// Estimation Section
if (onEstimate != null) {
Spacer(modifier = Modifier.height(12.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
if (estimate != null) {
estimate?.let { est ->
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Text(
text = "Estimated Tokens: ${est.tokens ?: "N/A"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
if (!est.complexity.isNullOrBlank()) {
Text(
text = "Complexity: ${est.complexity}",
style = MaterialTheme.typography.bodySmall,
)
}
if (!est.rationale.isNullOrBlank()) {
Text(
text = est.rationale,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
OutlinedButton(
onClick = {
if (title.isNotBlank() && !isEstimating) {
isEstimating = true
coroutineScope.launch {
val res = onEstimate(title.trim(), desc.trim().ifBlank { null })
estimate = res
isEstimating = false
}
}
},
enabled = title.isNotBlank() && !isEstimating && !isCreating,
modifier = Modifier.fillMaxWidth(),
) {
if (estimate != null && estimate?.ok == true) {
Text(
text = "~${estimate?.estTokens ?: 0} tokens · ${estimate?.complexity ?: "normal"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
TextButton(
onClick = {
if (title.isNotBlank() && !isEstimating) {
coroutineScope.launch {
isEstimating = true
estimate = onEstimate(title, desc.ifBlank { null })
isEstimating = false
}
}
},
enabled = title.isNotBlank() && !isEstimating && !isCreating,
) {
if (isEstimating) {
CircularProgressIndicator(modifier = Modifier.size(12.dp), strokeWidth = 1.5.dp)
Spacer(modifier = Modifier.width(4.dp))
}
Text(if (estimate != null) "Re-estimate" else "Estimate tokens")
if (isEstimating) {
CircularProgressIndicator(modifier = Modifier.size(12.dp), strokeWidth = 1.5.dp)
Spacer(modifier = Modifier.width(4.dp))
}
Text(if (estimate != null) "Re-estimate" else "Estimate tokens")
}
}
}
@@ -497,6 +522,23 @@ fun KanbanCreateTaskDialog(
.filter { it.isNotEmpty() }
.takeIf { it.isNotEmpty() }
val finalWorkspaceKind = if (workspaceKind == "scratch") null else workspaceKind
val finalWorkspacePath =
if (finalWorkspaceKind != null && workspacePath.isNotBlank()) {
workspacePath.trim()
} else {
null
}
val finalModel = modelOverride.model.trim().ifBlank { null }
val finalProvider =
if (finalModel != null && modelOverride.provider.isNotBlank()) {
modelOverride.provider.trim()
} else {
null
}
val finalEffort = modelOverride.effort.trim().ifBlank { null }
val body =
CreateTaskBody(
title = title.trim(),
@@ -505,10 +547,11 @@ fun KanbanCreateTaskDialog(
priority = priority,
goalMode = goalMode,
parents = parentTaskId?.let { listOf(it) } ?: emptyList(),
modelOverride = modelOverride.trim().ifBlank { null },
providerOverride = providerOverride.trim().ifBlank { null },
reasoningEffort = reasoningEffort,
workspaceKind = workspaceKind,
modelOverride = finalModel,
providerOverride = finalProvider,
reasoningEffort = finalEffort,
workspaceKind = finalWorkspaceKind,
workspacePath = finalWorkspacePath,
skills = skillsList,
triage = selectedColumn.equals("triage", ignoreCase = true),
)
@@ -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 com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.kanban.KanbanArcState
import com.m57.hermescontrol.ui.kanban.KanbanRuntimeHelper
@Composable
fun KanbanTaskCard(
@@ -44,6 +46,7 @@ fun KanbanTaskCard(
onTaskClick: (KanbanTask) -> Unit,
modifier: Modifier = Modifier,
isSelected: Boolean = false,
defaultAssignee: String? = null,
onActionClick: ((KanbanTask) -> Unit)? = null,
) {
val statusColors = LocalHermesStatusColors.current
@@ -57,6 +60,10 @@ fun KanbanTaskCard(
else -> MaterialTheme.colorScheme.outline
}
val arcState = KanbanRuntimeHelper.arcState(task, defaultAssignee)
val isWontRun = KanbanRuntimeHelper.isWontRun(task, defaultAssignee)
val elapsed = KanbanRuntimeHelper.formatElapsed(task.startedAt)
val cardColors =
if (isSelected) {
CardDefaults.cardColors(
@@ -92,6 +99,37 @@ fun KanbanTaskCard(
fontWeight = FontWeight.Bold,
color = statusTone,
)
when {
arcState == KanbanArcState.RUNNING -> {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "· ${elapsed ?: "working"}",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium,
color = statusColors.success,
)
}
arcState == KanbanArcState.STALE -> {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "· STALE",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = statusColors.warning,
)
}
isWontRun -> {
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "· WON'T RUN",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = statusColors.error,
)
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
@@ -145,7 +183,14 @@ fun KanbanTaskCard(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
task.assignee?.let { assignee ->
val displayAssignee =
task.assignee
?: if (arcState == KanbanArcState.QUEUED && !defaultAssignee.isNullOrBlank()) {
"$defaultAssignee (Default)"
} else {
null
}
displayAssignee?.let { assignee ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(end = 8.dp),