feat(kanban): live board updates over plugin events WebSocket + kanban UI pass (#789)

* feat(kanban): live board updates over plugin events WebSocket (#775)

Tail /api/plugins/kanban/events per board with cursor tracking and
backoff reconnect; debounced REST reload on event batches (desktop's
proven pattern — event payloads are partial updates). Board switch
re-subscribes with a fresh stream. Reuses HermesWsClient's ticket
refresh (now internal). Adds ServerEndpoint.webSocketUrl(path, ...)
overload for non-chat WS endpoints.

Kanban UI pass: add-task FAB (the AddTaskDialog was unreachable),
live/offline status pill, assignee chip on task cards, 2-line
description clamp, empty-column hint.

Tests: KanbanEventsClient frame parsing, KanbanViewModel WS wiring
(connect on load, debounced reload, board-switch reconnect,
no-reconnect guard, onCleared), ServerEndpoint overload.

* fix(kanban): create task response is wrapped {"task": ...} — read it as Unit

Backend POST /api/plugins/kanban/tasks returns {"task": {...}} (wrapped),
but the app declared Response<KanbanTask> — with ignoreUnknownKeys=true
the wrapper key is skipped and the required id/title/status fields are
missing, so deserialization threw and the create showed a failure toast
while the task WAS created server-side (ghost tasks). The body is never
consumed by the ViewModel, so Response<Unit> matches updateKanbanTask.

FAB from the live-events PR (#775) made this latent bug reachable.
Adds an E2e regression assertion for the create path.

* feat(kanban): remove move left/right arrows — backend rejects the transitions

The dashboard PATCH route's status whitelist omits 'review' (a first-class
kanban_db status), so any move touching that column 400s, and reopen
transitions (done->blocked) 409. Every task on the live board sits in
'done', making the arrows 100% failure. Removing them keeps the UI honest;
tasks are managed from the desktop/CLI.

Drops moveTask/revertTaskMove from the VM, the arrows from TaskCard, the
now-dead updateKanbanTask API method and move strings; E2e test keeps
load/create coverage.

* feat(kanban): desktop-style task transitions — gated action sheet

Mirrors the desktop kanban's move model: tap a card to open an action
sheet with only the transitions the backend PATCH route accepts:

- triage / ready from any status (direct write; unblock from blocked)
- block only from running/ready, complete only from running/ready/blocked
- archive from any non-archived status
- never running (dispatcher-only, backend 400s) and never review (not in
  the dashboard whitelist) — the moves that made the old arrows lie

Complete prompts for a mandatory completion summary (stored as result),
matching the desktop; block/complete/archive ask for confirmation.
Optimistic update with revert + toast on failure.

Tests: transition-gating map, move PATCH body, failure revert, completion
summary body; E2e kanban test covers a desktop-style todo->ready move.

* ui(kanban): replace oversized FAB with compact top-bar add button

The 56dp FAB floated over the board's bottom-right corner and covered a
chunk of the last column. The add action now lives in the header row next
to the live pill (input-top style), and the board's bottom contentPadding
drops back to 16dp — no more reserved dead zone.

* fix(kanban): mint an independent WS ticket — don't touch the chat's shared slot

The dashboard WS ticket is single-use and consumed at handshake. Both the
chat client and the kanban events client were refreshing into the SAME
shared AuthManager token slot on their own schedules (the kanban client on
every backoff reconnect, forever). Whichever client built its URL second
grabbed the other's just-consumed ticket, got 1008, reconnected, and stole
the other's next ticket — mutual starvation that left the app stuck at
'Reconnecting...' (repro on main: none, because main has one consumer).

HermesWsClient now exposes mintWsTicket(): a ticket minted WITHOUT touching
the shared slot (cookie-auth'd, same endpoint). The kanban events client
uses its own ticket per connection; the chat WS keeps the shared slot.
Refresh's HTTP-error -> status mapping is preserved via a small result
holder.

* fix(kanban): drop orphaned ko translations for removed move buttons

The move arrows were removed earlier but values-ko still shipped
content_desc_move_left/right — Android lint ExtraTranslation failed CI.

* fix(ws): keep RECONNECTING on transient ticket-mint exceptions

My refactor flattened the ticket-mint failure mapping: a network exception
during POST /api/auth/ws-ticket was reported as a null HTTP code and
mapped to DISCONNECTED, breaking the #740 contract that transient failures
stay in RECONNECTING (and with it testGatedMode_transientTicketFailure-
KeepsReconnectFlow). TicketRequestResult now carries an exception flag and
refreshWsTicketIfNeeded maps it to RECONNECTING.

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-03 18:19:08 +03:00
1 parent 37900a9af3
commit 44767fef4a
12 files changed
+1110 -127

No files matched your search

@@ -33,7 +33,6 @@ import com.m57.hermescontrol.data.model.HealthStatus
import com.m57.hermescontrol.data.model.HookResponse
import com.m57.hermescontrol.data.model.KanbanBoardResponse
import com.m57.hermescontrol.data.model.KanbanBoardsResponse
import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.model.LearningGraphResponse
import com.m57.hermescontrol.data.model.LogResponse
import com.m57.hermescontrol.data.model.ManagedDirectoryCreate
@@ -663,7 +662,7 @@ interface HermesApiService {
suspend fun createKanbanTask(
@Query("board") board: String?,
@Body task: CreateTaskBody,
): Response<KanbanTask>
): Response<Unit>
// ── Admin: Hermes update ──────────────────────────────────────────
@GET("api/hermes/update/check")
@@ -33,15 +33,31 @@ class ServerEndpoint private constructor(
fun webSocketUrl(
authParameter: String,
credential: String,
): String =
webSocketUrl(
path = "api/ws",
authParameter = authParameter,
credential = credential,
)
/**
* Build a WebSocket URL for an arbitrary dashboard WS endpoint (e.g. the
* kanban plugin's live-events stream), preserving the reverse-proxy path
* prefix, converting http(s) to ws(s), and appending the auth parameter
* plus any extra query parameters (e.g. `since`, `board`).
*/
fun webSocketUrl(
path: String,
authParameter: String,
credential: String,
extraParams: Map<String, String> = emptyMap(),
): String {
require(authParameter == "token" || authParameter == "ticket") {
"Unsupported WebSocket authentication parameter"
}
val socketHttpUrl =
resolve("api/ws")
.newBuilder()
.addQueryParameter(authParameter, credential)
.build()
val builder = resolve(path).newBuilder().addQueryParameter(authParameter, credential)
extraParams.forEach { (key, value) -> builder.addQueryParameter(key, value) }
val socketHttpUrl = builder.build()
val socketScheme = if (baseUrl.isHttps) "wss" else "ws"
return replaceScheme(socketHttpUrl.toString(), socketScheme)
}
@@ -260,7 +260,7 @@ object HermesWsClient {
* or because ticket refresh succeeded). Returns false if we are in gated mode
* and ticket refresh failed or cannot be performed.
*/
private fun refreshWsTicketIfNeeded(): Boolean {
internal fun refreshWsTicketIfNeeded(): Boolean {
val isGated =
try {
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
@@ -279,6 +279,59 @@ object HermesWsClient {
return true
}
val ticketResult = requestWsTicket()
if (!ticketResult.ticket.isNullOrBlank()) {
AuthManager.setToken(ticketResult.ticket)
if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed")
return true
}
val status =
when {
ticketResult.exception -> ConnectionStatus.RECONNECTING
ticketResult.httpCode == 401 || ticketResult.httpCode == 403 -> ConnectionStatus.AUTH_EXPIRED
ticketResult.httpCode == 408 || ticketResult.httpCode == 429 -> ConnectionStatus.RECONNECTING
ticketResult.httpCode != null && ticketResult.httpCode >= 500 -> ConnectionStatus.RECONNECTING
else -> ConnectionStatus.DISCONNECTED
}
return handleWsTicketRefreshFailure(status)
}
/**
* Mint a fresh WS ticket for a *secondary* consumer (e.g. the kanban
* events stream) WITHOUT touching the shared token slot.
*
* The shared slot is a single-use ticket that the chat WebSocket consumes
* at handshake. If two clients mint and stash into the same slot on their
* own schedules, each can grab the other's just-consumed ticket and the
* pair starves each other into a permanent reconnect loop. Secondary
* consumers must use their own ticket per connection.
*
* Gated mode: cookie-auth'd POST to /api/auth/ws-ticket, returns the
* ticket. Loopback mode: refresh the dashboard token and return it.
* Returns null when a ticket could not be obtained.
*/
internal fun mintWsTicket(): String? {
val isGated =
try {
AuthManager.serverStore.getLatestState().wsAuthParam == "ticket"
} catch (_: IllegalStateException) {
false
}
if (!isGated) {
DashboardSessionTokenRefresher.refresh()
return AuthManager.getToken()
}
return requestWsTicket().ticket
}
private data class TicketRequestResult(
val ticket: String?,
val httpCode: Int?,
val exception: Boolean = false,
)
/** POST /api/auth/ws-ticket (cookie-auth'd via the shared CookieJar) and parse the ticket. */
private fun requestWsTicket(): TicketRequestResult {
try {
val client = OkHttpProvider.probe
val request =
@@ -300,29 +353,19 @@ object HermesWsClient {
val ticketMatch = Regex("""\"ticket\":\"([^\"]+)\"""").find(body)
val ticket = ticketMatch?.groupValues?.getOrNull(1)
if (!ticket.isNullOrBlank()) {
AuthManager.setToken(ticket)
if (BuildConfig.DEBUG) Log.d(TAG, "WS ticket refreshed")
return true
} else {
Log.w(TAG, "WS ticket refresh failed: response body did not contain ticket")
return handleWsTicketRefreshFailure(ConnectionStatus.DISCONNECTED)
return TicketRequestResult(ticket, null)
}
Log.w(TAG, "WS ticket mint failed: response body did not contain ticket")
} else {
Log.w(TAG, "WS ticket refresh failed: HTTP ${response.code}")
val status =
when {
response.code == 401 || response.code == 403 -> ConnectionStatus.AUTH_EXPIRED
response.code == 408 || response.code == 429 || response.code >= 500 ->
ConnectionStatus.RECONNECTING
else -> ConnectionStatus.DISCONNECTED
}
return handleWsTicketRefreshFailure(status)
Log.w(TAG, "WS ticket mint failed: HTTP ${response.code}")
return TicketRequestResult(null, response.code)
}
}
} catch (e: Exception) {
Log.w(TAG, "WS ticket refresh failed: ${e.javaClass.simpleName}")
return handleWsTicketRefreshFailure(ConnectionStatus.RECONNECTING)
Log.w(TAG, "WS ticket mint failed: ${e.javaClass.simpleName}")
return TicketRequestResult(null, null, exception = true)
}
return TicketRequestResult(null, null)
}
private fun handleWsTicketRefreshFailure(status: ConnectionStatus): Boolean {
@@ -0,0 +1,239 @@
package com.m57.hermescontrol.data.ws
import android.util.Log
import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.JsonObject
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
/** Connection state of the kanban live-events stream. */
enum class KanbanLiveStatus {
/** Stream open and receiving events. */
CONNECTED,
/** Stream closed; reconnecting with backoff (or waiting for a board switch). */
DISCONNECTED,
/** Stream rejected with 1008 / credential refresh failed; not retrying. */
AUTH_FAILED,
}
/** One row of the backend's ``task_events`` table (``/api/plugins/kanban/events``). */
@Serializable
data class KanbanEvent(
val id: Long,
@SerialName("task_id") val taskId: String? = null,
@SerialName("run_id") val runId: String? = null,
val kind: String,
/** Partial update (e.g. ``{"status": "done"}``) — never applied directly. */
val payload: JsonObject? = null,
@SerialName("created_at") val createdAt: Long? = null,
)
/** Server frame: ``{"events": [...], "cursor": N}``. */
@Serializable
data class KanbanEventsEnvelope(
val events: List<KanbanEvent> = emptyList(),
val cursor: Long? = null,
)
/**
* Parse a kanban events WS frame. Returns null when the frame is not a valid
* envelope (heartbeats, partial writes, unrelated payloads) so the caller can
* ignore it safely.
*/
internal fun parseKanbanEventsFrame(json: String): KanbanEventsEnvelope? =
try {
OkHttpProvider.json.decodeFromString<KanbanEventsEnvelope>(json)
} catch (_: SerializationException) {
null
} catch (_: IllegalArgumentException) {
null
}
/**
* Scoped WebSocket tail of the kanban plugin's live-events stream
* (``/api/plugins/kanban/events`` — backend: plugin_api.py ``stream_events``).
*
* The backend pins the board at the WS handshake (``board`` query param) and
* replays events past the ``since`` cursor, so switching boards means opening a
* new connection with a fresh cursor. Event payloads are partial updates, so
* consumers are expected to re-fetch the board from REST on any event batch —
* the desktop dashboard uses the same debounced-reload pattern.
*/
class KanbanEventsClient {
// Deferred: building the OkHttp client touches the shared CookieJar
// (Android context), which must not happen at construction time — the
// client is only built when a stream is actually opened.
private val httpClient by lazy { OkHttpProvider.websocket }
private var webSocket: WebSocket? = null
private var reconnectJob: Job? = null
private var generation = 0
private var closed = true
private var backoffMs = INITIAL_BACKOFF_MS
private var cursor: Long = 0
/**
* Open (or re-open) the events stream for [board]. A previous connection is
* torn down first — safe to call on board switch.
*
* [onEvents] is invoked with each non-empty envelope; [onStatus] with every
* state transition. Both are dispatched on [scope] (caller's main scope).
*/
fun connect(
scope: CoroutineScope,
board: String,
since: Long = 0,
onEvents: (KanbanEventsEnvelope) -> Unit,
onStatus: (KanbanLiveStatus) -> Unit,
) {
val gen = ++generation
reconnectJob?.cancel()
webSocket?.cancel()
closed = false
backoffMs = INITIAL_BACKOFF_MS
cursor = since
openSocket(scope, gen, board, onEvents, onStatus)
}
/** Close the stream and cancel any pending reconnect. */
fun disconnect() {
closed = true
generation++
reconnectJob?.cancel()
reconnectJob = null
webSocket?.cancel()
webSocket = null
}
private fun openSocket(
scope: CoroutineScope,
gen: Int,
board: String,
onEvents: (KanbanEventsEnvelope) -> Unit,
onStatus: (KanbanLiveStatus) -> Unit,
) {
if (closed || gen != generation) return
val credential = HermesWsClient.mintWsTicket()
if (credential.isNullOrBlank()) {
onStatus(KanbanLiveStatus.AUTH_FAILED)
return
}
val rawAuthParam = AuthManager.serverStore.getLatestState().wsAuthParam
val authParam = if (rawAuthParam.isBlank()) "token" else rawAuthParam
val url =
AuthManager.endpointForBuild().webSocketUrl(
path = KANBAN_EVENTS_PATH,
authParameter = authParam,
credential = credential,
extraParams = mapOf("since" to cursor.toString(), "board" to board),
)
val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED")
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
val request = Request.Builder().url(url).build()
webSocket =
httpClient.newWebSocket(
request,
object : WebSocketListener() {
override fun onOpen(
ws: WebSocket,
response: Response,
) {
if (closed || gen != generation) return
backoffMs = INITIAL_BACKOFF_MS
scope.launch { onStatus(KanbanLiveStatus.CONNECTED) }
}
override fun onMessage(
ws: WebSocket,
text: String,
) {
if (closed || gen != generation) return
val envelope = parseKanbanEventsFrame(text) ?: return
if (envelope.events.isEmpty()) return
envelope.cursor?.let { cursor = it }
scope.launch { onEvents(envelope) }
}
override fun onMessage(
ws: WebSocket,
bytes: ByteString,
) = Unit
override fun onClosing(
ws: WebSocket,
code: Int,
reason: String,
) {
ws.close(code, reason)
}
override fun onClosed(
ws: WebSocket,
code: Int,
reason: String,
) {
if (closed || gen != generation) return
if (code == WS_1008_POLICY_VIOLATION) {
scope.launch { onStatus(KanbanLiveStatus.AUTH_FAILED) }
return
}
scheduleReconnect(scope, gen, board, onEvents, onStatus)
}
override fun onFailure(
ws: WebSocket,
t: Throwable,
response: Response?,
) {
if (closed || gen != generation) return
if (BuildConfig.DEBUG) Log.d(TAG, "Stream failure: ${t.javaClass.simpleName}")
scheduleReconnect(scope, gen, board, onEvents, onStatus)
}
},
)
}
private fun scheduleReconnect(
scope: CoroutineScope,
gen: Int,
board: String,
onEvents: (KanbanEventsEnvelope) -> Unit,
onStatus: (KanbanLiveStatus) -> Unit,
) {
if (closed || gen != generation) return
scope.launch { onStatus(KanbanLiveStatus.DISCONNECTED) }
reconnectJob?.cancel()
reconnectJob =
scope.launch {
delay(backoffMs)
backoffMs = (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
openSocket(scope, gen, board, onEvents, onStatus)
}
}
private companion object {
const val TAG = "KanbanEvents"
const val KANBAN_EVENTS_PATH = "api/plugins/kanban/events"
const val INITIAL_BACKOFF_MS = 1_000L
const val MAX_BACKOFF_MS = 30_000L
const val WS_1008_POLICY_VIOLATION = 1008
}
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.kanban
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -15,9 +16,11 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
@@ -39,6 +42,7 @@ 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -79,6 +83,9 @@ fun KanbanScreen(
filteredTasks.groupBy { it.status.lowercase() }
}
var showAddTaskDialog by remember { mutableStateOf(false) }
var taskForActions by remember { mutableStateOf<KanbanTask?>(null) }
var confirmTarget by remember { mutableStateOf<Pair<KanbanTask, KanbanTaskAction>?>(null) }
var summaryTarget by remember { mutableStateOf<Pair<KanbanTask, KanbanTaskAction>?>(null) }
LaunchedEffect(Unit) {
viewModel.loadBoards()
@@ -150,9 +157,33 @@ fun KanbanScreen(
Text(stringResource(R.string.kanban_no_columns))
}
} else {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically,
) {
LiveStatusPill(isLive = state.isLive)
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = { showAddTaskDialog = true }) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = stringResource(R.string.kanban_add_task),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
LazyRow(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
contentPadding =
PaddingValues(
start = 16.dp,
top = 16.dp,
end = 16.dp,
bottom = 16.dp,
),
horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
items(state.columns.size, key = { index ->
@@ -161,8 +192,6 @@ fun KanbanScreen(
val column = state.columns[columnIndex]
val colName = column.name
val colTasks = tasksByColumn[colName.lowercase()] ?: emptyList()
val prevColumn = state.columns.getOrNull(columnIndex - 1)
val nextColumn = state.columns.getOrNull(columnIndex + 1)
Column(
modifier =
@@ -179,23 +208,32 @@ fun KanbanScreen(
modifier = Modifier.padding(bottom = 8.dp),
)
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(colTasks, key = { it.id }) { task ->
TaskCard(
task = task,
onMoveLeft =
prevColumn?.let {
{ viewModel.moveTask(task, it.name) }
},
onMoveRight =
nextColumn?.let {
{ viewModel.moveTask(task, it.name) }
},
if (colTasks.isEmpty()) {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(R.string.kanban_no_tasks),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
} else {
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(colTasks, key = { it.id }) { task ->
TaskCard(
task = task,
onTaskClick = { taskForActions = it },
)
}
}
}
}
}
@@ -213,6 +251,47 @@ fun KanbanScreen(
},
)
}
taskForActions?.let { task ->
val actions = kanbanActionsForStatus(task.status)
if (actions.isNotEmpty()) {
TaskActionSheet(
task = task,
actions = actions,
onAction = { action ->
taskForActions = null
when {
action.needsSummary -> summaryTarget = task to action
action.needsConfirm -> confirmTarget = task to action
else -> viewModel.moveTask(task, action)
}
},
onDismiss = { taskForActions = null },
)
}
}
confirmTarget?.let { (task, action) ->
ConfirmActionDialog(
message = stringResource(action.confirmRes()),
onConfirm = {
confirmTarget = null
if (action.needsSummary) {
summaryTarget = task to action
} else {
viewModel.moveTask(task, action)
}
},
onDismiss = { confirmTarget = null },
)
}
summaryTarget?.let { (task, action) ->
CompleteTaskDialog(
onConfirm = { summary -> viewModel.moveTask(task, action, summary) },
onDismiss = { summaryTarget = null },
)
}
}
}
}
@@ -222,48 +301,65 @@ fun KanbanScreen(
@Composable
fun TaskCard(
task: KanbanTask,
onMoveLeft: (() -> Unit)?,
onMoveRight: (() -> Unit)?,
onTaskClick: (KanbanTask) -> Unit,
) {
Card(modifier = Modifier.fillMaxWidth()) {
Card(onClick = { onTaskClick(task) }, modifier = Modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(12.dp)) {
Text(text = task.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(4.dp))
task.description?.let {
Text(text = it, style = MaterialTheme.typography.bodyMedium)
Text(
text = it,
style = MaterialTheme.typography.bodyMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
if (onMoveLeft != null) {
IconButton(onClick = onMoveLeft) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.content_desc_move_left),
)
}
} else {
Spacer(modifier = Modifier.size(48.dp))
}
if (onMoveRight != null) {
IconButton(onClick = onMoveRight) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = stringResource(R.string.content_desc_move_right),
)
}
} else {
Spacer(modifier = Modifier.size(48.dp))
task.assignedTo?.let { assignee ->
Spacer(modifier = Modifier.height(6.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Person,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = assignee,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
@Composable
private fun LiveStatusPill(isLive: Boolean) {
val color =
if (isLive) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier =
Modifier
.size(8.dp)
.background(color = color, shape = CircleShape),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = stringResource(if (isLive) R.string.kanban_live else R.string.kanban_offline),
style = MaterialTheme.typography.labelMedium,
color = color,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddTaskDialog(
@@ -308,3 +404,111 @@ fun AddTaskDialog(
},
)
}
private fun KanbanTaskAction.labelRes(): Int =
when (this) {
KanbanTaskAction.TRIAGE -> R.string.kanban_action_triage
KanbanTaskAction.READY -> R.string.kanban_action_ready
KanbanTaskAction.UNBLOCK -> R.string.kanban_action_unblock
KanbanTaskAction.BLOCK -> R.string.kanban_action_block
KanbanTaskAction.COMPLETE -> R.string.kanban_action_complete
KanbanTaskAction.ARCHIVE -> R.string.kanban_action_archive
}
private fun KanbanTaskAction.confirmRes(): Int =
when (this) {
KanbanTaskAction.BLOCK -> R.string.kanban_confirm_blocked
KanbanTaskAction.COMPLETE -> R.string.kanban_confirm_done
KanbanTaskAction.ARCHIVE -> R.string.kanban_confirm_archive
else -> error("Action $this has no confirm message")
}
@Composable
private fun TaskActionSheet(
task: KanbanTask,
actions: List<KanbanTaskAction>,
onAction: (KanbanTaskAction) -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(task.title, maxLines = 2, overflow = TextOverflow.Ellipsis) },
text = {
Column {
actions.forEach { action ->
Button(
onClick = { onAction(action) },
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
) {
Text(stringResource(action.labelRes()))
}
}
}
},
confirmButton = {},
dismissButton = {
Button(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
@Composable
private fun ConfirmActionDialog(
message: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
text = { Text(message) },
confirmButton = {
Button(onClick = onConfirm) {
Text(stringResource(R.string.action_confirm))
}
},
dismissButton = {
Button(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
@Composable
private fun CompleteTaskDialog(
onConfirm: (String) -> Unit,
onDismiss: () -> Unit,
) {
var summary by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.kanban_complete_title)) },
text = {
OutlinedTextField(
value = summary,
onValueChange = { summary = it },
label = { Text(stringResource(R.string.kanban_complete_summary_label)) },
supportingText = { Text(stringResource(R.string.kanban_complete_summary_hint)) },
modifier = Modifier.fillMaxWidth(),
)
},
confirmButton = {
Button(
onClick = { onConfirm(summary.trim()) },
enabled = summary.isNotBlank(),
) {
Text(stringResource(R.string.kanban_action_complete))
}
},
dismissButton = {
Button(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
@@ -8,9 +8,13 @@ import com.m57.hermescontrol.data.model.KanbanTask
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.KanbanEventsClient
import com.m57.hermescontrol.data.ws.KanbanLiveStatus
import com.m57.hermescontrol.ui.common.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchLoad
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -24,16 +28,74 @@ data class KanbanUiState(
val selectedBoard: KanbanBoard? = null,
val columns: List<KanbanColumn> = emptyList(),
val tasks: List<KanbanTask> = emptyList(),
val isLive: Boolean = false,
val errorMessage: String? = null,
val toastMessage: String? = null,
)
class KanbanViewModel :
ViewModel(),
ToastHost {
/**
* Task actions mirroring the desktop kanban's transition-gated buttons.
* Only moves the backend PATCH route accepts are offered: never `running`
* (dispatcher-only, backend rejects with 400) and never `review` (not in
* the dashboard whitelist).
*/
enum class KanbanTaskAction(
val targetStatus: String,
val needsConfirm: Boolean = false,
val needsSummary: Boolean = false,
) {
TRIAGE(targetStatus = "triage"),
READY(targetStatus = "ready"),
UNBLOCK(targetStatus = "ready"),
BLOCK(targetStatus = "blocked", needsConfirm = true),
COMPLETE(targetStatus = "done", needsConfirm = true, needsSummary = true),
ARCHIVE(targetStatus = "archived", needsConfirm = true),
}
/** Desktop parity: which actions are valid from a given status. */
fun kanbanActionsForStatus(status: String): List<KanbanTaskAction> =
when (status) {
"triage" -> listOf(KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
"todo" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
"scheduled" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
"ready" ->
listOf(
KanbanTaskAction.TRIAGE,
KanbanTaskAction.BLOCK,
KanbanTaskAction.COMPLETE,
KanbanTaskAction.ARCHIVE,
)
"running" ->
listOf(
KanbanTaskAction.TRIAGE,
KanbanTaskAction.READY,
KanbanTaskAction.BLOCK,
KanbanTaskAction.COMPLETE,
KanbanTaskAction.ARCHIVE,
)
"blocked" ->
listOf(
KanbanTaskAction.TRIAGE,
KanbanTaskAction.UNBLOCK,
KanbanTaskAction.COMPLETE,
KanbanTaskAction.ARCHIVE,
)
"review" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
"done" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY, KanbanTaskAction.ARCHIVE)
"archived" -> listOf(KanbanTaskAction.TRIAGE, KanbanTaskAction.READY)
else -> emptyList()
}
class KanbanViewModel(
private val eventsClientProvider: () -> KanbanEventsClient = { KanbanEventsClient() },
) : ViewModel(), ToastHost {
private val _uiState = MutableStateFlow(KanbanUiState())
val uiState: StateFlow<KanbanUiState> = _uiState.asStateFlow()
private var eventsClient: KanbanEventsClient? = null
private var eventsBoard: String? = null
private var reloadJob: Job? = null
fun loadBoards() {
safeLaunchLoad(
apiCall = { safeApiCall { ApiClient.hermesApi.getKanbanBoards() } },
@@ -75,32 +137,8 @@ class KanbanViewModel :
return@launch
}
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
}
when (result) {
is NetworkResult.Success -> {
val body = result.data
val allTasks = body.columns.flatMap { it.tasks }
_uiState.update {
it.copy(
isLoading = false,
columns = body.columns,
tasks = allTasks,
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban tasks: ${result.error.message}",
)
}
}
}
loadBoardIntoState(board)
connectEvents(board)
}
}
@@ -139,28 +177,35 @@ class KanbanViewModel :
fun moveTask(
task: KanbanTask,
newStatus: String,
action: KanbanTaskAction,
summary: String? = null,
) {
val originalStatus = task.status
val board = _uiState.value.selectedBoard ?: return
// Optimistically update
// Optimistically update, desktop-style
_uiState.update { state ->
state.copy(
tasks =
state.tasks.map {
if (it.id == task.id) it.copy(status = newStatus) else it
if (it.id == task.id) it.copy(status = action.targetStatus) else it
},
)
}
viewModelScope.launch {
val body =
buildMap<String, String?> {
put("status", action.targetStatus)
if (action.needsSummary && !summary.isNullOrBlank()) {
put("result", summary)
put("summary", summary)
}
}
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.updateKanbanTask(task.id, mapOf("status" to newStatus)) }
safeApiCall { ApiClient.hermesApi.updateKanbanTask(task.id, body) }
}
if (result is NetworkResult.Failure) {
revertTaskMove(task.id, originalStatus, "Failed to move task: ${result.error.message}")
revertTaskMove(task.id, originalStatus, "Move failed: ${result.error.message}")
}
}
}
@@ -181,7 +226,95 @@ class KanbanViewModel :
}
}
override fun onCleared() {
eventsClient?.disconnect()
super.onCleared()
}
// ── Live events (issue #775) ─────────────────────────────────────────
/**
* Tail the kanban events WebSocket for [board]. The backend pins the board
* at the WS handshake, so a board switch opens a fresh stream; re-selecting
* the already-live board is a no-op (the REST load already refreshed it).
*/
private fun connectEvents(board: KanbanBoard) {
if (eventsBoard == board.id && _uiState.value.isLive) return
eventsBoard = board.id
val client = eventsClient ?: eventsClientProvider().also { eventsClient = it }
client.connect(
scope = viewModelScope,
board = board.id,
onEvents = { scheduleBoardReload() },
onStatus = { status ->
_uiState.update { it.copy(isLive = status == KanbanLiveStatus.CONNECTED) }
},
)
}
/** Debounced REST refresh after an events batch — mirrors the desktop pattern. */
private fun scheduleBoardReload() {
reloadJob?.cancel()
reloadJob =
viewModelScope.launch {
delay(RELOAD_DEBOUNCE_MS)
reloadBoardSilently()
}
}
/** Re-fetch the current board without touching the loading spinner. */
private fun reloadBoardSilently() {
viewModelScope.launch {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
}
if (result is NetworkResult.Success) {
val body = result.data
_uiState.update {
it.copy(
columns = body.columns,
tasks = body.columns.flatMap { it.tasks },
)
}
}
}
}
private suspend fun loadBoardIntoState(board: KanbanBoard) {
val result =
withContext(Dispatchers.IO) {
safeApiCall { ApiClient.hermesApi.getKanbanBoard() }
}
when (result) {
is NetworkResult.Success -> {
val body = result.data
val allTasks = body.columns.flatMap { it.tasks }
_uiState.update {
it.copy(
isLoading = false,
columns = body.columns,
tasks = allTasks,
)
}
}
is NetworkResult.Failure -> {
_uiState.update {
it.copy(
isLoading = false,
errorMessage = "Failed to load Kanban tasks: ${result.error.message}",
)
}
}
}
}
override fun clearToast() {
_uiState.update { it.copy(toastMessage = null) }
}
private companion object {
const val RELOAD_DEBOUNCE_MS = 250L
}
}