feat(tui): session filter + reconnect countdown
Tasks 4.2–4.3 of docs/plans/2026-05-17-tui-refactor.md: - SessionList filters by workflowId (case-insensitive) using state.sessions.filter, populated by SubmitInput while in InputMode.Filter and cleared on CancelInput. - TuiWsClient tracks reconnect attempt count and emits ConnectionEvent.RetryScheduled(attempt, nextRetryAtMs) before each backoff delay. Attempt resets on successful connect. - StatusBar renders "reconnecting (attempt N, retry in Xs)" with recompose-driven countdown. Also captures a renderer-evaluation note in the progress doc: Mosaic's ceiling vs Textual, candidate JVM alternatives (tamboui, Kotter, Lanterna, Mordant), and confirmation that the reducer/Effect core is renderer-agnostic if a swap is ever pursued. Pending: 4.1 (scroll log, deferred), 5.1 (coverage gate), 5.2 (cleanup).
This commit is contained in:
@@ -92,7 +92,9 @@ suspend fun runTuiApp(host: String, port: Int) {
|
||||
},
|
||||
) {
|
||||
Panel(title = "status") { StatusBar(state) }
|
||||
Panel(title = "session list") { SessionList(state.sessions.sessions, state.sessions.selectedId) }
|
||||
Panel(title = "session list") {
|
||||
SessionList(state.sessions.sessions, state.sessions.selectedId, state.sessions.filter)
|
||||
}
|
||||
val activeSession = state.sessions.sessions.find { it.id == state.sessions.selectedId }
|
||||
Panel(title = "active session") { ActiveSession(activeSession) }
|
||||
if (state.approval.active != null) {
|
||||
|
||||
@@ -12,12 +12,17 @@ private const val SECS_PER_HOUR = 3600L
|
||||
|
||||
@Suppress("FunctionNaming")
|
||||
@Composable
|
||||
fun SessionList(sessions: List<SessionSummary>, selectedId: String?) {
|
||||
fun SessionList(sessions: List<SessionSummary>, selectedId: String?, filter: String = "") {
|
||||
Column {
|
||||
if (sessions.isEmpty()) {
|
||||
val filtered = if (filter.isEmpty()) {
|
||||
sessions
|
||||
} else {
|
||||
sessions.filter { it.workflowId.contains(filter, ignoreCase = true) }
|
||||
}
|
||||
if (filtered.isEmpty()) {
|
||||
Text("(no sessions)")
|
||||
} else {
|
||||
sessions.forEach { session ->
|
||||
filtered.forEach { session ->
|
||||
val pointer = if (session.id == selectedId) "▶" else " "
|
||||
val stage = session.currentStage?.let { " stage $it" } ?: ""
|
||||
val ago = formatAgo(session.lastEventAt)
|
||||
|
||||
@@ -4,11 +4,21 @@ import androidx.compose.runtime.Composable
|
||||
import com.correx.apps.tui.state.TuiState
|
||||
import com.jakewharton.mosaic.ui.Text
|
||||
|
||||
private const val MS_PER_SECOND = 1000L
|
||||
|
||||
@Suppress("FunctionNaming")
|
||||
@Composable
|
||||
fun StatusBar(state: TuiState) {
|
||||
val connectionLabel = when {
|
||||
state.connection.reconnecting -> "○ reconnecting..."
|
||||
state.connection.reconnecting -> {
|
||||
val secondsRemaining = if (state.connection.nextRetryAtMs != null) {
|
||||
val now = System.currentTimeMillis()
|
||||
maxOf(0, (state.connection.nextRetryAtMs - now) / MS_PER_SECOND)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
"○ reconnecting (attempt ${state.connection.attempt}, retry in ${secondsRemaining}s)"
|
||||
}
|
||||
state.connection.connected -> "● connected"
|
||||
else -> "○ disconnected"
|
||||
}
|
||||
|
||||
@@ -55,12 +55,14 @@ class TuiWsClient(
|
||||
|
||||
suspend fun connect() {
|
||||
var delayMs = INITIAL_RETRY_DELAY_MS
|
||||
var attempt = 0
|
||||
while (true) {
|
||||
runCatching {
|
||||
client.webSocket(method = HttpMethod.Get, host = host, port = port, path = "/stream") {
|
||||
session = this
|
||||
_connection.tryEmit(ConnectionEvent.Connected)
|
||||
delayMs = INITIAL_RETRY_DELAY_MS
|
||||
attempt = 0
|
||||
for (frame in incoming) {
|
||||
if (frame is Frame.Text) {
|
||||
runCatching {
|
||||
@@ -74,7 +76,9 @@ class TuiWsClient(
|
||||
}.onFailure { println("WS connect failed: ${it.message}") }
|
||||
session = null
|
||||
_connection.tryEmit(ConnectionEvent.Disconnected)
|
||||
// TODO Task 4.3: emit RetryScheduled with attempt count and nextRetryAtMs
|
||||
attempt++
|
||||
val nextRetryAtMs = System.currentTimeMillis() + delayMs
|
||||
_connection.tryEmit(ConnectionEvent.RetryScheduled(attempt = attempt, nextRetryAtMs = nextRetryAtMs))
|
||||
delay(delayMs)
|
||||
delayMs = minOf(delayMs * 2, MAX_RETRY_DELAY_MS)
|
||||
}
|
||||
|
||||
@@ -63,6 +63,16 @@ class SessionsReducerTest {
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SubmitInput in Filter mode with empty text clears filter`() {
|
||||
val s = SessionsState(filter = "old")
|
||||
val (state, effects) = reduce(
|
||||
sessions = s, inputMode = InputMode.Filter, inputText = "", action = Action.SubmitInput,
|
||||
)
|
||||
assertEquals("", state.filter)
|
||||
assertTrue(effects.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CancelInput in Filter mode clears filter`() {
|
||||
val s = SessionsState(filter = "something")
|
||||
|
||||
Reference in New Issue
Block a user