feat(kanban): implement board parity, orchestration and task editing (#1137)
This commit is contained in:
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) }
|
||||
}
|
||||
|
||||
+155
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+164
-121
@@ -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),
|
||||
)
|
||||
|
||||
+166
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
+324
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user