Merge pull request #1161 from Hy4ri/feat/1137-kanban-parity
feat(kanban): close mobile gaps with desktop parity (#1137)
This commit is contained in:
@@ -0,0 +1,81 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.ui.test.assertTextEquals
|
||||||
|
import androidx.compose.ui.test.junit4.StateRestorationTester
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import androidx.lifecycle.LifecycleRegistry
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class KanbanLifecycleTest {
|
||||||
|
@get:Rule
|
||||||
|
val compose = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pendingExportSurvivesSavedStateRestoration() {
|
||||||
|
val restoration = StateRestorationTester(compose)
|
||||||
|
lateinit var path: MutableState<String?>
|
||||||
|
restoration.setContent {
|
||||||
|
path = rememberPendingKanbanExportPath()
|
||||||
|
}
|
||||||
|
compose.runOnIdle { path.value = "/cache/board.tar.gz" }
|
||||||
|
restoration.emulateSavedInstanceStateRestore()
|
||||||
|
compose.runOnIdle { assertEquals("/cache/board.tar.gz", path.value) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tickerStopsInBackgroundRefreshesOnResumeAndStopsOnDisposal() {
|
||||||
|
lateinit var registry: LifecycleRegistry
|
||||||
|
val owner =
|
||||||
|
object : LifecycleOwner {
|
||||||
|
override val lifecycle: Lifecycle get() = registry
|
||||||
|
}
|
||||||
|
compose.runOnUiThread {
|
||||||
|
registry = LifecycleRegistry(owner)
|
||||||
|
registry.currentState = Lifecycle.State.STARTED
|
||||||
|
}
|
||||||
|
var now = 100L
|
||||||
|
val visible = mutableStateOf(true)
|
||||||
|
var displayed = 0L
|
||||||
|
compose.mainClock.autoAdvance = false
|
||||||
|
compose.setContent {
|
||||||
|
CompositionLocalProvider(LocalLifecycleOwner provides owner) {
|
||||||
|
if (visible.value) {
|
||||||
|
displayed = rememberKanbanNowSeconds(true) { now }
|
||||||
|
Text("$displayed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.mainClock.advanceTimeBy(32)
|
||||||
|
compose.onNodeWithText("100").assertTextEquals("100")
|
||||||
|
compose.runOnUiThread { now = 105L }
|
||||||
|
compose.mainClock.advanceTimeBy(5_100)
|
||||||
|
compose.onNodeWithText("105").assertTextEquals("105")
|
||||||
|
compose.runOnUiThread {
|
||||||
|
registry.currentState = Lifecycle.State.CREATED
|
||||||
|
now = 200L
|
||||||
|
}
|
||||||
|
compose.mainClock.advanceTimeBy(10_100)
|
||||||
|
compose.onNodeWithText("105").assertTextEquals("105")
|
||||||
|
compose.runOnUiThread { registry.currentState = Lifecycle.State.STARTED }
|
||||||
|
compose.mainClock.advanceTimeBy(64)
|
||||||
|
compose.onNodeWithText("200").assertTextEquals("200")
|
||||||
|
compose.runOnUiThread { visible.value = false }
|
||||||
|
compose.mainClock.advanceTimeBy(64)
|
||||||
|
compose.runOnUiThread { now = 300L }
|
||||||
|
compose.mainClock.advanceTimeBy(10_100)
|
||||||
|
compose.runOnIdle { assertEquals(200L, displayed) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.assertIsSelected
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performScrollTo
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.filters.MediumTest
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanColumn
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanProfile
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
|
import com.m57.hermescontrol.theme.HermesControlTheme
|
||||||
|
import com.m57.hermescontrol.ui.kanban.components.KanbanCreateTaskDialog
|
||||||
|
import com.m57.hermescontrol.ui.kanban.components.KanbanFilterSheet
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
@MediumTest
|
||||||
|
class KanbanScreenTest {
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testShowArchivedToggleInvokesCallback() {
|
||||||
|
var toggled = false
|
||||||
|
composeTestRule.setContent {
|
||||||
|
HermesControlTheme {
|
||||||
|
KanbanFilterSheet(
|
||||||
|
assignees = emptyList(),
|
||||||
|
tenants = emptyList(),
|
||||||
|
selectedAssignee = null,
|
||||||
|
selectedTenant = null,
|
||||||
|
includeArchived = false,
|
||||||
|
groupRunning = false,
|
||||||
|
onSelectAssignee = {},
|
||||||
|
onSelectTenant = {},
|
||||||
|
onToggleIncludeArchived = { toggled = it },
|
||||||
|
onToggleGroupRunning = {},
|
||||||
|
onClearFilters = {},
|
||||||
|
onDismiss = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithTag("kanban_show_archived").performClick()
|
||||||
|
assertTrue(toggled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testWorkspacePathVisibilityInCreateTaskDialog() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
HermesControlTheme {
|
||||||
|
KanbanCreateTaskDialog(
|
||||||
|
columns = listOf(KanbanColumn(name = "todo")),
|
||||||
|
defaultColumn = "todo",
|
||||||
|
profiles = listOf(KanbanProfile(name = "researcher")),
|
||||||
|
existingTasks = emptyList(),
|
||||||
|
isCreating = false,
|
||||||
|
onDismiss = {},
|
||||||
|
onConfirm = { _, _ -> },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expand advanced options
|
||||||
|
composeTestRule.onNodeWithTag("kanban_advanced_options").performClick()
|
||||||
|
|
||||||
|
// By default scratch is selected, workspace path is not visible
|
||||||
|
composeTestRule.onNodeWithTag("kanban_workspace_path").assertDoesNotExist()
|
||||||
|
|
||||||
|
// Select Worktree
|
||||||
|
composeTestRule.onNodeWithTag("kanban_workspace_kinds").performScrollTo()
|
||||||
|
composeTestRule.onNodeWithTag("kanban_workspace_kind_worktree").performClick()
|
||||||
|
composeTestRule.onNodeWithTag("kanban_workspace_kind_worktree").assertIsSelected()
|
||||||
|
composeTestRule.onNodeWithTag("kanban_workspace_path").performScrollTo().assertIsDisplayed()
|
||||||
|
|
||||||
|
// Select Scratch again
|
||||||
|
composeTestRule.onNodeWithTag("kanban_workspace_kinds").performScrollTo()
|
||||||
|
composeTestRule.onNodeWithTag("kanban_workspace_kind_scratch").performClick()
|
||||||
|
composeTestRule.onNodeWithTag("kanban_workspace_path").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDefaultAssigneeOptionPresent() {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
HermesControlTheme {
|
||||||
|
KanbanCreateTaskDialog(
|
||||||
|
columns = listOf(KanbanColumn(name = "todo")),
|
||||||
|
defaultColumn = "todo",
|
||||||
|
profiles = listOf(KanbanProfile(name = "researcher")),
|
||||||
|
existingTasks = emptyList(),
|
||||||
|
isCreating = false,
|
||||||
|
defaultAssignee = "researcher",
|
||||||
|
onDismiss = {},
|
||||||
|
onConfirm = { _, _ -> },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText("researcher (Default)").assertIsDisplayed()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ interface KanbanPreferencesStore {
|
|||||||
slug: String,
|
slug: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
fun clearSelectedBoard(endpoint: String)
|
||||||
|
|
||||||
fun getViewMode(): String
|
fun getViewMode(): String
|
||||||
|
|
||||||
fun setViewMode(mode: String)
|
fun setViewMode(mode: String)
|
||||||
@@ -45,6 +47,10 @@ class InMemoryKanbanPreferencesStore : KanbanPreferencesStore {
|
|||||||
selectedBoards[endpoint] = slug
|
selectedBoards[endpoint] = slug
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun clearSelectedBoard(endpoint: String) {
|
||||||
|
selectedBoards.remove(endpoint)
|
||||||
|
}
|
||||||
|
|
||||||
override fun getViewMode(): String = viewMode
|
override fun getViewMode(): String = viewMode
|
||||||
|
|
||||||
override fun setViewMode(mode: String) {
|
override fun setViewMode(mode: String) {
|
||||||
|
|||||||
@@ -92,3 +92,17 @@ data class DeleteBoardResponse(
|
|||||||
val result: JsonElement? = null,
|
val result: JsonElement? = null,
|
||||||
val current: String? = null,
|
val current: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ExportBoardBody(
|
||||||
|
val output: String = "",
|
||||||
|
val attachments: Boolean = true,
|
||||||
|
val logs: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ImportBoardBody(
|
||||||
|
val archive: String,
|
||||||
|
val slug: String? = null,
|
||||||
|
val switch: Boolean = false,
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.data.model
|
package com.m57.hermescontrol.data.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
|
||||||
@@ -146,18 +147,18 @@ data class CreateTaskBody(
|
|||||||
val assignee: String? = null,
|
val assignee: String? = null,
|
||||||
val tenant: String? = null,
|
val tenant: String? = null,
|
||||||
val priority: Int = 0,
|
val priority: Int = 0,
|
||||||
val workspaceKind: String? = null,
|
@SerialName("workspace_kind") val workspaceKind: String? = null,
|
||||||
val workspacePath: String? = null,
|
@SerialName("workspace_path") val workspacePath: String? = null,
|
||||||
val parents: List<String> = emptyList(),
|
val parents: List<String> = emptyList(),
|
||||||
val triage: Boolean = false,
|
val triage: Boolean = false,
|
||||||
val maxRuntimeSeconds: Int? = null,
|
@SerialName("max_runtime_seconds") val maxRuntimeSeconds: Int? = null,
|
||||||
val skills: List<String>? = null,
|
val skills: List<String>? = null,
|
||||||
val goalMode: Boolean = false,
|
@SerialName("goal_mode") val goalMode: Boolean = false,
|
||||||
val goalMaxTurns: Int? = null,
|
@SerialName("goal_max_turns") val goalMaxTurns: Int? = null,
|
||||||
val modelOverride: String? = null,
|
@SerialName("model_override") val modelOverride: String? = null,
|
||||||
val providerOverride: String? = null,
|
@SerialName("provider_override") val providerOverride: String? = null,
|
||||||
val reasoningEffort: String? = null,
|
@SerialName("reasoning_effort") val reasoningEffort: String? = null,
|
||||||
val projectId: String? = null,
|
@SerialName("project_id") val projectId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -174,14 +175,14 @@ data class UpdateTaskBody(
|
|||||||
val title: String? = null,
|
val title: String? = null,
|
||||||
val body: String? = null,
|
val body: String? = null,
|
||||||
val result: String? = null,
|
val result: String? = null,
|
||||||
val blockReason: String? = null,
|
@SerialName("block_reason") val blockReason: String? = null,
|
||||||
val summary: String? = null,
|
val summary: String? = null,
|
||||||
val metadata: Map<String, JsonElement>? = null,
|
val metadata: Map<String, JsonElement>? = null,
|
||||||
val modelOverride: String? = null,
|
@SerialName("model_override") val modelOverride: String? = null,
|
||||||
val providerOverride: String? = null,
|
@SerialName("provider_override") val providerOverride: String? = null,
|
||||||
val clearModelOverride: Boolean = false,
|
@SerialName("clear_model_override") val clearModelOverride: Boolean = false,
|
||||||
val reasoningEffort: String? = null,
|
@SerialName("reasoning_effort") val reasoningEffort: String? = null,
|
||||||
val clearReasoningEffort: Boolean = false,
|
@SerialName("clear_reasoning_effort") val clearReasoningEffort: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -199,12 +200,12 @@ data class BulkTasksBody(
|
|||||||
val result: String? = null,
|
val result: String? = null,
|
||||||
val summary: String? = null,
|
val summary: String? = null,
|
||||||
val metadata: Map<String, JsonElement>? = null,
|
val metadata: Map<String, JsonElement>? = null,
|
||||||
val reclaimFirst: Boolean = false,
|
@SerialName("reclaim_first") val reclaimFirst: Boolean = false,
|
||||||
val modelOverride: String? = null,
|
@SerialName("model_override") val modelOverride: String? = null,
|
||||||
val providerOverride: String? = null,
|
@SerialName("provider_override") val providerOverride: String? = null,
|
||||||
val clearModelOverride: Boolean = false,
|
@SerialName("clear_model_override") val clearModelOverride: Boolean = false,
|
||||||
val reasoningEffort: String? = null,
|
@SerialName("reasoning_effort") val reasoningEffort: String? = null,
|
||||||
val clearReasoningEffort: Boolean = false,
|
@SerialName("clear_reasoning_effort") val clearReasoningEffort: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -252,4 +253,6 @@ data class TaskEstimate(
|
|||||||
val complexity: String? = null,
|
val complexity: String? = null,
|
||||||
val rationale: String? = null,
|
val rationale: String? = null,
|
||||||
val model: String? = null,
|
val model: String? = null,
|
||||||
)
|
) {
|
||||||
|
val tokens: Int? get() = estTokens
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package com.m57.hermescontrol.data.remote
|
|||||||
|
|
||||||
import com.m57.hermescontrol.data.model.AttachmentUploadResponse
|
import com.m57.hermescontrol.data.model.AttachmentUploadResponse
|
||||||
import com.m57.hermescontrol.data.model.AutoDescribeResponse
|
import com.m57.hermescontrol.data.model.AutoDescribeResponse
|
||||||
|
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.BulkTasksResponse
|
import com.m57.hermescontrol.data.model.BulkTasksResponse
|
||||||
import com.m57.hermescontrol.data.model.CreateBoardBody
|
import com.m57.hermescontrol.data.model.CreateBoardBody
|
||||||
@@ -10,6 +12,8 @@ import com.m57.hermescontrol.data.model.CreateTaskBody
|
|||||||
import com.m57.hermescontrol.data.model.CreateTaskResponse
|
import com.m57.hermescontrol.data.model.CreateTaskResponse
|
||||||
import com.m57.hermescontrol.data.model.DeleteBoardResponse
|
import com.m57.hermescontrol.data.model.DeleteBoardResponse
|
||||||
import com.m57.hermescontrol.data.model.DispatchResult
|
import com.m57.hermescontrol.data.model.DispatchResult
|
||||||
|
import com.m57.hermescontrol.data.model.ExportBoardBody
|
||||||
|
import com.m57.hermescontrol.data.model.ImportBoardBody
|
||||||
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||||
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
||||||
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
|
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
|
||||||
@@ -77,6 +81,17 @@ interface KanbanApiService {
|
|||||||
@Path("slug") slug: String,
|
@Path("slug") slug: String,
|
||||||
): Response<Unit>
|
): Response<Unit>
|
||||||
|
|
||||||
|
@POST("api/plugins/kanban/boards/{slug}/export")
|
||||||
|
suspend fun exportBoard(
|
||||||
|
@Path("slug") slug: String,
|
||||||
|
@Body body: ExportBoardBody = ExportBoardBody(),
|
||||||
|
): Response<BoardExportResult>
|
||||||
|
|
||||||
|
@POST("api/plugins/kanban/boards/import")
|
||||||
|
suspend fun importBoard(
|
||||||
|
@Body body: ImportBoardBody,
|
||||||
|
): Response<BoardImportResult>
|
||||||
|
|
||||||
@GET("api/plugins/kanban/tasks/{id}")
|
@GET("api/plugins/kanban/tasks/{id}")
|
||||||
suspend fun getTask(
|
suspend fun getTask(
|
||||||
@Path("id") taskId: String,
|
@Path("id") taskId: String,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.m57.hermescontrol.data.repository
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.remote.KanbanApiService
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
interface KanbanDispatcherNudger {
|
||||||
|
fun scheduleNudge(board: String?)
|
||||||
|
}
|
||||||
|
|
||||||
|
class DefaultKanbanDispatcherNudger(
|
||||||
|
private val apiProvider: () -> KanbanApiService,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||||
|
private val debounceMs: Long = 400L,
|
||||||
|
) : KanbanDispatcherNudger {
|
||||||
|
private val lock = Any()
|
||||||
|
private val boardJobs = mutableMapOf<String?, Job>()
|
||||||
|
|
||||||
|
override fun scheduleNudge(board: String?) {
|
||||||
|
synchronized(lock) {
|
||||||
|
boardJobs[board]?.cancel()
|
||||||
|
boardJobs[board] =
|
||||||
|
scope.launch {
|
||||||
|
delay(debounceMs)
|
||||||
|
runCatching {
|
||||||
|
apiProvider().nudgeDispatcher(board)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package com.m57.hermescontrol.data.repository
|
|||||||
|
|
||||||
import com.m57.hermescontrol.data.model.AttachmentUploadResponse
|
import com.m57.hermescontrol.data.model.AttachmentUploadResponse
|
||||||
import com.m57.hermescontrol.data.model.AutoDescribeResponse
|
import com.m57.hermescontrol.data.model.AutoDescribeResponse
|
||||||
|
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.BulkTasksResponse
|
import com.m57.hermescontrol.data.model.BulkTasksResponse
|
||||||
import com.m57.hermescontrol.data.model.CreateBoardBody
|
import com.m57.hermescontrol.data.model.CreateBoardBody
|
||||||
@@ -10,6 +12,8 @@ import com.m57.hermescontrol.data.model.CreateTaskBody
|
|||||||
import com.m57.hermescontrol.data.model.CreateTaskResponse
|
import com.m57.hermescontrol.data.model.CreateTaskResponse
|
||||||
import com.m57.hermescontrol.data.model.DeleteBoardResponse
|
import com.m57.hermescontrol.data.model.DeleteBoardResponse
|
||||||
import com.m57.hermescontrol.data.model.DispatchResult
|
import com.m57.hermescontrol.data.model.DispatchResult
|
||||||
|
import com.m57.hermescontrol.data.model.ExportBoardBody
|
||||||
|
import com.m57.hermescontrol.data.model.ImportBoardBody
|
||||||
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||||
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
|
||||||
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
|
import com.m57.hermescontrol.data.model.KanbanProfilesResponse
|
||||||
@@ -58,6 +62,13 @@ interface KanbanRepository {
|
|||||||
delete: Boolean = false,
|
delete: Boolean = false,
|
||||||
): NetworkResult<DeleteBoardResponse>
|
): NetworkResult<DeleteBoardResponse>
|
||||||
|
|
||||||
|
suspend fun exportBoard(
|
||||||
|
slug: String,
|
||||||
|
body: ExportBoardBody = ExportBoardBody(),
|
||||||
|
): NetworkResult<BoardExportResult>
|
||||||
|
|
||||||
|
suspend fun importBoard(body: ImportBoardBody): NetworkResult<BoardImportResult>
|
||||||
|
|
||||||
suspend fun getTask(
|
suspend fun getTask(
|
||||||
taskId: String,
|
taskId: String,
|
||||||
board: String?,
|
board: String?,
|
||||||
@@ -151,6 +162,7 @@ interface KanbanRepository {
|
|||||||
class KanbanRepositoryImpl(
|
class KanbanRepositoryImpl(
|
||||||
private val apiProvider: () -> KanbanApiService = { ApiClient.kanbanApi },
|
private val apiProvider: () -> KanbanApiService = { ApiClient.kanbanApi },
|
||||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
private val nudger: KanbanDispatcherNudger = DefaultKanbanDispatcherNudger(apiProvider),
|
||||||
) : KanbanRepository {
|
) : KanbanRepository {
|
||||||
override suspend fun getBoards(includeArchived: Boolean): NetworkResult<KanbanBoardsResponse> =
|
override suspend fun getBoards(includeArchived: Boolean): NetworkResult<KanbanBoardsResponse> =
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
@@ -187,6 +199,19 @@ class KanbanRepositoryImpl(
|
|||||||
safeApiCall { apiProvider().deleteBoard(slug, delete) }
|
safeApiCall { apiProvider().deleteBoard(slug, delete) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun exportBoard(
|
||||||
|
slug: String,
|
||||||
|
body: ExportBoardBody,
|
||||||
|
): NetworkResult<BoardExportResult> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().exportBoard(slug, body) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun importBoard(body: ImportBoardBody): NetworkResult<BoardImportResult> =
|
||||||
|
withContext(ioDispatcher) {
|
||||||
|
safeApiCall { apiProvider().importBoard(body) }
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getTask(
|
override suspend fun getTask(
|
||||||
taskId: String,
|
taskId: String,
|
||||||
board: String?,
|
board: String?,
|
||||||
@@ -200,7 +225,11 @@ class KanbanRepositoryImpl(
|
|||||||
body: CreateTaskBody,
|
body: CreateTaskBody,
|
||||||
): NetworkResult<CreateTaskResponse> =
|
): NetworkResult<CreateTaskResponse> =
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
safeApiCall { apiProvider().createTask(board = board, body = body) }
|
val result = safeApiCall { apiProvider().createTask(board = board, body = body) }
|
||||||
|
if (result is NetworkResult.Success) {
|
||||||
|
nudger.scheduleNudge(board)
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun updateTask(
|
override suspend fun updateTask(
|
||||||
@@ -209,7 +238,11 @@ class KanbanRepositoryImpl(
|
|||||||
body: UpdateTaskBody,
|
body: UpdateTaskBody,
|
||||||
): NetworkResult<UpdateTaskResponse> =
|
): NetworkResult<UpdateTaskResponse> =
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
safeApiCall { apiProvider().updateTask(taskId = taskId, board = board, body = body) }
|
val result = safeApiCall { apiProvider().updateTask(taskId = taskId, board = board, body = body) }
|
||||||
|
if (result is NetworkResult.Success) {
|
||||||
|
nudger.scheduleNudge(board)
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteTask(
|
override suspend fun deleteTask(
|
||||||
@@ -217,7 +250,11 @@ class KanbanRepositoryImpl(
|
|||||||
board: String?,
|
board: String?,
|
||||||
): NetworkResult<Unit> =
|
): NetworkResult<Unit> =
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
safeApiCall { apiProvider().deleteTask(taskId = taskId, board = board) }
|
val result = safeApiCall { apiProvider().deleteTask(taskId = taskId, board = board) }
|
||||||
|
if (result is NetworkResult.Success) {
|
||||||
|
nudger.scheduleNudge(board)
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun bulkTasks(
|
override suspend fun bulkTasks(
|
||||||
@@ -225,7 +262,11 @@ class KanbanRepositoryImpl(
|
|||||||
body: BulkTasksBody,
|
body: BulkTasksBody,
|
||||||
): NetworkResult<BulkTasksResponse> =
|
): NetworkResult<BulkTasksResponse> =
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
safeApiCall { apiProvider().bulkTasks(board = board, body = body) }
|
val result = safeApiCall { apiProvider().bulkTasks(board = board, body = body) }
|
||||||
|
if (result is NetworkResult.Success) {
|
||||||
|
nudger.scheduleNudge(board)
|
||||||
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun addComment(
|
override suspend fun addComment(
|
||||||
@@ -250,13 +291,18 @@ class KanbanRepositoryImpl(
|
|||||||
reclaimFirst: Boolean,
|
reclaimFirst: Boolean,
|
||||||
): NetworkResult<ReassignTaskResponse> =
|
): NetworkResult<ReassignTaskResponse> =
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
safeApiCall {
|
val result =
|
||||||
apiProvider().reassignTask(
|
safeApiCall {
|
||||||
taskId = taskId,
|
apiProvider().reassignTask(
|
||||||
board = board,
|
taskId = taskId,
|
||||||
body = ReassignTaskBody(profile = profile, reclaimFirst = reclaimFirst),
|
board = board,
|
||||||
)
|
body = ReassignTaskBody(profile = profile, reclaimFirst = reclaimFirst),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (result is NetworkResult.Success) {
|
||||||
|
nudger.scheduleNudge(board)
|
||||||
}
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun reclaimTask(
|
override suspend fun reclaimTask(
|
||||||
@@ -265,13 +311,18 @@ class KanbanRepositoryImpl(
|
|||||||
reason: String?,
|
reason: String?,
|
||||||
): NetworkResult<ReclaimTaskResponse> =
|
): NetworkResult<ReclaimTaskResponse> =
|
||||||
withContext(ioDispatcher) {
|
withContext(ioDispatcher) {
|
||||||
safeApiCall {
|
val result =
|
||||||
apiProvider().reclaimTask(
|
safeApiCall {
|
||||||
taskId = taskId,
|
apiProvider().reclaimTask(
|
||||||
board = board,
|
taskId = taskId,
|
||||||
body = ReclaimTaskBody(reason = reason),
|
board = board,
|
||||||
)
|
body = ReclaimTaskBody(reason = reason),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (result is NetworkResult.Success) {
|
||||||
|
nudger.scheduleNudge(board)
|
||||||
}
|
}
|
||||||
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTaskLog(
|
override suspend fun getTaskLog(
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
object KanbanColumnCollapseHelper {
|
||||||
|
fun isColumnCollapsed(
|
||||||
|
columnName: String,
|
||||||
|
columnTaskCount: Int,
|
||||||
|
boardHasWork: Boolean,
|
||||||
|
overrides: Map<String, Boolean>,
|
||||||
|
): Boolean {
|
||||||
|
overrides[columnName]?.let { return it }
|
||||||
|
if (!boardHasWork) return false
|
||||||
|
return columnTaskCount == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanDetailEvent
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
|
||||||
|
enum class KanbanEventMessage {
|
||||||
|
CREATED,
|
||||||
|
CREATED_BY,
|
||||||
|
MOVED,
|
||||||
|
ASSIGNED,
|
||||||
|
UNASSIGNED,
|
||||||
|
COMMENTED,
|
||||||
|
CLAIMED_REVIEW,
|
||||||
|
CLAIMED_WORKER,
|
||||||
|
WORKER_STARTED,
|
||||||
|
COMPLETED,
|
||||||
|
BLOCKED,
|
||||||
|
UNBLOCKED,
|
||||||
|
RECLAIMED,
|
||||||
|
SPECIFIED,
|
||||||
|
PROMOTED,
|
||||||
|
SCHEDULED,
|
||||||
|
ARCHIVED,
|
||||||
|
PRIORITY,
|
||||||
|
UNKNOWN,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class FormattedKanbanEvent(
|
||||||
|
val label: String,
|
||||||
|
val detail: String? = null,
|
||||||
|
val message: KanbanEventMessage = KanbanEventMessage.UNKNOWN,
|
||||||
|
val arguments: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
object KanbanEventFormatter {
|
||||||
|
private val json =
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun format(event: KanbanDetailEvent): FormattedKanbanEvent {
|
||||||
|
val payloadMap = extractPayloadMap(event.payload)
|
||||||
|
|
||||||
|
fun str(key: String): String? {
|
||||||
|
val element = payloadMap[key] ?: return null
|
||||||
|
return when {
|
||||||
|
element is JsonPrimitive && element.isString -> element.content.takeIf { it.isNotBlank() }
|
||||||
|
element is JsonPrimitive -> element.content.takeIf { it.isNotBlank() }
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun col(key: String): String? {
|
||||||
|
val raw = str(key) ?: return null
|
||||||
|
return when (raw.lowercase()) {
|
||||||
|
"triage" -> "Triage"
|
||||||
|
"todo" -> "Todo"
|
||||||
|
"scheduled" -> "Scheduled"
|
||||||
|
"ready" -> "Ready"
|
||||||
|
"running" -> "Running"
|
||||||
|
"blocked" -> "Blocked"
|
||||||
|
"review" -> "Review"
|
||||||
|
"done" -> "Done"
|
||||||
|
"archived" -> "Archived"
|
||||||
|
else -> raw.replaceFirstChar { it.uppercase() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return when (event.kind.lowercase()) {
|
||||||
|
"created" -> {
|
||||||
|
val status = col("status") ?: "Todo"
|
||||||
|
val assignee = str("assignee")
|
||||||
|
val label = if (assignee != null) "Created in $status by $assignee" else "Created in $status"
|
||||||
|
FormattedKanbanEvent(
|
||||||
|
label = label,
|
||||||
|
message = if (assignee != null) KanbanEventMessage.CREATED_BY else KanbanEventMessage.CREATED,
|
||||||
|
arguments = listOfNotNull(status, assignee),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"status" -> {
|
||||||
|
val targetStatus = col("status") ?: "?"
|
||||||
|
val reason = str("reason")
|
||||||
|
val parent = str("parent")
|
||||||
|
val detail =
|
||||||
|
when (reason) {
|
||||||
|
"parent_reopened" -> if (parent != null) "Parent $parent reopened" else "Parent reopened"
|
||||||
|
else -> reason
|
||||||
|
}
|
||||||
|
FormattedKanbanEvent(
|
||||||
|
label = "Moved to $targetStatus",
|
||||||
|
detail = detail,
|
||||||
|
message = KanbanEventMessage.MOVED,
|
||||||
|
arguments = listOf(targetStatus),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"assigned" -> {
|
||||||
|
val assignee = str("assignee")
|
||||||
|
FormattedKanbanEvent(
|
||||||
|
label = if (assignee != null) "Assigned to $assignee" else "Unassigned",
|
||||||
|
message = if (assignee != null) KanbanEventMessage.ASSIGNED else KanbanEventMessage.UNASSIGNED,
|
||||||
|
arguments = listOfNotNull(assignee),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"commented" -> {
|
||||||
|
val author = str("author") ?: "someone"
|
||||||
|
FormattedKanbanEvent(
|
||||||
|
label = "Comment by $author",
|
||||||
|
message = KanbanEventMessage.COMMENTED,
|
||||||
|
arguments = listOf(author),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"claimed" -> {
|
||||||
|
val source = str("source_status")
|
||||||
|
FormattedKanbanEvent(
|
||||||
|
label = if (source == "review") "Claimed for review" else "Claimed by worker",
|
||||||
|
message =
|
||||||
|
if (source ==
|
||||||
|
"review"
|
||||||
|
) {
|
||||||
|
KanbanEventMessage.CLAIMED_REVIEW
|
||||||
|
} else {
|
||||||
|
KanbanEventMessage.CLAIMED_WORKER
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"spawned" -> {
|
||||||
|
val pid = str("pid") ?: payloadMap["pid"]?.toString()
|
||||||
|
FormattedKanbanEvent(
|
||||||
|
label = "Worker started",
|
||||||
|
detail = if (pid != null && pid != "null") "PID $pid" else null,
|
||||||
|
message = KanbanEventMessage.WORKER_STARTED,
|
||||||
|
arguments = listOfNotNull(pid?.takeIf { it != "null" }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"completed" -> {
|
||||||
|
FormattedKanbanEvent(label = "Completed", message = KanbanEventMessage.COMPLETED)
|
||||||
|
}
|
||||||
|
|
||||||
|
"blocked" -> {
|
||||||
|
val reason = str("reason")
|
||||||
|
FormattedKanbanEvent(label = "Blocked", detail = reason, message = KanbanEventMessage.BLOCKED)
|
||||||
|
}
|
||||||
|
|
||||||
|
"unblocked" -> {
|
||||||
|
val status = col("status")
|
||||||
|
val label = if (status != null) "Unblocked ($status)" else "Unblocked"
|
||||||
|
FormattedKanbanEvent(
|
||||||
|
label = label,
|
||||||
|
message = KanbanEventMessage.UNBLOCKED,
|
||||||
|
arguments = listOfNotNull(status),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
"reclaimed" -> {
|
||||||
|
val reason = str("reason")
|
||||||
|
FormattedKanbanEvent(label = "Reclaimed", detail = reason, message = KanbanEventMessage.RECLAIMED)
|
||||||
|
}
|
||||||
|
|
||||||
|
"specified" -> {
|
||||||
|
FormattedKanbanEvent(label = "Specified", message = KanbanEventMessage.SPECIFIED)
|
||||||
|
}
|
||||||
|
|
||||||
|
"promoted" -> {
|
||||||
|
FormattedKanbanEvent(label = "Promoted to ready", message = KanbanEventMessage.PROMOTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
"scheduled" -> {
|
||||||
|
val reason = str("reason")
|
||||||
|
FormattedKanbanEvent(label = "Scheduled", detail = reason, message = KanbanEventMessage.SCHEDULED)
|
||||||
|
}
|
||||||
|
|
||||||
|
"archived" -> {
|
||||||
|
FormattedKanbanEvent(label = "Archived", message = KanbanEventMessage.ARCHIVED)
|
||||||
|
}
|
||||||
|
|
||||||
|
"reprioritized" -> {
|
||||||
|
val priority = str("priority") ?: payloadMap["priority"]?.toString() ?: "?"
|
||||||
|
FormattedKanbanEvent(
|
||||||
|
label = "Priority set to $priority",
|
||||||
|
message = KanbanEventMessage.PRIORITY,
|
||||||
|
arguments = listOf(priority),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
val label = event.kind.replace('_', ' ').replaceFirstChar { it.uppercase() }
|
||||||
|
val detail =
|
||||||
|
payloadMap.entries
|
||||||
|
.filter { (_, v) -> v is JsonPrimitive }
|
||||||
|
.joinToString(" ") { (k, v) -> "$k=${(v as JsonPrimitive).content}" }
|
||||||
|
.ifBlank { null }
|
||||||
|
FormattedKanbanEvent(label = label, detail = detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractPayloadMap(element: JsonElement?): Map<String, JsonElement> {
|
||||||
|
if (element == null) return emptyMap()
|
||||||
|
if (element is JsonObject) return element
|
||||||
|
if (element is JsonPrimitive && element.isString) {
|
||||||
|
val content = element.content.trim()
|
||||||
|
if (content.startsWith("{") && content.endsWith("}")) {
|
||||||
|
runCatching {
|
||||||
|
val parsed = json.parseToJsonElement(content)
|
||||||
|
if (parsed is JsonObject) return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (content.isNotEmpty()) mapOf("raw" to element) else emptyMap()
|
||||||
|
}
|
||||||
|
return emptyMap()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.MutableState
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
|
||||||
|
/** Keep the cache path while the external document picker recreates the Activity (#1137). */
|
||||||
|
@Composable
|
||||||
|
internal fun rememberPendingKanbanExportPath(): MutableState<String?> = rememberSaveable { mutableStateOf(null) }
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.NonCancellable
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeout
|
||||||
|
|
||||||
|
/** Own only confirmed uploads; cancellation must not strand their temporary archive (#1137). */
|
||||||
|
internal suspend fun <T> withKanbanImportCleanup(
|
||||||
|
cleanup: suspend () -> Unit,
|
||||||
|
onCleanupFailure: (Exception) -> Unit,
|
||||||
|
dispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||||
|
operation: suspend () -> T,
|
||||||
|
): T =
|
||||||
|
try {
|
||||||
|
operation()
|
||||||
|
} finally {
|
||||||
|
withContext(NonCancellable + dispatcher) {
|
||||||
|
try {
|
||||||
|
withTimeout(10_000L) { cleanup() }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
onCleanupFailure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
|
|
||||||
|
data class KanbanRunningGroup(
|
||||||
|
val assignee: String?,
|
||||||
|
val tasks: List<KanbanTask>,
|
||||||
|
)
|
||||||
|
|
||||||
|
object KanbanLaneGrouping {
|
||||||
|
fun groupRunningTasks(tasks: List<KanbanTask>): List<KanbanRunningGroup> {
|
||||||
|
val grouped = tasks.groupBy { it.assignee?.takeIf { a -> a.isNotBlank() } }
|
||||||
|
val namedGroups =
|
||||||
|
grouped
|
||||||
|
.filterKeys { it != null }
|
||||||
|
.entries
|
||||||
|
.sortedBy { it.key!!.lowercase() }
|
||||||
|
.map { KanbanRunningGroup(assignee = it.key, tasks = it.value) }
|
||||||
|
val unassignedTasks = grouped[null]
|
||||||
|
return if (!unassignedTasks.isNullOrEmpty()) {
|
||||||
|
namedGroups + KanbanRunningGroup(assignee = null, tasks = unassignedTasks)
|
||||||
|
} else {
|
||||||
|
namedGroups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.UpdateTaskBody
|
||||||
|
|
||||||
|
private val ALL_REASONING_EFFORTS = listOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
||||||
|
|
||||||
|
fun supportedKanbanReasoningEfforts(
|
||||||
|
supportsReasoning: Boolean,
|
||||||
|
canDisableReasoning: Boolean,
|
||||||
|
): List<String> =
|
||||||
|
if (!supportsReasoning) {
|
||||||
|
listOf("")
|
||||||
|
} else if (canDisableReasoning) {
|
||||||
|
ALL_REASONING_EFFORTS
|
||||||
|
} else {
|
||||||
|
ALL_REASONING_EFFORTS.filterNot { it == "none" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task model override matching desktop's TaskModelOverride contract:
|
||||||
|
* - model: model name or ID
|
||||||
|
* - provider: provider name or ID
|
||||||
|
* - effort: "" (inherit), "none" (explicitly disabled), or reasoning effort string ("low", "medium", "high")
|
||||||
|
*/
|
||||||
|
data class KanbanModelOverride(
|
||||||
|
val model: String = "",
|
||||||
|
val provider: String = "",
|
||||||
|
val effort: String = "",
|
||||||
|
) {
|
||||||
|
val isInherited: Boolean
|
||||||
|
get() = model.isBlank() && provider.isBlank() && effort.isBlank()
|
||||||
|
|
||||||
|
fun displayLabel(inheritLabel: String = "Inherit profile"): String {
|
||||||
|
if (isInherited) return inheritLabel
|
||||||
|
val trimmedModel = model.trim()
|
||||||
|
val trimmedProvider = provider.trim()
|
||||||
|
val trimmedEffort = effort.trim()
|
||||||
|
|
||||||
|
val base =
|
||||||
|
when {
|
||||||
|
trimmedModel.isNotEmpty() && trimmedProvider.isNotEmpty() -> "$trimmedProvider: $trimmedModel"
|
||||||
|
trimmedModel.isNotEmpty() -> trimmedModel
|
||||||
|
else -> inheritLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
val effortPart =
|
||||||
|
when (trimmedEffort.lowercase()) {
|
||||||
|
"none" -> "None"
|
||||||
|
"low" -> "Low"
|
||||||
|
"medium" -> "Medium"
|
||||||
|
"high" -> "High"
|
||||||
|
"" -> ""
|
||||||
|
else -> trimmedEffort.replaceFirstChar { it.uppercase() }
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (effortPart.isNotEmpty() && base != inheritLabel) {
|
||||||
|
"$base · $effortPart"
|
||||||
|
} else if (effortPart.isNotEmpty()) {
|
||||||
|
"Effort: $effortPart"
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toUpdateTaskBody(): UpdateTaskBody {
|
||||||
|
val trimmedModel = model.trim()
|
||||||
|
val trimmedProvider = provider.trim()
|
||||||
|
val trimmedEffort = effort.trim()
|
||||||
|
|
||||||
|
return UpdateTaskBody(
|
||||||
|
modelOverride = trimmedModel.ifEmpty { null },
|
||||||
|
providerOverride = if (trimmedModel.isNotEmpty() && trimmedProvider.isNotEmpty()) trimmedProvider else null,
|
||||||
|
clearModelOverride = trimmedModel.isEmpty(),
|
||||||
|
reasoningEffort = trimmedEffort.ifEmpty { null },
|
||||||
|
clearReasoningEffort = trimmedEffort.isEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val EMPTY = KanbanModelOverride()
|
||||||
|
|
||||||
|
fun fromTask(
|
||||||
|
model: String?,
|
||||||
|
provider: String?,
|
||||||
|
effort: String?,
|
||||||
|
): KanbanModelOverride =
|
||||||
|
KanbanModelOverride(
|
||||||
|
model = model?.trim().orEmpty(),
|
||||||
|
provider = provider?.trim().orEmpty(),
|
||||||
|
effort = effort?.trim().orEmpty(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.lifecycle.repeatOnLifecycle
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
|
||||||
|
enum class KanbanArcState {
|
||||||
|
QUEUED,
|
||||||
|
RUNNING,
|
||||||
|
STALE,
|
||||||
|
}
|
||||||
|
|
||||||
|
object KanbanRuntimeHelper {
|
||||||
|
fun arcState(
|
||||||
|
task: KanbanTask,
|
||||||
|
fallbackAssignee: String? = null,
|
||||||
|
nowSeconds: Long = System.currentTimeMillis() / 1000L,
|
||||||
|
): KanbanArcState? {
|
||||||
|
val normalized = task.status.lowercase()
|
||||||
|
if (normalized == "running") {
|
||||||
|
val isStale = task.lastHeartbeatAt?.let { (nowSeconds - it) > 120L } ?: false
|
||||||
|
return if (isStale) KanbanArcState.STALE else KanbanArcState.RUNNING
|
||||||
|
}
|
||||||
|
val isQueued =
|
||||||
|
normalized == "triage" ||
|
||||||
|
normalized == "review" ||
|
||||||
|
(normalized == "ready" && (!task.assignee.isNullOrBlank() || !fallbackAssignee.isNullOrBlank()))
|
||||||
|
return if (isQueued) KanbanArcState.QUEUED else null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isWontRun(
|
||||||
|
task: KanbanTask,
|
||||||
|
fallbackAssignee: String? = null,
|
||||||
|
): Boolean =
|
||||||
|
task.status.equals("ready", ignoreCase = true) &&
|
||||||
|
task.assignee.isNullOrBlank() &&
|
||||||
|
fallbackAssignee.isNullOrBlank()
|
||||||
|
|
||||||
|
fun formatElapsed(
|
||||||
|
startSeconds: Long?,
|
||||||
|
nowSeconds: Long = System.currentTimeMillis() / 1000L,
|
||||||
|
): String? {
|
||||||
|
if (startSeconds == null || startSeconds <= 0L || nowSeconds < startSeconds) return null
|
||||||
|
val diff = nowSeconds - startSeconds
|
||||||
|
return when {
|
||||||
|
diff < 60L -> "${diff}s"
|
||||||
|
diff < 3600L -> "${diff / 60L}m"
|
||||||
|
diff < 86400L -> "${diff / 3600L}h"
|
||||||
|
else -> "${diff / 86400L}d"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One coarse clock per board screen, rather than one coroutine per task card. */
|
||||||
|
@Composable
|
||||||
|
fun rememberKanbanNowSeconds(
|
||||||
|
enabled: Boolean,
|
||||||
|
clock: () -> Long = { System.currentTimeMillis() / 1000L },
|
||||||
|
): Long {
|
||||||
|
val lifecycle = LocalLifecycleOwner.current.lifecycle
|
||||||
|
return produceState(
|
||||||
|
initialValue = clock(),
|
||||||
|
key1 = enabled,
|
||||||
|
key2 = lifecycle,
|
||||||
|
) {
|
||||||
|
if (enabled) {
|
||||||
|
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||||
|
while (isActive) {
|
||||||
|
value = clock()
|
||||||
|
delay(5_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.value
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared constraints for Kanban target columns.
|
||||||
|
* Mirrors desktop's LOCKED_COLUMNS: system-owned columns that users can drag/move
|
||||||
|
* cards OUT of, but never manually target for moves or creation.
|
||||||
|
*/
|
||||||
|
object KanbanStatusConstraints {
|
||||||
|
val LOCKED_COLUMNS = setOf("running", "review", "scheduled")
|
||||||
|
|
||||||
|
fun isLockedTarget(status: String): Boolean = status.lowercase() in LOCKED_COLUMNS
|
||||||
|
|
||||||
|
fun isUserWritableTarget(status: String): Boolean =
|
||||||
|
!isLockedTarget(status) && !status.equals("archived", ignoreCase = true)
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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,84 @@ 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() },
|
||||||
|
private val enableFallbackPolling: Boolean = true,
|
||||||
) : 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
|
||||||
|
private var detailPollJob: Job? = null
|
||||||
|
|
||||||
fun loadTask(
|
fun loadTask(
|
||||||
board: String,
|
board: String,
|
||||||
taskId: String,
|
taskId: String,
|
||||||
) {
|
) {
|
||||||
_uiState.update { it.copy(isLoading = true, errorMessage = null) }
|
val gen = ++currentLoadGen
|
||||||
|
reloadJob?.cancel()
|
||||||
|
stopLogPolling()
|
||||||
|
detailPollJob?.cancel()
|
||||||
|
activeTaskId = taskId
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
isLoading = true,
|
||||||
|
detail = null,
|
||||||
|
workerLog = null,
|
||||||
|
estimate = null,
|
||||||
|
errorMessage = null,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
loadProfiles()
|
||||||
|
loadModelOptions()
|
||||||
|
connectEvents(board)
|
||||||
|
startDetailPolling(board, taskId)
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (val result = repository.getTask(taskId = taskId, board = board)) {
|
when (val result = repository.getTask(taskId = taskId, board = board)) {
|
||||||
is NetworkResult.Success -> {
|
is NetworkResult.Success -> {
|
||||||
|
if (gen != currentLoadGen) return@launch
|
||||||
|
val taskData = result.data
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
detail = result.data,
|
detail = taskData,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (result.data.task.status
|
if (taskData.task.status.equals("running", ignoreCase = true)) {
|
||||||
.equals("running", ignoreCase = true)
|
startLogPolling(board, taskId)
|
||||||
) {
|
} else {
|
||||||
|
stopLogPolling()
|
||||||
loadLog(board, taskId)
|
loadLog(board, taskId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
is NetworkResult.Failure -> {
|
||||||
|
if (gen != currentLoadGen) return@launch
|
||||||
|
stopLogPolling()
|
||||||
_uiState.update {
|
_uiState.update {
|
||||||
it.copy(
|
it.copy(
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
@@ -72,19 +122,147 @@ class KanbanTaskViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun loadProfiles() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.getProfiles()) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update { it.copy(profiles = result.data.profiles) }
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
// Failures to read profiles don't block detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadModelOptions() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val res = safeApiCall { ApiClient.hermesApi.getModelOptions() }) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
modelProviders = res.data.providers,
|
||||||
|
pinnedModels = AuthManager.getPinnedModels(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
// Failures to read models don't block detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun loadLog(
|
fun loadLog(
|
||||||
board: String,
|
board: String,
|
||||||
taskId: String,
|
taskId: String,
|
||||||
) {
|
) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
when (val result = repository.getTaskLog(taskId = taskId, board = board)) {
|
fetchLogInternal(board, taskId)
|
||||||
is NetworkResult.Success -> {
|
}
|
||||||
_uiState.update { it.copy(workerLog = result.data) }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
is NetworkResult.Failure -> {
|
private suspend fun fetchLogInternal(
|
||||||
// Failures to read log don't blank the task screen
|
board: String,
|
||||||
|
taskId: String,
|
||||||
|
) {
|
||||||
|
when (val result = repository.getTaskLog(taskId = taskId, board = board)) {
|
||||||
|
is NetworkResult.Success -> {
|
||||||
|
_uiState.update { it.copy(workerLog = result.data) }
|
||||||
|
}
|
||||||
|
|
||||||
|
is NetworkResult.Failure -> {
|
||||||
|
// Failures to read log don't blank the task screen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startLogPolling(
|
||||||
|
board: String,
|
||||||
|
taskId: String,
|
||||||
|
) {
|
||||||
|
logPollJob?.cancel()
|
||||||
|
logPollJob =
|
||||||
|
viewModelScope.launch {
|
||||||
|
fetchLogInternal(board, taskId)
|
||||||
|
val isRunning = {
|
||||||
|
_uiState.value.detail
|
||||||
|
?.task
|
||||||
|
?.status
|
||||||
|
?.equals("running", ignoreCase = true) == true
|
||||||
}
|
}
|
||||||
|
while (isActive && isRunning()) {
|
||||||
|
delay(POLL_INTERVAL_MS)
|
||||||
|
fetchLogInternal(board, taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopLogPolling() {
|
||||||
|
logPollJob?.cancel()
|
||||||
|
logPollJob = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startDetailPolling(
|
||||||
|
board: String,
|
||||||
|
taskId: String,
|
||||||
|
) {
|
||||||
|
if (!enableFallbackPolling) return
|
||||||
|
detailPollJob =
|
||||||
|
viewModelScope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
delay(DETAIL_POLL_INTERVAL_MS)
|
||||||
|
loadTaskSilently(board, taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectEvents(board: String) {
|
||||||
|
if (eventsBoard == board) return
|
||||||
|
eventsClient?.disconnect()
|
||||||
|
eventsBoard = board
|
||||||
|
val client = eventsClient ?: eventsClientProvider().also { eventsClient = it }
|
||||||
|
client.connect(
|
||||||
|
scope = viewModelScope,
|
||||||
|
board = board,
|
||||||
|
onEvents = { envelope ->
|
||||||
|
val current = activeTaskId
|
||||||
|
if (current != null && envelope.events.any { it.taskId == current }) {
|
||||||
|
scheduleTaskReload(board, current)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onStatus = {},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scheduleTaskReload(
|
||||||
|
board: String,
|
||||||
|
taskId: String,
|
||||||
|
) {
|
||||||
|
reloadJob?.cancel()
|
||||||
|
reloadJob =
|
||||||
|
viewModelScope.launch {
|
||||||
|
delay(RELOAD_DEBOUNCE_MS)
|
||||||
|
loadTaskSilently(board, taskId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun loadTaskSilently(
|
||||||
|
board: String,
|
||||||
|
taskId: String,
|
||||||
|
) {
|
||||||
|
val gen = currentLoadGen
|
||||||
|
val result = repository.getTask(taskId = taskId, board = board)
|
||||||
|
if (gen != currentLoadGen || taskId != activeTaskId) return
|
||||||
|
if (result is NetworkResult.Success) {
|
||||||
|
val taskData = result.data
|
||||||
|
_uiState.update { it.copy(detail = taskData) }
|
||||||
|
if (taskData.task.status.equals("running", ignoreCase = true)) {
|
||||||
|
if (logPollJob == null) startLogPolling(board, taskId)
|
||||||
|
} else {
|
||||||
|
stopLogPolling()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,6 +306,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 +521,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 +563,21 @@ class KanbanTaskViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public override fun onCleared() {
|
||||||
|
stopLogPolling()
|
||||||
|
detailPollJob?.cancel()
|
||||||
|
reloadJob?.cancel()
|
||||||
|
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
|
||||||
|
const val DETAIL_POLL_INTERVAL_MS = 30_000L
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,14 +170,18 @@ 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 = false)) {
|
||||||
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) }
|
||||||
val targetBoard =
|
val targetBoard =
|
||||||
boards.find { it.id == previouslySelectedId }
|
boards.find { it.id == previouslySelectedId }
|
||||||
?: boards.find { it.id == result.data.current }
|
?: boards.find { it.id == result.data.current }
|
||||||
|
?: boards.find { it.id.equals("default", ignoreCase = true) }
|
||||||
?: boards.firstOrNull()
|
?: boards.firstOrNull()
|
||||||
if (targetBoard != null) {
|
if (targetBoard != null) {
|
||||||
selectBoard(targetBoard)
|
selectBoard(targetBoard)
|
||||||
@@ -186,6 +214,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 +262,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 +433,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 +461,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 +470,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 +491,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 +508,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 +516,25 @@ 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") }
|
val endpoint = endpointProvider()
|
||||||
|
preferences.clearSelectedBoard(endpoint)
|
||||||
|
_uiState.update { it.copy(selectedBoard = null, toastMessage = "Board archived") }
|
||||||
loadBoards()
|
loadBoards()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,7 +542,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 +575,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 +604,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 +641,7 @@ class KanbanViewModel(
|
|||||||
toastMessage = "Bulk archive failed: ${res.error.message}",
|
toastMessage = "Bulk archive failed: ${res.error.message}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
onComplete?.invoke(taskIds.toSet())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,15 +650,33 @@ 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))) {
|
val body =
|
||||||
|
BulkTasksBody(
|
||||||
|
ids = taskIds,
|
||||||
|
assignee = assignee ?: "",
|
||||||
|
reclaimFirst = true,
|
||||||
|
)
|
||||||
|
when (val res = repository.bulkTasks(board.id, body)) {
|
||||||
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.isNullOrBlank()) "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 +686,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 +884,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 +910,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 +948,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(stringResource(R.string.kanban_board_name)) },
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
singleLine = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = description,
|
||||||
|
onValueChange = { description = it },
|
||||||
|
label = { Text(stringResource(R.string.kanban_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(stringResource(R.string.kanban_default_workdir)) },
|
||||||
|
placeholder = { Text(stringResource(R.string.kanban_workdir_example)) },
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
+181
-125
@@ -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
|
||||||
@@ -42,6 +43,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||||||
import androidx.compose.runtime.setValue
|
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.testTag
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
@@ -49,8 +51,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 +72,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.orEmpty())
|
||||||
|
}
|
||||||
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 +97,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 +146,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 +168,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 +188,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 +218,18 @@ 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) {
|
||||||
|
stringResource(R.string.kanban_assignee_default_suffix, profile.name)
|
||||||
|
} else {
|
||||||
|
profile.name
|
||||||
|
},
|
||||||
|
)
|
||||||
if (profile.model != null) {
|
if (profile.model != null) {
|
||||||
Text(
|
Text(
|
||||||
profile.model,
|
profile.model,
|
||||||
@@ -258,11 +289,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 +298,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 +312,43 @@ 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 }
|
||||||
|
.testTag("kanban_advanced_options")
|
||||||
|
.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 +377,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 +394,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),
|
||||||
@@ -417,22 +414,46 @@ fun KanbanCreateTaskDialog(
|
|||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.testTag("kanban_workspace_kinds"),
|
||||||
) {
|
) {
|
||||||
listOf(
|
listOf(
|
||||||
null to "Scratch (Default)",
|
"scratch" to stringResource(R.string.kanban_workspace_scratch),
|
||||||
"worktree" to "Worktree",
|
"worktree" to stringResource(R.string.kanban_workspace_worktree),
|
||||||
"dir" to "Dir",
|
"dir" to stringResource(R.string.kanban_workspace_dir),
|
||||||
).forEach { (kind, label) ->
|
).forEach { (kind, label) ->
|
||||||
FilterChip(
|
FilterChip(
|
||||||
selected = workspaceKind == kind,
|
selected = workspaceKind == kind,
|
||||||
onClick = { workspaceKind = kind },
|
onClick = { workspaceKind = kind },
|
||||||
label = { Text(label) },
|
label = { Text(label) },
|
||||||
enabled = !isCreating,
|
enabled = !isCreating,
|
||||||
|
modifier = Modifier.testTag("kanban_workspace_kind_$kind"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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().testTag("kanban_workspace_path"),
|
||||||
|
singleLine = true,
|
||||||
|
enabled = !isCreating,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
// Skills
|
// Skills
|
||||||
@@ -440,7 +461,7 @@ fun KanbanCreateTaskDialog(
|
|||||||
value = skillsText,
|
value = skillsText,
|
||||||
onValueChange = { skillsText = it },
|
onValueChange = { skillsText = it },
|
||||||
label = { Text(stringResource(R.string.kanban_skills)) },
|
label = { Text(stringResource(R.string.kanban_skills)) },
|
||||||
placeholder = { Text("e.g. git, coding, tests") },
|
placeholder = { Text(stringResource(R.string.kanban_skills_placeholder)) },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
enabled = !isCreating,
|
enabled = !isCreating,
|
||||||
@@ -448,40 +469,57 @@ fun KanbanCreateTaskDialog(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Estimation Section
|
||||||
if (onEstimate != null) {
|
if (onEstimate != null) {
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Row(
|
if (estimate != null) {
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
estimate?.let { est ->
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 4.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Estimated Tokens: ${est.tokens ?: "N/A"}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
if (!est.complexity.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = "Complexity: ${est.complexity}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!est.rationale.isNullOrBlank()) {
|
||||||
|
Text(
|
||||||
|
text = est.rationale,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = {
|
||||||
|
if (title.isNotBlank() && !isEstimating) {
|
||||||
|
isEstimating = true
|
||||||
|
coroutineScope.launch {
|
||||||
|
val res = onEstimate(title.trim(), desc.trim().ifBlank { null })
|
||||||
|
estimate = res
|
||||||
|
isEstimating = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = title.isNotBlank() && !isEstimating && !isCreating,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
if (estimate != null && estimate?.ok == true) {
|
if (isEstimating) {
|
||||||
Text(
|
CircularProgressIndicator(modifier = Modifier.size(12.dp), strokeWidth = 1.5.dp)
|
||||||
text = "~${estimate?.estTokens ?: 0} tokens · ${estimate?.complexity ?: "normal"}",
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
TextButton(
|
|
||||||
onClick = {
|
|
||||||
if (title.isNotBlank() && !isEstimating) {
|
|
||||||
coroutineScope.launch {
|
|
||||||
isEstimating = true
|
|
||||||
estimate = onEstimate(title, desc.ifBlank { null })
|
|
||||||
isEstimating = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = title.isNotBlank() && !isEstimating && !isCreating,
|
|
||||||
) {
|
|
||||||
if (isEstimating) {
|
|
||||||
CircularProgressIndicator(modifier = Modifier.size(12.dp), strokeWidth = 1.5.dp)
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
}
|
|
||||||
Text(if (estimate != null) "Re-estimate" else "Estimate tokens")
|
|
||||||
}
|
}
|
||||||
|
Text(if (estimate != null) "Re-estimate" else "Estimate tokens")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -497,6 +535,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 +560,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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
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.testTag
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
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 com.m57.hermescontrol.R
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -60,7 +63,7 @@ fun KanbanFilterSheet(
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
)
|
)
|
||||||
TextButton(onClick = onClearFilters) {
|
TextButton(onClick = onClearFilters) {
|
||||||
Text("Clear all")
|
Text(stringResource(R.string.kanban_clear_all))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,10 +78,11 @@ fun KanbanFilterSheet(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
Text("Show archived tasks", style = MaterialTheme.typography.bodyMedium)
|
Text(stringResource(R.string.kanban_show_archived), style = MaterialTheme.typography.bodyMedium)
|
||||||
Switch(
|
Switch(
|
||||||
checked = includeArchived,
|
checked = includeArchived,
|
||||||
onCheckedChange = onToggleIncludeArchived,
|
onCheckedChange = onToggleIncludeArchived,
|
||||||
|
modifier = Modifier.testTag("kanban_show_archived"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,10 +95,11 @@ fun KanbanFilterSheet(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 4.dp),
|
.padding(vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
Text("Group running lane by profile", style = MaterialTheme.typography.bodyMedium)
|
Text(stringResource(R.string.kanban_group_running), style = MaterialTheme.typography.bodyMedium)
|
||||||
Switch(
|
Switch(
|
||||||
checked = groupRunning,
|
checked = groupRunning,
|
||||||
onCheckedChange = onToggleGroupRunning,
|
onCheckedChange = onToggleGroupRunning,
|
||||||
|
modifier = Modifier.testTag("kanban_group_running"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,7 +107,7 @@ fun KanbanFilterSheet(
|
|||||||
|
|
||||||
// Assignee Filter
|
// Assignee Filter
|
||||||
Text(
|
Text(
|
||||||
text = "Assignee",
|
text = stringResource(R.string.kanban_assignee),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
@@ -120,7 +125,7 @@ fun KanbanFilterSheet(
|
|||||||
selected = selectedAssignee == null,
|
selected = selectedAssignee == null,
|
||||||
onClick = { onSelectAssignee(null) },
|
onClick = { onSelectAssignee(null) },
|
||||||
)
|
)
|
||||||
Text("All assignees", style = MaterialTheme.typography.bodyMedium)
|
Text(stringResource(R.string.kanban_all_assignees), style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
|
|
||||||
assignees.forEach { assignee ->
|
assignees.forEach { assignee ->
|
||||||
@@ -144,7 +149,7 @@ fun KanbanFilterSheet(
|
|||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Tenant",
|
text = stringResource(R.string.kanban_tenant),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
@@ -162,7 +167,7 @@ fun KanbanFilterSheet(
|
|||||||
selected = selectedTenant == null,
|
selected = selectedTenant == null,
|
||||||
onClick = { onSelectTenant(null) },
|
onClick = { onSelectTenant(null) },
|
||||||
)
|
)
|
||||||
Text("All tenants", style = MaterialTheme.typography.bodyMedium)
|
Text(stringResource(R.string.kanban_all_tenants), style = MaterialTheme.typography.bodyMedium)
|
||||||
}
|
}
|
||||||
|
|
||||||
tenants.forEach { tenant ->
|
tenants.forEach { tenant ->
|
||||||
|
|||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
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.kanban.supportedKanbanReasoningEfforts
|
||||||
|
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))
|
||||||
|
val selectedCapabilities =
|
||||||
|
modelProviders
|
||||||
|
.firstOrNull { it.slug == override.provider }
|
||||||
|
?.capabilities
|
||||||
|
?.get(override.model)
|
||||||
|
val reasoningSupported = selectedCapabilities?.reasoning != false
|
||||||
|
val canDisableReasoning = selectedCapabilities?.can_disable_reasoning != false
|
||||||
|
val supportedEfforts = supportedKanbanReasoningEfforts(reasoningSupported, canDisableReasoning)
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
supportedEfforts
|
||||||
|
.map { effortVal ->
|
||||||
|
effortVal to
|
||||||
|
when (effortVal) {
|
||||||
|
"" -> stringResource(R.string.kanban_effort_inherit)
|
||||||
|
"none" -> stringResource(R.string.kanban_effort_none)
|
||||||
|
"minimal" -> stringResource(R.string.kanban_effort_minimal)
|
||||||
|
"low" -> stringResource(R.string.kanban_effort_low)
|
||||||
|
"medium" -> stringResource(R.string.kanban_effort_medium)
|
||||||
|
"high" -> stringResource(R.string.kanban_effort_high)
|
||||||
|
"xhigh" -> stringResource(R.string.kanban_effort_xhigh)
|
||||||
|
"max" -> stringResource(R.string.kanban_effort_max)
|
||||||
|
"ultra" -> stringResource(R.string.kanban_effort_ultra)
|
||||||
|
else -> effortVal
|
||||||
|
}
|
||||||
|
}.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 ->
|
||||||
|
val capabilities = modelProviders.firstOrNull { it.slug == provider }?.capabilities?.get(model)
|
||||||
|
onOverrideChange(
|
||||||
|
override.copy(
|
||||||
|
provider = provider,
|
||||||
|
model = model,
|
||||||
|
effort = if (capabilities?.reasoning == false) "" else override.effort,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
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(stringResource(R.string.kanban_profile_description_hint)) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,12 +31,16 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
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.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.m57.hermescontrol.R
|
||||||
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 +48,8 @@ fun KanbanTaskCard(
|
|||||||
onTaskClick: (KanbanTask) -> Unit,
|
onTaskClick: (KanbanTask) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isSelected: Boolean = false,
|
isSelected: Boolean = false,
|
||||||
|
defaultAssignee: String? = null,
|
||||||
|
nowSeconds: Long = System.currentTimeMillis() / 1000L,
|
||||||
onActionClick: ((KanbanTask) -> Unit)? = null,
|
onActionClick: ((KanbanTask) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val statusColors = LocalHermesStatusColors.current
|
val statusColors = LocalHermesStatusColors.current
|
||||||
@@ -57,6 +63,10 @@ fun KanbanTaskCard(
|
|||||||
else -> MaterialTheme.colorScheme.outline
|
else -> MaterialTheme.colorScheme.outline
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val arcState = KanbanRuntimeHelper.arcState(task, defaultAssignee, nowSeconds)
|
||||||
|
val isWontRun = KanbanRuntimeHelper.isWontRun(task, defaultAssignee)
|
||||||
|
val elapsed = KanbanRuntimeHelper.formatElapsed(task.startedAt, nowSeconds)
|
||||||
|
|
||||||
val cardColors =
|
val cardColors =
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
CardDefaults.cardColors(
|
CardDefaults.cardColors(
|
||||||
@@ -92,6 +102,41 @@ fun KanbanTaskCard(
|
|||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = statusTone,
|
color = statusTone,
|
||||||
)
|
)
|
||||||
|
when {
|
||||||
|
arcState == KanbanArcState.RUNNING -> {
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text =
|
||||||
|
stringResource(
|
||||||
|
R.string.kanban_runtime_elapsed,
|
||||||
|
elapsed ?: stringResource(R.string.kanban_runtime_working),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = statusColors.success,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
arcState == KanbanArcState.STALE -> {
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.kanban_runtime_stale_badge),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = statusColors.warning,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
isWontRun -> {
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.kanban_runtime_wont_run_badge),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = statusColors.error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Text(
|
Text(
|
||||||
@@ -108,7 +153,7 @@ fun KanbanTaskCard(
|
|||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.MoreVert,
|
imageVector = Icons.Default.MoreVert,
|
||||||
contentDescription = "Task actions",
|
contentDescription = stringResource(R.string.kanban_task_actions),
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
@@ -145,7 +190,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),
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.m57.hermescontrol.util
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.net.Uri
|
||||||
|
import okhttp3.MediaType
|
||||||
|
import okhttp3.RequestBody
|
||||||
|
import okio.BufferedSink
|
||||||
|
import okio.source
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A streaming OkHttp [RequestBody] backed by an Android SAF [Uri] and [ContentResolver].
|
||||||
|
*
|
||||||
|
* Streams bytes directly from the open stream into OkHttp's [BufferedSink] without
|
||||||
|
* buffering the entire file into heap memory (avoiding OutOfMemoryError on large archives).
|
||||||
|
*/
|
||||||
|
class StreamingUriRequestBody(
|
||||||
|
private val contentResolver: ContentResolver,
|
||||||
|
private val uri: Uri,
|
||||||
|
private val contentType: MediaType?,
|
||||||
|
private val contentLength: Long = -1L,
|
||||||
|
) : RequestBody() {
|
||||||
|
override fun contentType(): MediaType? = contentType
|
||||||
|
|
||||||
|
override fun contentLength(): Long = contentLength
|
||||||
|
|
||||||
|
override fun writeTo(sink: BufferedSink) {
|
||||||
|
val inputStream =
|
||||||
|
contentResolver.openInputStream(uri)
|
||||||
|
?: throw IOException("Unable to open input stream for URI: $uri")
|
||||||
|
inputStream.use { stream ->
|
||||||
|
stream.source().use { source ->
|
||||||
|
sink.writeAll(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -318,7 +318,153 @@
|
|||||||
<string name="kanban_live">مباشر</string>
|
<string name="kanban_live">مباشر</string>
|
||||||
<string name="kanban_offline">غير متصل</string>
|
<string name="kanban_offline">غير متصل</string>
|
||||||
<string name="kanban_no_tasks">لا توجد مهام</string>
|
<string name="kanban_no_tasks">لا توجد مهام</string>
|
||||||
<string name="action_add">إضافة</string>
|
<string name="kanban_priority">الأولوية</string>
|
||||||
|
<string name="kanban_priority_normal">0 (عادي)</string>
|
||||||
|
<string name="kanban_priority_med">1 (متوسط)</string>
|
||||||
|
<string name="kanban_priority_high">2 (مرتفع)</string>
|
||||||
|
<string name="kanban_priority_urgent">3 (عاجل)</string>
|
||||||
|
<string name="kanban_assignee">المسؤول</string>
|
||||||
|
<string name="kanban_assignee_default_suffix">%1$s (الافتراضي)</string>
|
||||||
|
<string name="kanban_assignee_unassigned">افتراضي (غير معيَّن)</string>
|
||||||
|
<string name="kanban_assignee_parked">موقوف (بدون عامل)</string>
|
||||||
|
<string name="kanban_goal_mode">وضع الهدف الذاتي</string>
|
||||||
|
<string name="kanban_goal_mode_desc">يتحقق القاضي من اكتمال المهمة عبر تكرارات متعددة</string>
|
||||||
|
<string name="kanban_advanced_options">خيارات متقدمة</string>
|
||||||
|
<string name="kanban_parent_task">المهمة الأصلية</string>
|
||||||
|
<string name="kanban_parent_none">لا يوجد</string>
|
||||||
|
<string name="kanban_model_override">تجاوز النموذج</string>
|
||||||
|
<string name="kanban_provider_override">تجاوز المزود</string>
|
||||||
|
<string name="kanban_reasoning_effort">جهد الاستنتاج</string>
|
||||||
|
<string name="kanban_workspace_kind">نوع مساحة العمل</string>
|
||||||
|
<string name="kanban_workspace_path">تجاوز مسار مساحة العمل</string>
|
||||||
|
<string name="kanban_workspace_path_hint">مسار اختياري (الافتراضي مجلد المشروع)</string>
|
||||||
|
<string name="kanban_skills">المهارات</string>
|
||||||
|
<string name="kanban_target_column">العمود</string>
|
||||||
|
<string name="kanban_model_inherit">وراثة الملف الشخصي</string>
|
||||||
|
<string name="kanban_model_clear">مسح تجاوز النموذج</string>
|
||||||
|
<string name="kanban_select_model">اختيار النموذج</string>
|
||||||
|
<string name="kanban_effort_inherit">تلقائي</string>
|
||||||
|
<string name="kanban_effort_none">معطل</string>
|
||||||
|
<string name="kanban_effort_low">منخفض</string>
|
||||||
|
<string name="kanban_effort_medium">متوسط</string>
|
||||||
|
<string name="kanban_effort_high">مرتفع</string>
|
||||||
|
<string name="kanban_effort_minimal">حد أدنى</string>
|
||||||
|
<string name="kanban_effort_xhigh">مرتفع جدًا</string>
|
||||||
|
<string name="kanban_effort_max">أقصى</string>
|
||||||
|
<string name="kanban_effort_ultra">فائق</string>
|
||||||
|
<string name="kanban_show_archived">إظهار المهام المؤرشفة</string>
|
||||||
|
<string name="kanban_group_running">تجميع المهام قيد التشغيل حسب الملف الشخصي</string>
|
||||||
|
<string name="kanban_all_assignees">جميع المسؤولين</string>
|
||||||
|
<string name="kanban_tenant">المستأجر</string>
|
||||||
|
<string name="kanban_all_tenants">جميع المستأجرين</string>
|
||||||
|
<string name="kanban_execution_runs">عمليات التنفيذ</string>
|
||||||
|
<string name="kanban_no_run_history">لا يتوفر سجل للتنفيذ.</string>
|
||||||
|
<string name="kanban_run_number">التنفيذ #%1$d</string>
|
||||||
|
<string name="kanban_run_profile">الملف الشخصي: %1$s</string>
|
||||||
|
<string name="kanban_run_outcome">النتيجة: %1$s</string>
|
||||||
|
<string name="kanban_run_duration">المدة: %1$s</string>
|
||||||
|
<string name="kanban_run_error">الخطأ: %1$s</string>
|
||||||
|
<string name="kanban_run_summary">الملخص: %1$s</string>
|
||||||
|
<string name="kanban_project">نطاق المشروع</string>
|
||||||
|
<string name="kanban_no_project">بدون مشروع</string>
|
||||||
|
<string name="kanban_archive_board">أرشفة اللوحة</string>
|
||||||
|
<string name="kanban_archive_board_confirm">هل أنت متأكد من أرشفة اللوحة \'%1$s\'؟ يمكنك استعادتها لاحقًا من اللوحات المؤرشفة.</string>
|
||||||
|
<string name="kanban_board_settings">إعدادات اللوحة</string>
|
||||||
|
<string name="kanban_board_name">اسم اللوحة</string>
|
||||||
|
<string name="kanban_description">الوصف</string>
|
||||||
|
<string name="kanban_default_workdir">مجلد العمل الافتراضي</string>
|
||||||
|
<string name="kanban_workdir_example">مثال: /home/user/project</string>
|
||||||
|
<string name="kanban_orchestration_settings">إعدادات التنسيق</string>
|
||||||
|
<string name="kanban_orchestrator_profile">الملف المنسق</string>
|
||||||
|
<string name="kanban_default_assignee">المسؤول الافتراضي</string>
|
||||||
|
<string name="kanban_auto_decompose">تفكيك تلقائي</string>
|
||||||
|
<string name="kanban_profile_descriptions">أوصاف الملفات الشخصية</string>
|
||||||
|
<string name="kanban_auto_describe">وصف تلقائي</string>
|
||||||
|
<string name="kanban_dependencies">الارتباطات والتبعيات</string>
|
||||||
|
<string name="kanban_blocked_by">محظورة بواسطة</string>
|
||||||
|
<string name="kanban_blocks">تحظر</string>
|
||||||
|
<string name="kanban_estimate">تقدير الجهد</string>
|
||||||
|
<string name="kanban_estimate_action">تقدير الجهد</string>
|
||||||
|
<string name="kanban_reestimate_action">إعادة التقدير</string>
|
||||||
|
<string name="kanban_estimated_tokens">الرموز المقدرة: %1$d</string>
|
||||||
|
<string name="kanban_complexity">التعقيد: %1$s</string>
|
||||||
|
<string name="kanban_rationale">السبب والمنطق</string>
|
||||||
|
<string name="kanban_delete_task">حذف المهمة</string>
|
||||||
|
<string name="kanban_delete_task_confirm">هل أنت متأكد من حذف هذه المهمة؟ لا يمكن التراجع عن هذا الإجراء.</string>
|
||||||
|
<string name="kanban_bulk_assign">تعيين</string>
|
||||||
|
<string name="kanban_bulk_unassign">إلغاء التعيين</string>
|
||||||
|
<string name="kanban_bulk_delete">حذف</string>
|
||||||
|
<string name="kanban_bulk_delete_confirm">هل تريد حذف %1$d مهمة محددة؟ لا يمكن التراجع عن هذا الإجراء.</string>
|
||||||
|
<string name="kanban_export_board">تصدير اللوحة</string>
|
||||||
|
<string name="kanban_import_board">استيراد لوحة</string>
|
||||||
|
<string name="kanban_runtime_working">قيد التشغيل</string>
|
||||||
|
<string name="kanban_runtime_stale">متوقف (لا توجد إشارة)</string>
|
||||||
|
<string name="kanban_runtime_wont_run">لن تعمل: غير معيّنة</string>
|
||||||
|
<string name="kanban_runtime_elapsed">· %1$s</string>
|
||||||
|
<string name="kanban_runtime_stale_badge">· متوقف</string>
|
||||||
|
<string name="kanban_runtime_wont_run_badge">· لن تعمل</string>
|
||||||
|
<string name="kanban_task_actions">إجراءات المهمة</string>
|
||||||
|
<string name="kanban_reclaim_task">استعادة المهمة</string>
|
||||||
|
<string name="kanban_no_comments">لا توجد تعليقات بعد.</string>
|
||||||
|
<string name="kanban_write_comment">اكتب تعليقًا...</string>
|
||||||
|
<string name="kanban_note_requeue">ملاحظة وإعادة إدراج</string>
|
||||||
|
<string name="kanban_comment">تعليق</string>
|
||||||
|
<string name="kanban_worker_log">مخرجات سجل العامل</string>
|
||||||
|
<string name="kanban_event_timeline">الخط الزمني للأحداث</string>
|
||||||
|
<string name="kanban_no_activity">لا توجد أحداث نشاط.</string>
|
||||||
|
<string name="kanban_attachments">المرفقات</string>
|
||||||
|
<string name="kanban_upload">رفع</string>
|
||||||
|
<string name="kanban_no_attachments">لا توجد مرفقات مرفوعة.</string>
|
||||||
|
<string name="kanban_clear_all">مسح الكل</string>
|
||||||
|
<string name="kanban_skills_placeholder">مثال: git، coding، tests</string>
|
||||||
|
<string name="kanban_profile_description_hint">وصف الملف الشخصي للتوجيه</string>
|
||||||
|
<string name="kanban_filter">تصفية</string>
|
||||||
|
<string name="kanban_board_options">خيارات اللوحة</string>
|
||||||
|
<string name="kanban_new_board">لوحة جديدة</string>
|
||||||
|
<string name="kanban_clear_selection">مسح</string>
|
||||||
|
<string name="kanban_select_all">الكل</string>
|
||||||
|
<string name="kanban_move">نقل</string>
|
||||||
|
<string name="kanban_close_multi_select">إغلاق التحديد المتعدد</string>
|
||||||
|
<string name="kanban_move_tasks">نقل %1$d من المهام</string>
|
||||||
|
<string name="kanban_create_new_board">إنشاء لوحة جديدة</string>
|
||||||
|
<string name="kanban_slug">المعرّف (Slug)</string>
|
||||||
|
<string name="kanban_display_name">اسم العرض</string>
|
||||||
|
<string name="kanban_filters">عوامل التصفية</string>
|
||||||
|
<string name="kanban_confirm_action">تأكيد الإجراء</string>
|
||||||
|
<string name="kanban_edit_description">تعديل الوصف</string>
|
||||||
|
<string name="kanban_task_details">تفاصيل المهمة</string>
|
||||||
|
<string name="kanban_tab_overview">نظرة عامة</string>
|
||||||
|
<string name="kanban_tab_discussion">النقاش</string>
|
||||||
|
<string name="kanban_tab_runs">التنفيذات</string>
|
||||||
|
<string name="kanban_tab_activity">النشاط</string>
|
||||||
|
<string name="kanban_tab_files">الملفات</string>
|
||||||
|
<string name="kanban_event_created">أُنشئت في %1$s</string>
|
||||||
|
<string name="kanban_event_created_by">أُنشئت في %1$s بواسطة %2$s</string>
|
||||||
|
<string name="kanban_event_moved">نُقلت إلى %1$s</string>
|
||||||
|
<string name="kanban_event_assigned">عُيّنت إلى %1$s</string>
|
||||||
|
<string name="kanban_event_unassigned">غير معيّنة</string>
|
||||||
|
<string name="kanban_event_commented">تعليق بواسطة %1$s</string>
|
||||||
|
<string name="kanban_event_claimed_review">تمت المطالبة للمراجعة</string>
|
||||||
|
<string name="kanban_event_claimed_worker">تمت المطالبة بواسطة العامل</string>
|
||||||
|
<string name="kanban_event_worker_started">بدأ العامل</string>
|
||||||
|
<string name="kanban_event_completed">مكتملة</string>
|
||||||
|
<string name="kanban_event_blocked">محظورة</string>
|
||||||
|
<string name="kanban_event_unblocked">أزيل حظرها</string>
|
||||||
|
<string name="kanban_event_unblocked_to">أزيل حظرها (%1$s)</string>
|
||||||
|
<string name="kanban_event_reclaimed">تمت الاستعادة</string>
|
||||||
|
<string name="kanban_event_specified">تم التحديد</string>
|
||||||
|
<string name="kanban_event_promoted">تمت الترقية إلى جاهزة</string>
|
||||||
|
<string name="kanban_event_scheduled">مجدولة</string>
|
||||||
|
<string name="kanban_event_archived">مؤرشفة</string>
|
||||||
|
<string name="kanban_event_priority">تم ضبط الأولوية على %1$s</string>
|
||||||
|
<string name="kanban_workspace_scratch">مؤقت (افتراضي)</string>
|
||||||
|
<string name="kanban_workspace_worktree">شجرة عمل</string>
|
||||||
|
<string name="kanban_workspace_dir">مجلد</string>
|
||||||
|
<string name="kanban_unassigned_group">غير معيّن</string>
|
||||||
|
<string name="kanban_expand_column">توسيع %1$s</string>
|
||||||
|
<string name="kanban_collapse_column">طي %1$s</string>
|
||||||
|
<string name="kanban_add_to_column">إضافة مهمة إلى %1$s</string>
|
||||||
|
<string name="action_add">إضافة</string>
|
||||||
<string name="skills_status_all">جميع الحالات</string>
|
<string name="skills_status_all">جميع الحالات</string>
|
||||||
<string name="skills_status_enabled">مفعَّلة</string>
|
<string name="skills_status_enabled">مفعَّلة</string>
|
||||||
<string name="skills_status_disabled">معطَّلة</string>
|
<string name="skills_status_disabled">معطَّلة</string>
|
||||||
|
|||||||
@@ -299,6 +299,170 @@
|
|||||||
<string name="kanban_add_new_task">새 작업 추가</string>
|
<string name="kanban_add_new_task">새 작업 추가</string>
|
||||||
<string name="kanban_task_title">작업 제목</string>
|
<string name="kanban_task_title">작업 제목</string>
|
||||||
<string name="kanban_task_desc">작업 설명</string>
|
<string name="kanban_task_desc">작업 설명</string>
|
||||||
|
<string name="kanban_add_task">작업 추가</string>
|
||||||
|
<string name="kanban_action_triage">분류</string>
|
||||||
|
<string name="kanban_action_ready">대기</string>
|
||||||
|
<string name="kanban_action_block">차단</string>
|
||||||
|
<string name="kanban_action_unblock">차단 해제</string>
|
||||||
|
<string name="kanban_action_complete">완료</string>
|
||||||
|
<string name="kanban_action_archive">보관</string>
|
||||||
|
<string name="kanban_confirm_blocked">이 작업을 차단하시겠습니까? 워커 점유가 해제됩니다.</string>
|
||||||
|
<string name="kanban_confirm_done">이 작업을 완료하시겠습니까? 워커 점유가 해제됩니다.</string>
|
||||||
|
<string name="kanban_confirm_archive">이 작업을 보관하시겠습니까? 기본 보드 뷰에서 숨겨집니다.</string>
|
||||||
|
<string name="kanban_complete_title">작업 완료</string>
|
||||||
|
<string name="kanban_complete_summary_label">완료 요약</string>
|
||||||
|
<string name="kanban_complete_summary_hint">작업 결과로 저장됩니다</string>
|
||||||
|
<string name="kanban_move_failed">이동 실패: %1$s</string>
|
||||||
|
<string name="kanban_live">실시간</string>
|
||||||
|
<string name="kanban_offline">오프라인</string>
|
||||||
|
<string name="kanban_no_tasks">작업 없음</string>
|
||||||
|
<string name="kanban_priority">우선순위</string>
|
||||||
|
<string name="kanban_priority_normal">0 (일반)</string>
|
||||||
|
<string name="kanban_priority_med">1 (중간)</string>
|
||||||
|
<string name="kanban_priority_high">2 (높음)</string>
|
||||||
|
<string name="kanban_priority_urgent">3 (긴급)</string>
|
||||||
|
<string name="kanban_assignee">담당자</string>
|
||||||
|
<string name="kanban_assignee_default_suffix">%1$s (기본값)</string>
|
||||||
|
<string name="kanban_assignee_unassigned">기본 (미지정)</string>
|
||||||
|
<string name="kanban_assignee_parked">보류 (워커 없음)</string>
|
||||||
|
<string name="kanban_goal_mode">자율 목표 모드</string>
|
||||||
|
<string name="kanban_goal_mode_desc">판정자가 다회차 반복 작업을 통해 완료 여부를 검증합니다</string>
|
||||||
|
<string name="kanban_advanced_options">고급 옵션</string>
|
||||||
|
<string name="kanban_parent_task">상위 작업</string>
|
||||||
|
<string name="kanban_parent_none">없음</string>
|
||||||
|
<string name="kanban_model_override">모델 오버라이드</string>
|
||||||
|
<string name="kanban_provider_override">공급자 오버라이드</string>
|
||||||
|
<string name="kanban_reasoning_effort">추론 강도</string>
|
||||||
|
<string name="kanban_workspace_kind">작업공간 종류</string>
|
||||||
|
<string name="kanban_workspace_path">작업공간 경로 오버라이드</string>
|
||||||
|
<string name="kanban_workspace_path_hint">선택적 경로 (기본값: 프로젝트 디렉터리)</string>
|
||||||
|
<string name="kanban_skills">스킬</string>
|
||||||
|
<string name="kanban_target_column">컬럼</string>
|
||||||
|
<string name="kanban_model_inherit">프로필 기본값 상속</string>
|
||||||
|
<string name="kanban_model_clear">모델 오버라이드 초기화</string>
|
||||||
|
<string name="kanban_select_model">모델 선택</string>
|
||||||
|
<string name="kanban_effort_inherit">상속</string>
|
||||||
|
<string name="kanban_effort_none">비활성화</string>
|
||||||
|
<string name="kanban_effort_low">낮음</string>
|
||||||
|
<string name="kanban_effort_medium">중간</string>
|
||||||
|
<string name="kanban_effort_high">높음</string>
|
||||||
|
<string name="kanban_effort_minimal">최소</string>
|
||||||
|
<string name="kanban_effort_xhigh">매우 높음</string>
|
||||||
|
<string name="kanban_effort_max">최대</string>
|
||||||
|
<string name="kanban_effort_ultra">울트라</string>
|
||||||
|
<string name="kanban_show_archived">보관된 작업 표시</string>
|
||||||
|
<string name="kanban_group_running">실행 중인 작업을 프로필별로 그룹화</string>
|
||||||
|
<string name="kanban_all_assignees">모든 담당자</string>
|
||||||
|
<string name="kanban_tenant">테넌트</string>
|
||||||
|
<string name="kanban_all_tenants">모든 테넌트</string>
|
||||||
|
<string name="kanban_execution_runs">실행 기록</string>
|
||||||
|
<string name="kanban_no_run_history">실행 기록이 없습니다.</string>
|
||||||
|
<string name="kanban_run_number">실행 #%1$d</string>
|
||||||
|
<string name="kanban_run_profile">프로필: %1$s</string>
|
||||||
|
<string name="kanban_run_outcome">결과: %1$s</string>
|
||||||
|
<string name="kanban_run_duration">기간: %1$s</string>
|
||||||
|
<string name="kanban_run_error">오류: %1$s</string>
|
||||||
|
<string name="kanban_run_summary">요약: %1$s</string>
|
||||||
|
<string name="kanban_project">프로젝트 범위</string>
|
||||||
|
<string name="kanban_no_project">프로젝트 없음</string>
|
||||||
|
<string name="kanban_archive_board">보드 보관</string>
|
||||||
|
<string name="kanban_archive_board_confirm">\'%1$s\' 보드를 보관하시겠습니까? 나중에 보관된 보드에서 복구할 수 있습니다.</string>
|
||||||
|
<string name="kanban_board_settings">보드 설정</string>
|
||||||
|
<string name="kanban_board_name">보드 이름</string>
|
||||||
|
<string name="kanban_description">설명</string>
|
||||||
|
<string name="kanban_default_workdir">기본 작업 디렉터리</string>
|
||||||
|
<string name="kanban_workdir_example">예: /home/user/project</string>
|
||||||
|
<string name="kanban_orchestration_settings">오케스트레이션 설정</string>
|
||||||
|
<string name="kanban_orchestrator_profile">오케스트레이터 프로필</string>
|
||||||
|
<string name="kanban_default_assignee">기본 담당자</string>
|
||||||
|
<string name="kanban_auto_decompose">자동 분해</string>
|
||||||
|
<string name="kanban_profile_descriptions">프로필 설명</string>
|
||||||
|
<string name="kanban_auto_describe">자동 설명 생성</string>
|
||||||
|
<string name="kanban_dependencies">종속성</string>
|
||||||
|
<string name="kanban_blocked_by">차단됨 (상위)</string>
|
||||||
|
<string name="kanban_blocks">차단함 (하위)</string>
|
||||||
|
<string name="kanban_estimate">노력 추정</string>
|
||||||
|
<string name="kanban_estimate_action">노력 추정</string>
|
||||||
|
<string name="kanban_reestimate_action">재추정</string>
|
||||||
|
<string name="kanban_estimated_tokens">예상 토큰: %1$d</string>
|
||||||
|
<string name="kanban_complexity">복잡도: %1$s</string>
|
||||||
|
<string name="kanban_rationale">근거</string>
|
||||||
|
<string name="kanban_delete_task">작업 삭제</string>
|
||||||
|
<string name="kanban_delete_task_confirm">이 작업을 삭제하시겠습니까? 이 동작은 되돌릴 수 없습니다.</string>
|
||||||
|
<string name="kanban_bulk_assign">담당자 지정</string>
|
||||||
|
<string name="kanban_bulk_unassign">담당자 해제</string>
|
||||||
|
<string name="kanban_bulk_delete">삭제</string>
|
||||||
|
<string name="kanban_bulk_delete_confirm">선택한 %1$d개 작업을 삭제하시겠습니까? 이 동작은 되돌릴 수 없습니다.</string>
|
||||||
|
<string name="kanban_export_board">보드 내보내기</string>
|
||||||
|
<string name="kanban_import_board">보드 가져오기</string>
|
||||||
|
<string name="kanban_runtime_working">실행 중</string>
|
||||||
|
<string name="kanban_runtime_stale">응답 없음 (하트비트 지연)</string>
|
||||||
|
<string name="kanban_runtime_wont_run">실행 안 됨: 담당자 미지정</string>
|
||||||
|
<string name="kanban_runtime_elapsed">· %1$s</string>
|
||||||
|
<string name="kanban_runtime_stale_badge">· 응답 없음</string>
|
||||||
|
<string name="kanban_runtime_wont_run_badge">· 실행 안 됨</string>
|
||||||
|
<string name="kanban_task_actions">작업 작업</string>
|
||||||
|
<string name="kanban_reclaim_task">작업 회수</string>
|
||||||
|
<string name="kanban_no_comments">아직 댓글이 없습니다.</string>
|
||||||
|
<string name="kanban_write_comment">댓글을 작성하세요...</string>
|
||||||
|
<string name="kanban_note_requeue">메모 및 다시 대기열에 추가</string>
|
||||||
|
<string name="kanban_comment">댓글</string>
|
||||||
|
<string name="kanban_worker_log">워커 로그 출력</string>
|
||||||
|
<string name="kanban_event_timeline">이벤트 타임라인</string>
|
||||||
|
<string name="kanban_no_activity">활동 이벤트가 없습니다.</string>
|
||||||
|
<string name="kanban_attachments">첨부파일</string>
|
||||||
|
<string name="kanban_upload">업로드</string>
|
||||||
|
<string name="kanban_no_attachments">업로드된 첨부파일이 없습니다.</string>
|
||||||
|
<string name="kanban_clear_all">모두 지우기</string>
|
||||||
|
<string name="kanban_skills_placeholder">예: git, coding, tests</string>
|
||||||
|
<string name="kanban_profile_description_hint">라우팅에 사용할 프로필 설명</string>
|
||||||
|
<string name="kanban_filter">필터</string>
|
||||||
|
<string name="kanban_filter_placeholder">제목, 상태 또는 담당자로 작업 필터링...</string>
|
||||||
|
<string name="kanban_board_options">보드 옵션</string>
|
||||||
|
<string name="kanban_new_board">새 보드</string>
|
||||||
|
<string name="kanban_clear_selection">지우기</string>
|
||||||
|
<string name="kanban_select_all">전체</string>
|
||||||
|
<string name="kanban_move">이동</string>
|
||||||
|
<string name="kanban_close_multi_select">다중 선택 닫기</string>
|
||||||
|
<string name="kanban_move_tasks">작업 %1$d개 이동</string>
|
||||||
|
<string name="kanban_create_new_board">새 보드 만들기</string>
|
||||||
|
<string name="kanban_slug">슬러그 (id)</string>
|
||||||
|
<string name="kanban_display_name">표시 이름</string>
|
||||||
|
<string name="kanban_filters">필터</string>
|
||||||
|
<string name="kanban_confirm_action">작업 확인</string>
|
||||||
|
<string name="kanban_edit_description">설명 편집</string>
|
||||||
|
<string name="kanban_task_details">작업 세부정보</string>
|
||||||
|
<string name="kanban_tab_overview">개요</string>
|
||||||
|
<string name="kanban_tab_discussion">토론</string>
|
||||||
|
<string name="kanban_tab_runs">실행</string>
|
||||||
|
<string name="kanban_tab_activity">활동</string>
|
||||||
|
<string name="kanban_tab_files">파일</string>
|
||||||
|
<string name="kanban_event_created">%1$s에서 생성됨</string>
|
||||||
|
<string name="kanban_event_created_by">%1$s에서 %2$s가 생성함</string>
|
||||||
|
<string name="kanban_event_moved">%1$s로 이동됨</string>
|
||||||
|
<string name="kanban_event_assigned">%1$s에게 할당됨</string>
|
||||||
|
<string name="kanban_event_unassigned">미지정</string>
|
||||||
|
<string name="kanban_event_commented">%1$s의 댓글</string>
|
||||||
|
<string name="kanban_event_claimed_review">검토를 위해 할당됨</string>
|
||||||
|
<string name="kanban_event_claimed_worker">워커가 할당함</string>
|
||||||
|
<string name="kanban_event_worker_started">워커 시작됨</string>
|
||||||
|
<string name="kanban_event_completed">완료됨</string>
|
||||||
|
<string name="kanban_event_blocked">차단됨</string>
|
||||||
|
<string name="kanban_event_unblocked">차단 해제됨</string>
|
||||||
|
<string name="kanban_event_unblocked_to">차단 해제됨 (%1$s)</string>
|
||||||
|
<string name="kanban_event_reclaimed">회수됨</string>
|
||||||
|
<string name="kanban_event_specified">지정됨</string>
|
||||||
|
<string name="kanban_event_promoted">준비 상태로 승격됨</string>
|
||||||
|
<string name="kanban_event_scheduled">예약됨</string>
|
||||||
|
<string name="kanban_event_archived">보관됨</string>
|
||||||
|
<string name="kanban_event_priority">우선순위가 %1$s로 설정됨</string>
|
||||||
|
<string name="kanban_workspace_scratch">임시 (기본값)</string>
|
||||||
|
<string name="kanban_workspace_worktree">작업 트리</string>
|
||||||
|
<string name="kanban_workspace_dir">디렉터리</string>
|
||||||
|
<string name="kanban_unassigned_group">미지정</string>
|
||||||
|
<string name="kanban_expand_column">%1$s 펼치기</string>
|
||||||
|
<string name="kanban_collapse_column">%1$s 접기</string>
|
||||||
|
<string name="kanban_add_to_column">%1$s에 작업 추가</string>
|
||||||
<string name="action_add">추가</string>
|
<string name="action_add">추가</string>
|
||||||
|
|
||||||
<!-- Skills Screen -->
|
<!-- Skills Screen -->
|
||||||
|
|||||||
@@ -317,6 +317,152 @@
|
|||||||
<string name="kanban_live">实时</string>
|
<string name="kanban_live">实时</string>
|
||||||
<string name="kanban_offline">离线</string>
|
<string name="kanban_offline">离线</string>
|
||||||
<string name="kanban_no_tasks">暂无任务</string>
|
<string name="kanban_no_tasks">暂无任务</string>
|
||||||
|
<string name="kanban_priority">优先级</string>
|
||||||
|
<string name="kanban_priority_normal">0 (普通)</string>
|
||||||
|
<string name="kanban_priority_med">1 (中等)</string>
|
||||||
|
<string name="kanban_priority_high">2 (高)</string>
|
||||||
|
<string name="kanban_priority_urgent">3 (紧急)</string>
|
||||||
|
<string name="kanban_assignee">负责人</string>
|
||||||
|
<string name="kanban_assignee_default_suffix">%1$s(默认)</string>
|
||||||
|
<string name="kanban_assignee_unassigned">默认 (未指定)</string>
|
||||||
|
<string name="kanban_assignee_parked">暂挂 (无 worker)</string>
|
||||||
|
<string name="kanban_goal_mode">自主目标模式</string>
|
||||||
|
<string name="kanban_goal_mode_desc">由评判者在多轮迭代中验证任务是否完成</string>
|
||||||
|
<string name="kanban_advanced_options">高级选项</string>
|
||||||
|
<string name="kanban_parent_task">父任务</string>
|
||||||
|
<string name="kanban_parent_none">无</string>
|
||||||
|
<string name="kanban_model_override">覆盖模型</string>
|
||||||
|
<string name="kanban_provider_override">覆盖提供商</string>
|
||||||
|
<string name="kanban_reasoning_effort">推理强度</string>
|
||||||
|
<string name="kanban_workspace_kind">工作区类型</string>
|
||||||
|
<string name="kanban_workspace_path">覆盖工作区路径</string>
|
||||||
|
<string name="kanban_workspace_path_hint">可选路径(默认为项目目录)</string>
|
||||||
|
<string name="kanban_skills">技能</string>
|
||||||
|
<string name="kanban_target_column">列</string>
|
||||||
|
<string name="kanban_model_inherit">继承配置默认模型</string>
|
||||||
|
<string name="kanban_model_clear">清除模型覆盖</string>
|
||||||
|
<string name="kanban_select_model">选择模型</string>
|
||||||
|
<string name="kanban_effort_inherit">继承</string>
|
||||||
|
<string name="kanban_effort_none">禁用</string>
|
||||||
|
<string name="kanban_effort_low">低</string>
|
||||||
|
<string name="kanban_effort_medium">中</string>
|
||||||
|
<string name="kanban_effort_high">高</string>
|
||||||
|
<string name="kanban_effort_minimal">最小</string>
|
||||||
|
<string name="kanban_effort_xhigh">超高</string>
|
||||||
|
<string name="kanban_effort_max">最大</string>
|
||||||
|
<string name="kanban_effort_ultra">极高</string>
|
||||||
|
<string name="kanban_show_archived">显示已归档任务</string>
|
||||||
|
<string name="kanban_group_running">按配置文件分组运行中的任务</string>
|
||||||
|
<string name="kanban_all_assignees">所有负责人</string>
|
||||||
|
<string name="kanban_tenant">租户</string>
|
||||||
|
<string name="kanban_all_tenants">所有租户</string>
|
||||||
|
<string name="kanban_execution_runs">执行记录</string>
|
||||||
|
<string name="kanban_no_run_history">暂无执行记录。</string>
|
||||||
|
<string name="kanban_run_number">执行 #%1$d</string>
|
||||||
|
<string name="kanban_run_profile">配置文件:%1$s</string>
|
||||||
|
<string name="kanban_run_outcome">结果:%1$s</string>
|
||||||
|
<string name="kanban_run_duration">时长:%1$s</string>
|
||||||
|
<string name="kanban_run_error">错误:%1$s</string>
|
||||||
|
<string name="kanban_run_summary">摘要:%1$s</string>
|
||||||
|
<string name="kanban_project">项目范围</string>
|
||||||
|
<string name="kanban_no_project">无项目</string>
|
||||||
|
<string name="kanban_archive_board">归档看板</string>
|
||||||
|
<string name="kanban_archive_board_confirm">确定要归档看板 \'%1$s\' 吗?稍后可从已归档看板中恢复。</string>
|
||||||
|
<string name="kanban_board_settings">看板设置</string>
|
||||||
|
<string name="kanban_board_name">看板名称</string>
|
||||||
|
<string name="kanban_description">描述</string>
|
||||||
|
<string name="kanban_default_workdir">默认工作目录</string>
|
||||||
|
<string name="kanban_workdir_example">例如:/home/user/project</string>
|
||||||
|
<string name="kanban_orchestration_settings">编排设置</string>
|
||||||
|
<string name="kanban_orchestrator_profile">编排配置文件</string>
|
||||||
|
<string name="kanban_default_assignee">默认负责人</string>
|
||||||
|
<string name="kanban_auto_decompose">自动分解</string>
|
||||||
|
<string name="kanban_profile_descriptions">配置文件描述</string>
|
||||||
|
<string name="kanban_auto_describe">自动生成描述</string>
|
||||||
|
<string name="kanban_dependencies">依赖关系</string>
|
||||||
|
<string name="kanban_blocked_by">阻塞于 (父级)</string>
|
||||||
|
<string name="kanban_blocks">阻塞 (子级)</string>
|
||||||
|
<string name="kanban_estimate">工作量评估</string>
|
||||||
|
<string name="kanban_estimate_action">评估工作量</string>
|
||||||
|
<string name="kanban_reestimate_action">重新评估</string>
|
||||||
|
<string name="kanban_estimated_tokens">预计 Token:%1$d</string>
|
||||||
|
<string name="kanban_complexity">复杂度:%1$s</string>
|
||||||
|
<string name="kanban_rationale">评估依据</string>
|
||||||
|
<string name="kanban_delete_task">删除任务</string>
|
||||||
|
<string name="kanban_delete_task_confirm">确定要删除此任务吗?此操作无法撤销。</string>
|
||||||
|
<string name="kanban_bulk_assign">分配负责人</string>
|
||||||
|
<string name="kanban_bulk_unassign">取消分配</string>
|
||||||
|
<string name="kanban_bulk_delete">删除</string>
|
||||||
|
<string name="kanban_bulk_delete_confirm">确定要删除所选的 %1$d 个任务吗?此操作无法撤销。</string>
|
||||||
|
<string name="kanban_export_board">导出看板</string>
|
||||||
|
<string name="kanban_import_board">导入看板</string>
|
||||||
|
<string name="kanban_runtime_working">运行中</string>
|
||||||
|
<string name="kanban_runtime_stale">无响应 (心跳超时)</string>
|
||||||
|
<string name="kanban_runtime_wont_run">无法运行:未指定负责人</string>
|
||||||
|
<string name="kanban_runtime_elapsed">· %1$s</string>
|
||||||
|
<string name="kanban_runtime_stale_badge">· 无响应</string>
|
||||||
|
<string name="kanban_runtime_wont_run_badge">· 无法运行</string>
|
||||||
|
<string name="kanban_task_actions">任务操作</string>
|
||||||
|
<string name="kanban_reclaim_task">回收任务</string>
|
||||||
|
<string name="kanban_no_comments">暂无评论。</string>
|
||||||
|
<string name="kanban_write_comment">写下评论……</string>
|
||||||
|
<string name="kanban_note_requeue">备注并重新排队</string>
|
||||||
|
<string name="kanban_comment">评论</string>
|
||||||
|
<string name="kanban_worker_log">工作器日志输出</string>
|
||||||
|
<string name="kanban_event_timeline">事件时间线</string>
|
||||||
|
<string name="kanban_no_activity">暂无活动事件。</string>
|
||||||
|
<string name="kanban_attachments">附件</string>
|
||||||
|
<string name="kanban_upload">上传</string>
|
||||||
|
<string name="kanban_no_attachments">暂无已上传附件。</string>
|
||||||
|
<string name="kanban_clear_all">全部清除</string>
|
||||||
|
<string name="kanban_skills_placeholder">例如:git、coding、tests</string>
|
||||||
|
<string name="kanban_profile_description_hint">用于路由的配置文件描述</string>
|
||||||
|
<string name="kanban_filter">筛选</string>
|
||||||
|
<string name="kanban_board_options">看板选项</string>
|
||||||
|
<string name="kanban_new_board">新建看板</string>
|
||||||
|
<string name="kanban_clear_selection">清除</string>
|
||||||
|
<string name="kanban_select_all">全部</string>
|
||||||
|
<string name="kanban_move">移动</string>
|
||||||
|
<string name="kanban_close_multi_select">关闭多选</string>
|
||||||
|
<string name="kanban_move_tasks">移动 %1$d 个任务</string>
|
||||||
|
<string name="kanban_create_new_board">创建新看板</string>
|
||||||
|
<string name="kanban_slug">Slug(ID)</string>
|
||||||
|
<string name="kanban_display_name">显示名称</string>
|
||||||
|
<string name="kanban_filters">筛选</string>
|
||||||
|
<string name="kanban_confirm_action">确认操作</string>
|
||||||
|
<string name="kanban_edit_description">编辑描述</string>
|
||||||
|
<string name="kanban_task_details">任务详情</string>
|
||||||
|
<string name="kanban_tab_overview">概览</string>
|
||||||
|
<string name="kanban_tab_discussion">讨论</string>
|
||||||
|
<string name="kanban_tab_runs">执行</string>
|
||||||
|
<string name="kanban_tab_activity">活动</string>
|
||||||
|
<string name="kanban_tab_files">文件</string>
|
||||||
|
<string name="kanban_event_created">创建于 %1$s</string>
|
||||||
|
<string name="kanban_event_created_by">由 %2$s 创建于 %1$s</string>
|
||||||
|
<string name="kanban_event_moved">移动到 %1$s</string>
|
||||||
|
<string name="kanban_event_assigned">分配给 %1$s</string>
|
||||||
|
<string name="kanban_event_unassigned">未分配</string>
|
||||||
|
<string name="kanban_event_commented">%1$s 的评论</string>
|
||||||
|
<string name="kanban_event_claimed_review">已领取进行审核</string>
|
||||||
|
<string name="kanban_event_claimed_worker">已由工作器领取</string>
|
||||||
|
<string name="kanban_event_worker_started">工作器已启动</string>
|
||||||
|
<string name="kanban_event_completed">已完成</string>
|
||||||
|
<string name="kanban_event_blocked">已阻塞</string>
|
||||||
|
<string name="kanban_event_unblocked">已解除阻塞</string>
|
||||||
|
<string name="kanban_event_unblocked_to">已解除阻塞(%1$s)</string>
|
||||||
|
<string name="kanban_event_reclaimed">已回收</string>
|
||||||
|
<string name="kanban_event_specified">已指定</string>
|
||||||
|
<string name="kanban_event_promoted">已提升为就绪</string>
|
||||||
|
<string name="kanban_event_scheduled">已计划</string>
|
||||||
|
<string name="kanban_event_archived">已归档</string>
|
||||||
|
<string name="kanban_event_priority">优先级设置为 %1$s</string>
|
||||||
|
<string name="kanban_workspace_scratch">临时(默认)</string>
|
||||||
|
<string name="kanban_workspace_worktree">工作树</string>
|
||||||
|
<string name="kanban_workspace_dir">目录</string>
|
||||||
|
<string name="kanban_unassigned_group">未分配</string>
|
||||||
|
<string name="kanban_expand_column">展开 %1$s</string>
|
||||||
|
<string name="kanban_collapse_column">折叠 %1$s</string>
|
||||||
|
<string name="kanban_add_to_column">添加任务到 %1$s</string>
|
||||||
<string name="action_add">添加</string>
|
<string name="action_add">添加</string>
|
||||||
<string name="skills_status_all">全部状态</string>
|
<string name="skills_status_all">全部状态</string>
|
||||||
<string name="skills_status_enabled">已启用</string>
|
<string name="skills_status_enabled">已启用</string>
|
||||||
|
|||||||
@@ -359,6 +359,7 @@
|
|||||||
<string name="kanban_priority_high">2 (High)</string>
|
<string name="kanban_priority_high">2 (High)</string>
|
||||||
<string name="kanban_priority_urgent">3 (Urgent)</string>
|
<string name="kanban_priority_urgent">3 (Urgent)</string>
|
||||||
<string name="kanban_assignee">Assignee</string>
|
<string name="kanban_assignee">Assignee</string>
|
||||||
|
<string name="kanban_assignee_default_suffix">%1$s (Default)</string>
|
||||||
<string name="kanban_assignee_unassigned">Default (Unassigned)</string>
|
<string name="kanban_assignee_unassigned">Default (Unassigned)</string>
|
||||||
<string name="kanban_assignee_parked">Parked (No worker)</string>
|
<string name="kanban_assignee_parked">Parked (No worker)</string>
|
||||||
<string name="kanban_goal_mode">Autonomous Goal Mode</string>
|
<string name="kanban_goal_mode">Autonomous Goal Mode</string>
|
||||||
@@ -370,8 +371,134 @@
|
|||||||
<string name="kanban_provider_override">Provider Override</string>
|
<string name="kanban_provider_override">Provider Override</string>
|
||||||
<string name="kanban_reasoning_effort">Reasoning Effort</string>
|
<string name="kanban_reasoning_effort">Reasoning Effort</string>
|
||||||
<string name="kanban_workspace_kind">Workspace Kind</string>
|
<string name="kanban_workspace_kind">Workspace Kind</string>
|
||||||
|
<string name="kanban_workspace_path">Workspace Path Override</string>
|
||||||
|
<string name="kanban_workspace_path_hint">Optional path (defaults to project dir)</string>
|
||||||
<string name="kanban_skills">Skills</string>
|
<string name="kanban_skills">Skills</string>
|
||||||
<string name="kanban_target_column">Column</string>
|
<string name="kanban_target_column">Column</string>
|
||||||
|
<string name="kanban_model_inherit">Inherit profile</string>
|
||||||
|
<string name="kanban_model_clear">Clear model override</string>
|
||||||
|
<string name="kanban_select_model">Select Model</string>
|
||||||
|
<string name="kanban_effort_inherit">Inherit</string>
|
||||||
|
<string name="kanban_effort_none">None</string>
|
||||||
|
<string name="kanban_effort_low">Low</string>
|
||||||
|
<string name="kanban_effort_medium">Medium</string>
|
||||||
|
<string name="kanban_effort_high">High</string>
|
||||||
|
<string name="kanban_effort_minimal">Minimal</string>
|
||||||
|
<string name="kanban_effort_xhigh">XHigh</string>
|
||||||
|
<string name="kanban_effort_max">Max</string>
|
||||||
|
<string name="kanban_effort_ultra">Ultra</string>
|
||||||
|
<string name="kanban_show_archived">Show archived tasks</string>
|
||||||
|
<string name="kanban_group_running">Group running lane by profile</string>
|
||||||
|
<string name="kanban_all_assignees">All assignees</string>
|
||||||
|
<string name="kanban_tenant">Tenant</string>
|
||||||
|
<string name="kanban_all_tenants">All tenants</string>
|
||||||
|
<string name="kanban_execution_runs">Execution Runs</string>
|
||||||
|
<string name="kanban_no_run_history">No run history available.</string>
|
||||||
|
<string name="kanban_run_number">Run #%1$d</string>
|
||||||
|
<string name="kanban_run_profile">Profile: %1$s</string>
|
||||||
|
<string name="kanban_run_outcome">Outcome: %1$s</string>
|
||||||
|
<string name="kanban_run_duration">Duration: %1$s</string>
|
||||||
|
<string name="kanban_run_error">Error: %1$s</string>
|
||||||
|
<string name="kanban_run_summary">Summary: %1$s</string>
|
||||||
|
<string name="kanban_project">Project Scope</string>
|
||||||
|
<string name="kanban_no_project">No project</string>
|
||||||
|
<string name="kanban_archive_board">Archive Board</string>
|
||||||
|
<string name="kanban_archive_board_confirm">Are you sure you want to archive board \'%1$s\'? You can recover it later from archived boards.</string>
|
||||||
|
<string name="kanban_board_settings">Board Settings</string>
|
||||||
|
<string name="kanban_board_name">Board Name</string>
|
||||||
|
<string name="kanban_description">Description</string>
|
||||||
|
<string name="kanban_default_workdir">Default Workdir</string>
|
||||||
|
<string name="kanban_workdir_example">e.g. /home/user/project</string>
|
||||||
|
<string name="kanban_orchestration_settings">Orchestration Settings</string>
|
||||||
|
<string name="kanban_orchestrator_profile">Orchestrator Profile</string>
|
||||||
|
<string name="kanban_default_assignee">Default Assignee</string>
|
||||||
|
<string name="kanban_auto_decompose">Auto-decompose</string>
|
||||||
|
<string name="kanban_profile_descriptions">Profile Descriptions</string>
|
||||||
|
<string name="kanban_auto_describe">Auto-describe</string>
|
||||||
|
<string name="kanban_dependencies">Dependencies</string>
|
||||||
|
<string name="kanban_blocked_by">Blocked by</string>
|
||||||
|
<string name="kanban_blocks">Blocks</string>
|
||||||
|
<string name="kanban_estimate">Effort Estimation</string>
|
||||||
|
<string name="kanban_estimate_action">Estimate effort</string>
|
||||||
|
<string name="kanban_reestimate_action">Re-estimate</string>
|
||||||
|
<string name="kanban_estimated_tokens">Tokens: %1$d</string>
|
||||||
|
<string name="kanban_complexity">Complexity: %1$s</string>
|
||||||
|
<string name="kanban_rationale">Rationale</string>
|
||||||
|
<string name="kanban_delete_task">Delete Task</string>
|
||||||
|
<string name="kanban_delete_task_confirm">Are you sure you want to delete this task? This cannot be undone.</string>
|
||||||
|
<string name="kanban_bulk_assign">Assign</string>
|
||||||
|
<string name="kanban_bulk_unassign">Unassign</string>
|
||||||
|
<string name="kanban_bulk_delete">Delete</string>
|
||||||
|
<string name="kanban_bulk_delete_confirm">Delete %1$d selected tasks? This cannot be undone.</string>
|
||||||
|
<string name="kanban_export_board">Export Board</string>
|
||||||
|
<string name="kanban_import_board">Import Board</string>
|
||||||
|
<string name="kanban_runtime_working">Working</string>
|
||||||
|
<string name="kanban_runtime_stale">Stale (no heartbeat)</string>
|
||||||
|
<string name="kanban_runtime_wont_run">Won\'t run: unassigned</string>
|
||||||
|
<string name="kanban_runtime_elapsed">· %1$s</string>
|
||||||
|
<string name="kanban_runtime_stale_badge">· STALE</string>
|
||||||
|
<string name="kanban_runtime_wont_run_badge">· WON\'T RUN</string>
|
||||||
|
<string name="kanban_task_actions">Task actions</string>
|
||||||
|
<string name="kanban_reclaim_task">Reclaim task</string>
|
||||||
|
<string name="kanban_no_comments">No comments yet.</string>
|
||||||
|
<string name="kanban_write_comment">Write a comment...</string>
|
||||||
|
<string name="kanban_note_requeue">Note & Requeue</string>
|
||||||
|
<string name="kanban_comment">Comment</string>
|
||||||
|
<string name="kanban_worker_log">Worker Log Output</string>
|
||||||
|
<string name="kanban_event_timeline">Event Timeline</string>
|
||||||
|
<string name="kanban_no_activity">No activity events.</string>
|
||||||
|
<string name="kanban_attachments">Attachments</string>
|
||||||
|
<string name="kanban_upload">Upload</string>
|
||||||
|
<string name="kanban_no_attachments">No attachments uploaded.</string>
|
||||||
|
<string name="kanban_clear_all">Clear all</string>
|
||||||
|
<string name="kanban_skills_placeholder">e.g. git, coding, tests</string>
|
||||||
|
<string name="kanban_profile_description_hint">Profile description for routing</string>
|
||||||
|
<string name="kanban_filter">Filter</string>
|
||||||
|
<string name="kanban_board_options">Board options</string>
|
||||||
|
<string name="kanban_new_board">New Board</string>
|
||||||
|
<string name="kanban_clear_selection">Clear</string>
|
||||||
|
<string name="kanban_select_all">All</string>
|
||||||
|
<string name="kanban_move">Move</string>
|
||||||
|
<string name="kanban_close_multi_select">Close multi-select</string>
|
||||||
|
<string name="kanban_move_tasks">Move %1$d tasks</string>
|
||||||
|
<string name="kanban_create_new_board">Create New Board</string>
|
||||||
|
<string name="kanban_slug">Slug (id)</string>
|
||||||
|
<string name="kanban_display_name">Display Name</string>
|
||||||
|
<string name="kanban_filters">Filters</string>
|
||||||
|
<string name="kanban_confirm_action">Confirm action</string>
|
||||||
|
<string name="kanban_edit_description">Edit Description</string>
|
||||||
|
<string name="kanban_task_details">Task Details</string>
|
||||||
|
<string name="kanban_tab_overview">Overview</string>
|
||||||
|
<string name="kanban_tab_discussion">Discussion</string>
|
||||||
|
<string name="kanban_tab_runs">Runs</string>
|
||||||
|
<string name="kanban_tab_activity">Activity</string>
|
||||||
|
<string name="kanban_tab_files">Files</string>
|
||||||
|
<string name="kanban_event_created">Created in %1$s</string>
|
||||||
|
<string name="kanban_event_created_by">Created in %1$s by %2$s</string>
|
||||||
|
<string name="kanban_event_moved">Moved to %1$s</string>
|
||||||
|
<string name="kanban_event_assigned">Assigned to %1$s</string>
|
||||||
|
<string name="kanban_event_unassigned">Unassigned</string>
|
||||||
|
<string name="kanban_event_commented">Comment by %1$s</string>
|
||||||
|
<string name="kanban_event_claimed_review">Claimed for review</string>
|
||||||
|
<string name="kanban_event_claimed_worker">Claimed by worker</string>
|
||||||
|
<string name="kanban_event_worker_started">Worker started</string>
|
||||||
|
<string name="kanban_event_completed">Completed</string>
|
||||||
|
<string name="kanban_event_blocked">Blocked</string>
|
||||||
|
<string name="kanban_event_unblocked">Unblocked</string>
|
||||||
|
<string name="kanban_event_unblocked_to">Unblocked (%1$s)</string>
|
||||||
|
<string name="kanban_event_reclaimed">Reclaimed</string>
|
||||||
|
<string name="kanban_event_specified">Specified</string>
|
||||||
|
<string name="kanban_event_promoted">Promoted to ready</string>
|
||||||
|
<string name="kanban_event_scheduled">Scheduled</string>
|
||||||
|
<string name="kanban_event_archived">Archived</string>
|
||||||
|
<string name="kanban_event_priority">Priority set to %1$s</string>
|
||||||
|
<string name="kanban_workspace_scratch">Scratch (Default)</string>
|
||||||
|
<string name="kanban_workspace_worktree">Worktree</string>
|
||||||
|
<string name="kanban_workspace_dir">Dir</string>
|
||||||
|
<string name="kanban_unassigned_group">Unassigned</string>
|
||||||
|
<string name="kanban_expand_column">Expand %1$s</string>
|
||||||
|
<string name="kanban_collapse_column">Collapse %1$s</string>
|
||||||
|
<string name="kanban_add_to_column">Add task to %1$s</string>
|
||||||
<string name="action_add">Add</string>
|
<string name="action_add">Add</string>
|
||||||
<!-- Skills Screen -->
|
<!-- Skills Screen -->
|
||||||
<string name="skills_status_all">All Statuses</string>
|
<string name="skills_status_all">All Statuses</string>
|
||||||
|
|||||||
@@ -289,4 +289,29 @@ class KanbanSerializationTest {
|
|||||||
assertEquals("M", estimate.complexity)
|
assertEquals("M", estimate.complexity)
|
||||||
assertEquals("Multi-file changes needed", estimate.rationale)
|
assertEquals("Multi-file changes needed", estimate.rationale)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBulkAssignmentUsesExplicitEmptyStringAndReclaim() {
|
||||||
|
val assignJson =
|
||||||
|
json.encodeToString(
|
||||||
|
BulkTasksBody(
|
||||||
|
ids = listOf("t_1"),
|
||||||
|
assignee = "researcher",
|
||||||
|
reclaimFirst = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val unassignJson =
|
||||||
|
json.encodeToString(
|
||||||
|
BulkTasksBody(
|
||||||
|
ids = listOf("t_1"),
|
||||||
|
assignee = "",
|
||||||
|
reclaimFirst = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(assignJson.contains("\"assignee\":\"researcher\""))
|
||||||
|
assertTrue(assignJson.contains("\"reclaim_first\":true"))
|
||||||
|
assertTrue(unassignJson.contains("\"assignee\":\"\""))
|
||||||
|
assertTrue(unassignJson.contains("\"reclaim_first\":true"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class KanbanColumnCollapseHelperTest {
|
||||||
|
@Test
|
||||||
|
fun testBoardWithoutWorkDoesNotCollapse() {
|
||||||
|
assertFalse(
|
||||||
|
KanbanColumnCollapseHelper.isColumnCollapsed(
|
||||||
|
columnName = "todo",
|
||||||
|
columnTaskCount = 0,
|
||||||
|
boardHasWork = false,
|
||||||
|
overrides = emptyMap(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBoardWithWorkAutoCollapsesEmptyColumn() {
|
||||||
|
assertTrue(
|
||||||
|
KanbanColumnCollapseHelper.isColumnCollapsed(
|
||||||
|
columnName = "todo",
|
||||||
|
columnTaskCount = 0,
|
||||||
|
boardHasWork = true,
|
||||||
|
overrides = emptyMap(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertFalse(
|
||||||
|
KanbanColumnCollapseHelper.isColumnCollapsed(
|
||||||
|
columnName = "ready",
|
||||||
|
columnTaskCount = 3,
|
||||||
|
boardHasWork = true,
|
||||||
|
overrides = emptyMap(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testManualOverrideTakesPrecedence() {
|
||||||
|
// Force expand an empty column
|
||||||
|
assertFalse(
|
||||||
|
KanbanColumnCollapseHelper.isColumnCollapsed(
|
||||||
|
columnName = "todo",
|
||||||
|
columnTaskCount = 0,
|
||||||
|
boardHasWork = true,
|
||||||
|
overrides = mapOf("todo" to false),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Force collapse an occupied column
|
||||||
|
assertTrue(
|
||||||
|
KanbanColumnCollapseHelper.isColumnCollapsed(
|
||||||
|
columnName = "ready",
|
||||||
|
columnTaskCount = 3,
|
||||||
|
boardHasWork = true,
|
||||||
|
overrides = mapOf("ready" to true),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskBody
|
||||||
|
import com.m57.hermescontrol.data.model.CreateTaskResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoard
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanBoardResponse
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanColumn
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
|
import com.m57.hermescontrol.data.repository.KanbanRepository
|
||||||
|
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.resetMain
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.coroutines.test.setMain
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class KanbanCreateTaskPayloadTest {
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
|
||||||
|
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
|
||||||
|
|
||||||
|
private fun createViewModel(): KanbanViewModel =
|
||||||
|
KanbanViewModel(
|
||||||
|
repository = mockRepository,
|
||||||
|
eventsClientProvider = { mockEventsClient },
|
||||||
|
)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreateTaskPayloadWithAllFields() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
val newTask = KanbanTask(id = "t_100", title = "Full task", status = "todo")
|
||||||
|
|
||||||
|
coEvery { mockRepository.getBoard("dev", any(), any()) } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
|
||||||
|
coEvery { mockRepository.createTask("dev", any()) } returns
|
||||||
|
NetworkResult.Success(CreateTaskResponse(task = newTask))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
val body =
|
||||||
|
CreateTaskBody(
|
||||||
|
title = "Full task",
|
||||||
|
body = "Rich details",
|
||||||
|
assignee = "researcher",
|
||||||
|
priority = 2,
|
||||||
|
goalMode = true,
|
||||||
|
parents = listOf("p_1", "p_2"),
|
||||||
|
modelOverride = "claude-3-5-sonnet",
|
||||||
|
providerOverride = "anthropic",
|
||||||
|
reasoningEffort = "high",
|
||||||
|
workspaceKind = "worktree",
|
||||||
|
workspacePath = "/custom/wt/path",
|
||||||
|
skills = listOf("git", "testing"),
|
||||||
|
triage = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
vm.createTask(body, targetStatus = "triage")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
mockRepository.createTask(
|
||||||
|
"dev",
|
||||||
|
match {
|
||||||
|
it.title == "Full task" &&
|
||||||
|
it.body == "Rich details" &&
|
||||||
|
it.assignee == "researcher" &&
|
||||||
|
it.priority == 2 &&
|
||||||
|
it.goalMode &&
|
||||||
|
it.parents == listOf("p_1", "p_2") &&
|
||||||
|
it.modelOverride == "claude-3-5-sonnet" &&
|
||||||
|
it.providerOverride == "anthropic" &&
|
||||||
|
it.reasoningEffort == "high" &&
|
||||||
|
it.workspaceKind == "worktree" &&
|
||||||
|
it.workspacePath == "/custom/wt/path" &&
|
||||||
|
it.skills == listOf("git", "testing") &&
|
||||||
|
it.triage
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testScratchWorkspaceNeverSendsPath() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
coEvery { mockRepository.getBoard("dev", any(), any()) } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = emptyList()))
|
||||||
|
coEvery { mockRepository.createTask("dev", any()) } returns
|
||||||
|
NetworkResult.Success(
|
||||||
|
CreateTaskResponse(task = KanbanTask(id = "t_1", title = "Task", status = "todo")),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
// In our dialog logic, scratch sets workspaceKind = null (or scratch) and workspacePath = null
|
||||||
|
val body =
|
||||||
|
CreateTaskBody(
|
||||||
|
title = "Scratch Task",
|
||||||
|
workspaceKind = null,
|
||||||
|
workspacePath = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
vm.createTask(body)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
mockRepository.createTask(
|
||||||
|
"dev",
|
||||||
|
match {
|
||||||
|
it.workspaceKind == null && it.workspacePath == null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParkedAssigneeSendsNull() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
coEvery { mockRepository.getBoard("dev", any(), any()) } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = emptyList()))
|
||||||
|
coEvery { mockRepository.createTask("dev", any()) } returns
|
||||||
|
NetworkResult.Success(
|
||||||
|
CreateTaskResponse(task = KanbanTask(id = "t_1", title = "Task", status = "todo")),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
val body =
|
||||||
|
CreateTaskBody(
|
||||||
|
title = "Parked Task",
|
||||||
|
assignee = null, // Parked
|
||||||
|
)
|
||||||
|
|
||||||
|
vm.createTask(body)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
mockRepository.createTask(
|
||||||
|
"dev",
|
||||||
|
match { it.assignee == null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.DispatchResult
|
||||||
|
import com.m57.hermescontrol.data.remote.KanbanApiService
|
||||||
|
import com.m57.hermescontrol.data.repository.DefaultKanbanDispatcherNudger
|
||||||
|
import io.mockk.coEvery
|
||||||
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.TestScope
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Test
|
||||||
|
import retrofit2.Response
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class KanbanDispatcherNudgerTest {
|
||||||
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
|
private val testScope = TestScope(testDispatcher)
|
||||||
|
private val mockApi = mockk<KanbanApiService>()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testNudgeDebouncingSchedulesSingleCall() =
|
||||||
|
testScope.runTest {
|
||||||
|
coEvery { mockApi.nudgeDispatcher(any()) } returns Response.success(DispatchResult())
|
||||||
|
|
||||||
|
val nudger =
|
||||||
|
DefaultKanbanDispatcherNudger(
|
||||||
|
apiProvider = { mockApi },
|
||||||
|
scope = this,
|
||||||
|
debounceMs = 400L,
|
||||||
|
)
|
||||||
|
|
||||||
|
nudger.scheduleNudge("board-a")
|
||||||
|
advanceTimeBy(100L)
|
||||||
|
nudger.scheduleNudge("board-a")
|
||||||
|
advanceTimeBy(100L)
|
||||||
|
nudger.scheduleNudge("board-a")
|
||||||
|
|
||||||
|
// Before 400ms passes from last call
|
||||||
|
advanceTimeBy(300L)
|
||||||
|
coVerify(exactly = 0) { mockApi.nudgeDispatcher("board-a") }
|
||||||
|
|
||||||
|
// After 400ms passes
|
||||||
|
advanceTimeBy(150L)
|
||||||
|
coVerify(exactly = 1) { mockApi.nudgeDispatcher("board-a") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testNudgeScopedPerBoard() =
|
||||||
|
testScope.runTest {
|
||||||
|
coEvery { mockApi.nudgeDispatcher(any()) } returns Response.success(DispatchResult())
|
||||||
|
|
||||||
|
val nudger =
|
||||||
|
DefaultKanbanDispatcherNudger(
|
||||||
|
apiProvider = { mockApi },
|
||||||
|
scope = this,
|
||||||
|
debounceMs = 400L,
|
||||||
|
)
|
||||||
|
|
||||||
|
nudger.scheduleNudge("board-a")
|
||||||
|
nudger.scheduleNudge("board-b")
|
||||||
|
|
||||||
|
advanceTimeBy(450L)
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { mockApi.nudgeDispatcher("board-a") }
|
||||||
|
coVerify(exactly = 1) { mockApi.nudgeDispatcher("board-b") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testNudgeFailureAbsorbedSilently() =
|
||||||
|
testScope.runTest {
|
||||||
|
coEvery { mockApi.nudgeDispatcher(any()) } throws RuntimeException("Network timeout")
|
||||||
|
|
||||||
|
val nudger =
|
||||||
|
DefaultKanbanDispatcherNudger(
|
||||||
|
apiProvider = { mockApi },
|
||||||
|
scope = this,
|
||||||
|
debounceMs = 100L,
|
||||||
|
)
|
||||||
|
|
||||||
|
nudger.scheduleNudge("board-a")
|
||||||
|
advanceTimeBy(150L)
|
||||||
|
|
||||||
|
coVerify(exactly = 1) { mockApi.nudgeDispatcher("board-a") }
|
||||||
|
// No unhandled exception thrown
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanDetailEvent
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class KanbanEventFormatterTest {
|
||||||
|
@Test
|
||||||
|
fun testCreatedEvent() {
|
||||||
|
val payload =
|
||||||
|
buildJsonObject {
|
||||||
|
put("status", "ready")
|
||||||
|
put("assignee", "coder")
|
||||||
|
}
|
||||||
|
val event = KanbanDetailEvent(id = 1, kind = "created", payload = payload, createdAt = 1000)
|
||||||
|
val formatted = KanbanEventFormatter.format(event)
|
||||||
|
assertEquals("Created in Ready by coder", formatted.label)
|
||||||
|
assertNull(formatted.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testStatusEventWithReason() {
|
||||||
|
val payload =
|
||||||
|
buildJsonObject {
|
||||||
|
put("status", "done")
|
||||||
|
put("reason", "completed successfully")
|
||||||
|
}
|
||||||
|
val event = KanbanDetailEvent(id = 2, kind = "status", payload = payload, createdAt = 1000)
|
||||||
|
val formatted = KanbanEventFormatter.format(event)
|
||||||
|
assertEquals("Moved to Done", formatted.label)
|
||||||
|
assertEquals("completed successfully", formatted.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testParentReopenedStatusEvent() {
|
||||||
|
val payload =
|
||||||
|
buildJsonObject {
|
||||||
|
put("status", "todo")
|
||||||
|
put("reason", "parent_reopened")
|
||||||
|
put("parent", "t_parent1")
|
||||||
|
}
|
||||||
|
val event = KanbanDetailEvent(id = 3, kind = "status", payload = payload, createdAt = 1000)
|
||||||
|
val formatted = KanbanEventFormatter.format(event)
|
||||||
|
assertEquals("Moved to Todo", formatted.label)
|
||||||
|
assertEquals("Parent t_parent1 reopened", formatted.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAssignedEvent() {
|
||||||
|
val payload = buildJsonObject { put("assignee", "reviewer") }
|
||||||
|
val event = KanbanDetailEvent(id = 4, kind = "assigned", payload = payload, createdAt = 1000)
|
||||||
|
val formatted = KanbanEventFormatter.format(event)
|
||||||
|
assertEquals("Assigned to reviewer", formatted.label)
|
||||||
|
|
||||||
|
val unassignedEvent = KanbanDetailEvent(id = 5, kind = "assigned", payload = null, createdAt = 1000)
|
||||||
|
assertEquals("Unassigned", KanbanEventFormatter.format(unassignedEvent).label)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testClaimedReviewAndWorker() {
|
||||||
|
val reviewClaim =
|
||||||
|
KanbanDetailEvent(
|
||||||
|
id = 6,
|
||||||
|
kind = "claimed",
|
||||||
|
payload = buildJsonObject { put("source_status", "review") },
|
||||||
|
createdAt = 1000,
|
||||||
|
)
|
||||||
|
assertEquals("Claimed for review", KanbanEventFormatter.format(reviewClaim).label)
|
||||||
|
|
||||||
|
val workerClaim =
|
||||||
|
KanbanDetailEvent(
|
||||||
|
id = 7,
|
||||||
|
kind = "claimed",
|
||||||
|
payload = buildJsonObject { put("source_status", "ready") },
|
||||||
|
createdAt = 1000,
|
||||||
|
)
|
||||||
|
assertEquals("Claimed by worker", KanbanEventFormatter.format(workerClaim).label)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSpawnedEvent() {
|
||||||
|
val event =
|
||||||
|
KanbanDetailEvent(
|
||||||
|
id = 8,
|
||||||
|
kind = "spawned",
|
||||||
|
payload = buildJsonObject { put("pid", 12345) },
|
||||||
|
createdAt = 1000,
|
||||||
|
)
|
||||||
|
val formatted = KanbanEventFormatter.format(event)
|
||||||
|
assertEquals("Worker started", formatted.label)
|
||||||
|
assertEquals("PID 12345", formatted.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testLifecycleEvents() {
|
||||||
|
assertEquals("Completed", KanbanEventFormatter.format(KanbanDetailEvent(9, "completed", null, 1000)).label)
|
||||||
|
assertEquals(
|
||||||
|
"Blocked",
|
||||||
|
KanbanEventFormatter
|
||||||
|
.format(
|
||||||
|
KanbanDetailEvent(
|
||||||
|
10,
|
||||||
|
"blocked",
|
||||||
|
buildJsonObject { put("reason", "waiting on CI") },
|
||||||
|
1000,
|
||||||
|
),
|
||||||
|
).label,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"Unblocked (Ready)",
|
||||||
|
KanbanEventFormatter
|
||||||
|
.format(
|
||||||
|
KanbanDetailEvent(
|
||||||
|
11,
|
||||||
|
"unblocked",
|
||||||
|
buildJsonObject { put("status", "ready") },
|
||||||
|
1000,
|
||||||
|
),
|
||||||
|
).label,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"Reclaimed",
|
||||||
|
KanbanEventFormatter
|
||||||
|
.format(
|
||||||
|
KanbanDetailEvent(
|
||||||
|
12,
|
||||||
|
"reclaimed",
|
||||||
|
buildJsonObject { put("reason", "timeout") },
|
||||||
|
1000,
|
||||||
|
),
|
||||||
|
).label,
|
||||||
|
)
|
||||||
|
assertEquals("Specified", KanbanEventFormatter.format(KanbanDetailEvent(13, "specified", null, 1000)).label)
|
||||||
|
assertEquals(
|
||||||
|
"Promoted to ready",
|
||||||
|
KanbanEventFormatter.format(KanbanDetailEvent(14, "promoted", null, 1000)).label,
|
||||||
|
)
|
||||||
|
assertEquals("Archived", KanbanEventFormatter.format(KanbanDetailEvent(15, "archived", null, 1000)).label)
|
||||||
|
assertEquals(
|
||||||
|
"Priority set to 2",
|
||||||
|
KanbanEventFormatter
|
||||||
|
.format(
|
||||||
|
KanbanDetailEvent(
|
||||||
|
16,
|
||||||
|
"reprioritized",
|
||||||
|
buildJsonObject { put("priority", 2) },
|
||||||
|
1000,
|
||||||
|
),
|
||||||
|
).label,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testUnknownEventWithScalarPayload() {
|
||||||
|
val payload =
|
||||||
|
buildJsonObject {
|
||||||
|
put("foo", "bar")
|
||||||
|
put("count", 42)
|
||||||
|
}
|
||||||
|
val event = KanbanDetailEvent(id = 17, kind = "custom_hook", payload = payload, createdAt = 1000)
|
||||||
|
val formatted = KanbanEventFormatter.format(event)
|
||||||
|
assertEquals("Custom hook", formatted.label)
|
||||||
|
assertEquals("foo=bar count=42", formatted.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testStringJsonPayload() {
|
||||||
|
val event =
|
||||||
|
KanbanDetailEvent(
|
||||||
|
id = 18,
|
||||||
|
kind = "status",
|
||||||
|
payload = JsonPrimitive("{\"status\":\"blocked\",\"reason\":\"rate limit\"}"),
|
||||||
|
createdAt = 1000,
|
||||||
|
)
|
||||||
|
val formatted = KanbanEventFormatter.format(event)
|
||||||
|
assertEquals("Moved to Blocked", formatted.label)
|
||||||
|
assertEquals("rate limit", formatted.detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testMalformedPayloadGraceful() {
|
||||||
|
val event =
|
||||||
|
KanbanDetailEvent(
|
||||||
|
id = 19,
|
||||||
|
kind = "custom_test",
|
||||||
|
payload = JsonPrimitive("{not valid json"),
|
||||||
|
createdAt = 1000,
|
||||||
|
)
|
||||||
|
val formatted = KanbanEventFormatter.format(event)
|
||||||
|
assertEquals("Custom test", formatted.label)
|
||||||
|
assertEquals("raw={not valid json", formatted.detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
|
import kotlinx.coroutines.awaitCancellation
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceUntilIdle
|
||||||
|
import kotlinx.coroutines.test.runCurrent
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
class KanbanImportCleanupTest {
|
||||||
|
@Test
|
||||||
|
fun cleansAfterSuccess() =
|
||||||
|
runTest {
|
||||||
|
var cleaned = false
|
||||||
|
val result =
|
||||||
|
withKanbanImportCleanup(
|
||||||
|
cleanup = { cleaned = true },
|
||||||
|
onCleanupFailure = { throw AssertionError(it) },
|
||||||
|
dispatcher = StandardTestDispatcher(testScheduler),
|
||||||
|
) { "imported" }
|
||||||
|
assertEquals("imported", result)
|
||||||
|
assertTrue(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cleansAfterImportFailureWithoutMaskingIt() =
|
||||||
|
runTest {
|
||||||
|
val failure = IllegalStateException("import failed")
|
||||||
|
var cleaned = false
|
||||||
|
try {
|
||||||
|
withKanbanImportCleanup(
|
||||||
|
cleanup = { cleaned = true },
|
||||||
|
onCleanupFailure = { throw AssertionError(it) },
|
||||||
|
dispatcher = StandardTestDispatcher(testScheduler),
|
||||||
|
) { throw failure }
|
||||||
|
} catch (e: IllegalStateException) {
|
||||||
|
assertSame(failure, e)
|
||||||
|
}
|
||||||
|
assertTrue(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cancellationWaitsForSuspendingCleanupAndRemainsCancelled() =
|
||||||
|
runTest {
|
||||||
|
var cleaned = false
|
||||||
|
var cancellationPropagated = false
|
||||||
|
val job =
|
||||||
|
launch {
|
||||||
|
try {
|
||||||
|
withKanbanImportCleanup(
|
||||||
|
cleanup = {
|
||||||
|
delay(100)
|
||||||
|
cleaned = true
|
||||||
|
},
|
||||||
|
onCleanupFailure = { throw AssertionError(it) },
|
||||||
|
dispatcher = StandardTestDispatcher(testScheduler),
|
||||||
|
) { awaitCancellation() }
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
cancellationPropagated = true
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
runCurrent()
|
||||||
|
job.cancel()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(cleaned)
|
||||||
|
assertTrue(cancellationPropagated)
|
||||||
|
assertTrue(job.isCancelled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cleanupFailureIsReportedWithoutMaskingImportResult() =
|
||||||
|
runTest {
|
||||||
|
val failure = IllegalStateException("HTTP 500")
|
||||||
|
var reported: Exception? = null
|
||||||
|
val result =
|
||||||
|
withKanbanImportCleanup(
|
||||||
|
cleanup = { throw failure },
|
||||||
|
onCleanupFailure = { reported = it },
|
||||||
|
dispatcher = StandardTestDispatcher(testScheduler),
|
||||||
|
) { "imported" }
|
||||||
|
assertEquals("imported", result)
|
||||||
|
// Coroutine stack-trace recovery can copy exceptions across dispatcher boundaries.
|
||||||
|
assertEquals(failure.message, reported?.message)
|
||||||
|
assertTrue(reported is IllegalStateException)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cleanupIsBounded() =
|
||||||
|
runTest {
|
||||||
|
var reported = false
|
||||||
|
withKanbanImportCleanup(
|
||||||
|
cleanup = { awaitCancellation() },
|
||||||
|
onCleanupFailure = { reported = true },
|
||||||
|
dispatcher = StandardTestDispatcher(testScheduler),
|
||||||
|
) { Unit }
|
||||||
|
assertTrue(reported)
|
||||||
|
assertEquals(10_000L, testScheduler.currentTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class KanbanLaneGroupingTest {
|
||||||
|
@Test
|
||||||
|
fun testGroupRunningTasksSeparatesProfilesDeterministically() {
|
||||||
|
val tasks =
|
||||||
|
listOf(
|
||||||
|
KanbanTask(id = "t1", title = "Task 1", status = "running", assignee = "reviewer"),
|
||||||
|
KanbanTask(id = "t2", title = "Task 2", status = "running", assignee = "coder"),
|
||||||
|
KanbanTask(id = "t3", title = "Task 3", status = "running", assignee = null),
|
||||||
|
KanbanTask(id = "t4", title = "Task 4", status = "running", assignee = "coder"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val groups = KanbanLaneGrouping.groupRunningTasks(tasks)
|
||||||
|
|
||||||
|
// Coder first (alphabetical), then Reviewer, then Unassigned
|
||||||
|
assertEquals(3, groups.size)
|
||||||
|
|
||||||
|
assertEquals("coder", groups[0].assignee)
|
||||||
|
assertEquals(listOf("t2", "t4"), groups[0].tasks.map { it.id })
|
||||||
|
|
||||||
|
assertEquals("reviewer", groups[1].assignee)
|
||||||
|
assertEquals(listOf("t1"), groups[1].tasks.map { it.id })
|
||||||
|
|
||||||
|
assertNull(groups[2].assignee)
|
||||||
|
assertEquals(listOf("t3"), groups[2].tasks.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGroupRunningTasksWithoutUnassigned() {
|
||||||
|
val tasks =
|
||||||
|
listOf(
|
||||||
|
KanbanTask(id = "t1", title = "Task 1", status = "running", assignee = "coder"),
|
||||||
|
)
|
||||||
|
val groups = KanbanLaneGrouping.groupRunningTasks(tasks)
|
||||||
|
assertEquals(1, groups.size)
|
||||||
|
assertEquals("coder", groups[0].assignee)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testGroupRunningTasksEmpty() {
|
||||||
|
val groups = KanbanLaneGrouping.groupRunningTasks(emptyList())
|
||||||
|
assertEquals(0, groups.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class KanbanModelOverrideTest {
|
||||||
|
@Test
|
||||||
|
fun testEmptyOverrideIsInherited() {
|
||||||
|
val override = KanbanModelOverride.EMPTY
|
||||||
|
assertTrue(override.isInherited)
|
||||||
|
assertEquals("Inherit profile", override.displayLabel("Inherit profile"))
|
||||||
|
|
||||||
|
val patch = override.toUpdateTaskBody()
|
||||||
|
assertNull(patch.modelOverride)
|
||||||
|
assertNull(patch.providerOverride)
|
||||||
|
assertTrue(patch.clearModelOverride)
|
||||||
|
assertNull(patch.reasoningEffort)
|
||||||
|
assertTrue(patch.clearReasoningEffort)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCustomModelAndEffortPatch() {
|
||||||
|
val override =
|
||||||
|
KanbanModelOverride(
|
||||||
|
model = "claude-3-5-sonnet",
|
||||||
|
provider = "anthropic",
|
||||||
|
effort = "high",
|
||||||
|
)
|
||||||
|
assertFalse(override.isInherited)
|
||||||
|
assertEquals("anthropic: claude-3-5-sonnet · High", override.displayLabel())
|
||||||
|
|
||||||
|
val patch = override.toUpdateTaskBody()
|
||||||
|
assertEquals("claude-3-5-sonnet", patch.modelOverride)
|
||||||
|
assertEquals("anthropic", patch.providerOverride)
|
||||||
|
assertFalse(patch.clearModelOverride)
|
||||||
|
assertEquals("high", patch.reasoningEffort)
|
||||||
|
assertFalse(patch.clearReasoningEffort)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testExplicitNoneReasoningEffort() {
|
||||||
|
val override =
|
||||||
|
KanbanModelOverride(
|
||||||
|
model = "gpt-4o",
|
||||||
|
provider = "openai",
|
||||||
|
effort = "none",
|
||||||
|
)
|
||||||
|
assertEquals("openai: gpt-4o · None", override.displayLabel())
|
||||||
|
|
||||||
|
val patch = override.toUpdateTaskBody()
|
||||||
|
assertEquals("none", patch.reasoningEffort)
|
||||||
|
assertFalse(patch.clearReasoningEffort)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testModelClearedLeavesEffortIntact() {
|
||||||
|
val override = KanbanModelOverride(model = "", provider = "", effort = "low")
|
||||||
|
val patch = override.toUpdateTaskBody()
|
||||||
|
assertTrue(patch.clearModelOverride)
|
||||||
|
assertEquals("low", patch.reasoningEffort)
|
||||||
|
assertFalse(patch.clearReasoningEffort)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFullReasoningScaleAndCapabilities() {
|
||||||
|
assertEquals(
|
||||||
|
listOf("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"),
|
||||||
|
supportedKanbanReasoningEfforts(supportsReasoning = true, canDisableReasoning = true),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf("minimal", "low", "medium", "high", "xhigh", "max", "ultra"),
|
||||||
|
supportedKanbanReasoningEfforts(supportsReasoning = true, canDisableReasoning = false),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(""),
|
||||||
|
supportedKanbanReasoningEfforts(supportsReasoning = false, canDisableReasoning = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -374,6 +374,40 @@ class KanbanMutationTest {
|
|||||||
assertEquals("Board created: Sprint 2", vm.uiState.value.toastMessage)
|
assertEquals("Board created: Sprint 2", vm.uiState.value.toastMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBulkAssignUsesReclaimAndRetainsOnlyFailures() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
coEvery { mockRepository.getBoard("dev") } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
|
||||||
|
coEvery { mockRepository.bulkTasks("dev", any()) } returns
|
||||||
|
NetworkResult.Success(
|
||||||
|
BulkTasksResponse(
|
||||||
|
results =
|
||||||
|
listOf(
|
||||||
|
BulkTaskResult("t_1", ok = true),
|
||||||
|
BulkTaskResult("t_2", ok = false, error = "locked"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
var failedIds = emptySet<String>()
|
||||||
|
vm.bulkAssign(listOf("t_1", "t_2"), assignee = null) { failedIds = it }
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
mockRepository.bulkTasks(
|
||||||
|
"dev",
|
||||||
|
match { it.ids == listOf("t_1", "t_2") && it.assignee == "" && it.reclaimFirst },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(setOf("t_2"), failedIds)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testRenameBoardSuccess() =
|
fun testRenameBoardSuccess() =
|
||||||
runTest(testDispatcher) {
|
runTest(testDispatcher) {
|
||||||
@@ -393,26 +427,122 @@ class KanbanMutationTest {
|
|||||||
coVerify {
|
coVerify {
|
||||||
mockRepository.updateBoard("dev", match { it.name == "Dev Renamed" })
|
mockRepository.updateBoard("dev", match { it.name == "Dev Renamed" })
|
||||||
}
|
}
|
||||||
assertEquals("Board renamed to Dev Renamed", vm.uiState.value.toastMessage)
|
assertEquals("Board updated: Dev Renamed", vm.uiState.value.toastMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testDeleteBoardSuccess() =
|
fun testDeleteBoardSuccess() =
|
||||||
runTest(testDispatcher) {
|
runTest(testDispatcher) {
|
||||||
coEvery { mockRepository.deleteBoard("old-board", delete = true) } returns
|
coEvery { mockRepository.deleteBoard("old-board", delete = false) } returns
|
||||||
NetworkResult.Success(DeleteBoardResponse(current = null))
|
NetworkResult.Success(DeleteBoardResponse(current = null))
|
||||||
coEvery { mockRepository.getBoards() } returns
|
coEvery { mockRepository.getBoards(any()) } returns
|
||||||
NetworkResult.Success(
|
NetworkResult.Success(
|
||||||
com.m57.hermescontrol.data.model
|
com.m57.hermescontrol.data.model
|
||||||
.KanbanBoardsResponse(emptyList(), null),
|
.KanbanBoardsResponse(emptyList(), null),
|
||||||
)
|
)
|
||||||
|
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
|
preferences.setSelectedBoard("http://127.0.0.1:9119", "old-board")
|
||||||
vm.deleteBoard("old-board")
|
vm.deleteBoard("old-board")
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
coVerify { mockRepository.deleteBoard("old-board", delete = true) }
|
coVerify { mockRepository.deleteBoard("old-board", delete = false) }
|
||||||
assertEquals("Board deleted", vm.uiState.value.toastMessage)
|
assertEquals("Board archived", vm.uiState.value.toastMessage)
|
||||||
|
assertEquals(null, preferences.getSelectedBoard("http://127.0.0.1:9119"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDeleteDefaultBoardRefused() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.deleteBoard("default")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify(exactly = 0) { mockRepository.deleteBoard(any(), any()) }
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage
|
||||||
|
?.contains("default board") == true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSetIncludeArchivedTogglesAndReloadsBoard() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
coEvery { mockRepository.getBoard(board = "dev", includeArchived = any(), any()) } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("todo", emptyList()))))
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.setIncludeArchived(true)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertTrue(vm.uiState.value.includeArchived)
|
||||||
|
coVerify { mockRepository.getBoard(board = "dev", includeArchived = true, any()) }
|
||||||
|
|
||||||
|
vm.setIncludeArchived(false)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertFalse(vm.uiState.value.includeArchived)
|
||||||
|
coVerify { mockRepository.getBoard(board = "dev", includeArchived = false, any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBulkMovePartialFailureKeepsFailedIds() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "dev", name = "Dev")
|
||||||
|
coEvery { mockRepository.getBoard("dev", any(), any()) } returns
|
||||||
|
NetworkResult.Success(KanbanBoardResponse(columns = listOf(KanbanColumn("done", emptyList()))))
|
||||||
|
coEvery { mockRepository.bulkTasks("dev", any()) } returns
|
||||||
|
NetworkResult.Success(
|
||||||
|
BulkTasksResponse(
|
||||||
|
listOf(
|
||||||
|
BulkTaskResult("t_1", true),
|
||||||
|
BulkTaskResult("t_2", false, "Conflict"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.selectBoard(board)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
var reportedFailed: Set<String>? = null
|
||||||
|
vm.bulkMove(listOf("t_1", "t_2"), "done") { failed ->
|
||||||
|
reportedFailed = failed
|
||||||
|
}
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(setOf("t_2"), reportedFailed)
|
||||||
|
assertTrue(
|
||||||
|
vm.uiState.value.toastMessage
|
||||||
|
?.contains("1 failed") == true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCreateBoardWithProject() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val board = KanbanBoard(id = "scoped", name = "Scoped", projectId = "proj-123")
|
||||||
|
coEvery { mockRepository.createBoard(any()) } returns
|
||||||
|
NetworkResult.Success(CreateBoardResponse(board = board))
|
||||||
|
coEvery { mockRepository.getBoards(any()) } returns
|
||||||
|
NetworkResult.Success(
|
||||||
|
com.m57.hermescontrol.data.model
|
||||||
|
.KanbanBoardsResponse(listOf(board), "scoped"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.createBoard(slug = "scoped", name = "Scoped", projectId = "proj-123")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
mockRepository.createBoard(
|
||||||
|
match { it.slug == "scoped" && it.projectId == "proj-123" },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package com.m57.hermescontrol.ui.kanban
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.KanbanTask
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class KanbanRuntimeHelperTest {
|
||||||
|
@Test
|
||||||
|
fun testRunningTaskWithFreshHeartbeat() {
|
||||||
|
val now = 1000L
|
||||||
|
val task = KanbanTask(id = "t1", title = "Task 1", status = "running", lastHeartbeatAt = now - 30)
|
||||||
|
val state = KanbanRuntimeHelper.arcState(task, nowSeconds = now)
|
||||||
|
assertEquals(KanbanArcState.RUNNING, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRunningTaskWithStaleHeartbeat() {
|
||||||
|
val now = 1000L
|
||||||
|
val task = KanbanTask(id = "t1", title = "Task 1", status = "running", lastHeartbeatAt = now - 150)
|
||||||
|
val state = KanbanRuntimeHelper.arcState(task, nowSeconds = now)
|
||||||
|
assertEquals(KanbanArcState.STALE, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testQueuedTask() {
|
||||||
|
val triageTask = KanbanTask(id = "t1", title = "Task 1", status = "triage")
|
||||||
|
assertEquals(KanbanArcState.QUEUED, KanbanRuntimeHelper.arcState(triageTask))
|
||||||
|
|
||||||
|
val reviewTask = KanbanTask(id = "t2", title = "Task 2", status = "review")
|
||||||
|
assertEquals(KanbanArcState.QUEUED, KanbanRuntimeHelper.arcState(reviewTask))
|
||||||
|
|
||||||
|
val readyAssigned = KanbanTask(id = "t3", title = "Task 3", status = "ready", assignee = "coder")
|
||||||
|
assertEquals(KanbanArcState.QUEUED, KanbanRuntimeHelper.arcState(readyAssigned))
|
||||||
|
|
||||||
|
val readyWithFallback = KanbanTask(id = "t4", title = "Task 4", status = "ready", assignee = null)
|
||||||
|
assertEquals(
|
||||||
|
KanbanArcState.QUEUED,
|
||||||
|
KanbanRuntimeHelper.arcState(readyWithFallback, fallbackAssignee = "default"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val readyUnassigned = KanbanTask(id = "t5", title = "Task 5", status = "ready", assignee = null)
|
||||||
|
assertNull(KanbanRuntimeHelper.arcState(readyUnassigned, fallbackAssignee = null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testIsWontRun() {
|
||||||
|
val readyUnassigned = KanbanTask(id = "t1", title = "Task 1", status = "ready", assignee = null)
|
||||||
|
assertTrue(KanbanRuntimeHelper.isWontRun(readyUnassigned, fallbackAssignee = null))
|
||||||
|
assertFalse(KanbanRuntimeHelper.isWontRun(readyUnassigned, fallbackAssignee = "default"))
|
||||||
|
|
||||||
|
val readyAssigned = KanbanTask(id = "t2", title = "Task 2", status = "ready", assignee = "coder")
|
||||||
|
assertFalse(KanbanRuntimeHelper.isWontRun(readyAssigned, fallbackAssignee = null))
|
||||||
|
|
||||||
|
val todoUnassigned = KanbanTask(id = "t3", title = "Task 3", status = "todo", assignee = null)
|
||||||
|
assertFalse(KanbanRuntimeHelper.isWontRun(todoUnassigned))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFormatElapsed() {
|
||||||
|
val now = 200_000L
|
||||||
|
assertEquals("45s", KanbanRuntimeHelper.formatElapsed(now - 45, nowSeconds = now))
|
||||||
|
assertEquals("5m", KanbanRuntimeHelper.formatElapsed(now - 300, nowSeconds = now))
|
||||||
|
assertEquals("2h", KanbanRuntimeHelper.formatElapsed(now - 7200, nowSeconds = now))
|
||||||
|
assertEquals("1d", KanbanRuntimeHelper.formatElapsed(now - 90000, nowSeconds = now))
|
||||||
|
assertNull(KanbanRuntimeHelper.formatElapsed(null))
|
||||||
|
assertNull(KanbanRuntimeHelper.formatElapsed(now + 10, nowSeconds = now))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,12 +8,14 @@ import com.m57.hermescontrol.data.model.UpdateTaskResponse
|
|||||||
import com.m57.hermescontrol.data.model.WorkerLog
|
import com.m57.hermescontrol.data.model.WorkerLog
|
||||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||||
import com.m57.hermescontrol.data.repository.KanbanRepository
|
import com.m57.hermescontrol.data.repository.KanbanRepository
|
||||||
|
import com.m57.hermescontrol.data.ws.KanbanEventsClient
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||||
|
import kotlinx.coroutines.test.advanceTimeBy
|
||||||
import kotlinx.coroutines.test.resetMain
|
import kotlinx.coroutines.test.resetMain
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.coroutines.test.setMain
|
import kotlinx.coroutines.test.setMain
|
||||||
@@ -28,10 +30,13 @@ import org.junit.Test
|
|||||||
class KanbanTaskViewModelTest {
|
class KanbanTaskViewModelTest {
|
||||||
private val testDispatcher = StandardTestDispatcher()
|
private val testDispatcher = StandardTestDispatcher()
|
||||||
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
|
private val mockRepository = mockk<KanbanRepository>(relaxed = true)
|
||||||
|
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
|
||||||
|
|
||||||
private fun createViewModel(): KanbanTaskViewModel =
|
private fun createViewModel(enableFallbackPolling: Boolean = false): KanbanTaskViewModel =
|
||||||
KanbanTaskViewModel(
|
KanbanTaskViewModel(
|
||||||
repository = mockRepository,
|
repository = mockRepository,
|
||||||
|
eventsClientProvider = { mockEventsClient },
|
||||||
|
enableFallbackPolling = enableFallbackPolling,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
@@ -75,7 +80,7 @@ class KanbanTaskViewModelTest {
|
|||||||
|
|
||||||
val vm = createViewModel()
|
val vm = createViewModel()
|
||||||
vm.loadTask("dev", "t_1")
|
vm.loadTask("dev", "t_1")
|
||||||
testDispatcher.scheduler.advanceUntilIdle()
|
testDispatcher.scheduler.runCurrent()
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"Log line 1",
|
"Log line 1",
|
||||||
@@ -83,6 +88,7 @@ class KanbanTaskViewModelTest {
|
|||||||
?.content,
|
?.content,
|
||||||
)
|
)
|
||||||
coVerify { mockRepository.getTaskLog("t_1", "dev", any()) }
|
coVerify { mockRepository.getTaskLog("t_1", "dev", any()) }
|
||||||
|
vm.onCleared()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -133,7 +139,7 @@ class KanbanTaskViewModelTest {
|
|||||||
@Test
|
@Test
|
||||||
fun testPostNoteAndRequeue() =
|
fun testPostNoteAndRequeue() =
|
||||||
runTest(testDispatcher) {
|
runTest(testDispatcher) {
|
||||||
val task = KanbanTaskFull(id = "t_1", title = "Running task", status = "running")
|
val task = KanbanTaskFull(id = "t_1", title = "Task 1", status = "todo")
|
||||||
coEvery { mockRepository.getTask("t_1", "dev") } returns
|
coEvery { mockRepository.getTask("t_1", "dev") } returns
|
||||||
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
|
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
|
||||||
coEvery { mockRepository.addComment("t_1", "dev", "Stop now") } returns
|
coEvery { mockRepository.addComment("t_1", "dev", "Stop now") } returns
|
||||||
@@ -197,4 +203,122 @@ class KanbanTaskViewModelTest {
|
|||||||
coVerify { mockRepository.uploadAttachment("t_1", "dev", any()) }
|
coVerify { mockRepository.uploadAttachment("t_1", "dev", any()) }
|
||||||
assertEquals("Attachment uploaded", vm.uiState.value.toastMessage)
|
assertEquals("Attachment uploaded", vm.uiState.value.toastMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testUpdateModelOverride() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val task = KanbanTaskFull(id = "t_1", title = "Task 1", status = "todo")
|
||||||
|
coEvery { mockRepository.getTask("t_1", "dev") } returns
|
||||||
|
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
|
||||||
|
coEvery { mockRepository.updateTask("t_1", "dev", any()) } returns
|
||||||
|
NetworkResult.Success(UpdateTaskResponse())
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadTask("dev", "t_1")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
val override = KanbanModelOverride(model = "claude-3-5-sonnet", provider = "anthropic", effort = "high")
|
||||||
|
vm.updateModelOverride("dev", "t_1", override)
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify {
|
||||||
|
mockRepository.updateTask(
|
||||||
|
"t_1",
|
||||||
|
"dev",
|
||||||
|
match {
|
||||||
|
it.modelOverride == "claude-3-5-sonnet" &&
|
||||||
|
it.providerOverride == "anthropic" &&
|
||||||
|
it.reasoningEffort == "high" &&
|
||||||
|
!it.clearModelOverride
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals("Model override updated", vm.uiState.value.toastMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testEstimateTask() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val task = KanbanTaskFull(id = "t_1", title = "Task 1", status = "todo")
|
||||||
|
coEvery { mockRepository.getTask("t_1", "dev") } returns
|
||||||
|
NetworkResult.Success(KanbanTaskDetailResponse(task = task))
|
||||||
|
coEvery { mockRepository.estimateTask("t_1", "dev") } returns
|
||||||
|
NetworkResult.Success(
|
||||||
|
com.m57.hermescontrol.data.model.TaskEstimate(
|
||||||
|
ok = true,
|
||||||
|
estTokens = 1500,
|
||||||
|
complexity = "medium",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
vm.loadTask("dev", "t_1")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
vm.estimateTask("dev", "t_1")
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify { mockRepository.estimateTask("t_1", "dev") }
|
||||||
|
assertEquals(
|
||||||
|
1500,
|
||||||
|
vm.uiState.value.estimate
|
||||||
|
?.tokens,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"medium",
|
||||||
|
vm.uiState.value.estimate
|
||||||
|
?.complexity,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDeleteTask() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
coEvery { mockRepository.deleteTask("t_1", "dev") } returns NetworkResult.Success(Unit)
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
var deleted = false
|
||||||
|
vm.deleteTask("dev", "t_1") {
|
||||||
|
deleted = true
|
||||||
|
}
|
||||||
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
|
|
||||||
|
coVerify { mockRepository.deleteTask("t_1", "dev") }
|
||||||
|
assertTrue(deleted)
|
||||||
|
assertEquals("Task deleted", vm.uiState.value.toastMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFallbackPollingReloadsTaskWithoutEvents() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val first = KanbanTaskFull(id = "t_1", title = "Before", status = "todo")
|
||||||
|
val second = KanbanTaskFull(id = "t_1", title = "After", status = "todo")
|
||||||
|
coEvery { mockRepository.getTask("t_1", "dev") } returnsMany
|
||||||
|
listOf(
|
||||||
|
NetworkResult.Success(KanbanTaskDetailResponse(task = first)),
|
||||||
|
NetworkResult.Success(KanbanTaskDetailResponse(task = second)),
|
||||||
|
)
|
||||||
|
|
||||||
|
val vm = createViewModel(enableFallbackPolling = true)
|
||||||
|
vm.loadTask("dev", "t_1")
|
||||||
|
testDispatcher.scheduler.runCurrent()
|
||||||
|
assertEquals(
|
||||||
|
"Before",
|
||||||
|
vm.uiState.value.detail
|
||||||
|
?.task
|
||||||
|
?.title,
|
||||||
|
)
|
||||||
|
|
||||||
|
testDispatcher.scheduler.advanceTimeBy(30_000L)
|
||||||
|
testDispatcher.scheduler.runCurrent()
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
"After",
|
||||||
|
vm.uiState.value.detail
|
||||||
|
?.task
|
||||||
|
?.title,
|
||||||
|
)
|
||||||
|
coVerify(exactly = 2) { mockRepository.getTask("t_1", "dev") }
|
||||||
|
vm.onCleared()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.m57.hermescontrol.util
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.net.Uri
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
|
import okio.Buffer
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
class StreamingUriRequestBodyTest {
|
||||||
|
@Test
|
||||||
|
fun testStreamsDirectlyIntoSink() {
|
||||||
|
val testData = "test archive data streaming content".toByteArray(Charsets.UTF_8)
|
||||||
|
val mockResolver = mockk<ContentResolver>()
|
||||||
|
val mockUri = mockk<Uri>()
|
||||||
|
|
||||||
|
every { mockResolver.openInputStream(mockUri) } answers {
|
||||||
|
ByteArrayInputStream(testData)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaType = "application/gzip".toMediaTypeOrNull()
|
||||||
|
val requestBody =
|
||||||
|
StreamingUriRequestBody(
|
||||||
|
contentResolver = mockResolver,
|
||||||
|
uri = mockUri,
|
||||||
|
contentType = mediaType,
|
||||||
|
contentLength = testData.size.toLong(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(mediaType, requestBody.contentType())
|
||||||
|
assertEquals(testData.size.toLong(), requestBody.contentLength())
|
||||||
|
|
||||||
|
val buffer = Buffer()
|
||||||
|
requestBody.writeTo(buffer)
|
||||||
|
|
||||||
|
assertEquals("test archive data streaming content", buffer.readUtf8())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testThrowsWhenInputStreamCannotBeOpened() {
|
||||||
|
val mockResolver = mockk<ContentResolver>()
|
||||||
|
val mockUri = mockk<Uri>()
|
||||||
|
|
||||||
|
every { mockResolver.openInputStream(mockUri) } returns null
|
||||||
|
|
||||||
|
val requestBody =
|
||||||
|
StreamingUriRequestBody(
|
||||||
|
contentResolver = mockResolver,
|
||||||
|
uri = mockUri,
|
||||||
|
contentType = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
var thrown = false
|
||||||
|
try {
|
||||||
|
val buffer = Buffer()
|
||||||
|
requestBody.writeTo(buffer)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
thrown = true
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(thrown)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user