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