fix(tui): status bar model, snapshot events, session-start selection

Three fixes:

1. Status bar now shows model/provider from ProviderStatusChanged:
   - RootReducer sets currentModel and providerType when the message arrives
   - providerType is LOCAL for llama/local providers, REMOTE otherwise
   - Removes the stale '(no model) (local)' display

2. SessionSnapshot now carries recent event history:
   - New EventEntryDto in the protocol with timestamp/type/detail
   - SessionSnapshot.recentEvents populated by bridge from event store
   - Bridge reads last 7 display-relevant events per session
   - TUI processSessionSnapshotMessage maps them to TuiEventEntry
   - Completed sessions finally show their event history

3. SessionStarted now switches selectedId to the new session:
   - RootReducer cross-field weave updates selectedId alongside sessionEntered
   - Previously kept the old selectedId, navigating user into the wrong session
   - Works whether starting a session from the list or during replay
This commit is contained in:
2026-05-26 13:22:48 +04:00
parent 6770e3bf0a
commit 450b492937
6 changed files with 121 additions and 4 deletions
@@ -1,6 +1,7 @@
package com.correx.apps.server.bridge package com.correx.apps.server.bridge
import com.correx.apps.server.protocol.ApprovalDto import com.correx.apps.server.protocol.ApprovalDto
import com.correx.apps.server.protocol.EventEntryDto
import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.server.protocol.SessionStateDto import com.correx.apps.server.protocol.SessionStateDto
import com.correx.apps.server.protocol.StageToolDecl import com.correx.apps.server.protocol.StageToolDecl
@@ -8,6 +9,20 @@ import com.correx.apps.server.protocol.ToolDecl
import com.correx.apps.server.registry.WorkflowRegistry import com.correx.apps.server.registry.WorkflowRegistry
import com.correx.core.approvals.DefaultApprovalRepository import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.artifactstore.ArtifactStore import com.correx.core.artifactstore.ArtifactStore
import com.correx.core.events.events.InferenceCompletedEvent
import com.correx.core.events.events.InferenceStartedEvent
import com.correx.core.events.events.InferenceTimeoutEvent
import com.correx.core.events.events.OrchestrationPausedEvent
import com.correx.core.events.events.StageCompletedEvent
import com.correx.core.events.events.StageFailedEvent
import com.correx.core.events.events.StoredEvent
import com.correx.core.events.events.ToolExecutionCompletedEvent
import com.correx.core.events.events.ToolExecutionFailedEvent
import com.correx.core.events.events.ToolExecutionRejectedEvent
import com.correx.core.events.events.ToolInvocationRequestedEvent
import com.correx.core.events.events.TransitionExecutedEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.orchestration.OrchestrationStatus import com.correx.core.events.orchestration.OrchestrationStatus
import com.correx.core.events.stores.EventStore import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
@@ -48,6 +63,10 @@ class SessionEventBridge(
} }
?: emptyList() ?: emptyList()
val recentEvents = eventStore.read(sessionId)
.mapNotNull { eventToEntry(it) }
.takeLast(7)
send(ServerMessage.SessionSnapshot( send(ServerMessage.SessionSnapshot(
sessionId = sessionId, sessionId = sessionId,
workflowId = orchState.workflowId, workflowId = orchState.workflowId,
@@ -57,6 +76,7 @@ class SessionEventBridge(
pauseReason = orchState.pauseReason, pauseReason = orchState.pauseReason,
), ),
pendingApprovals = pendingApprovals, pendingApprovals = pendingApprovals,
recentEvents = recentEvents,
lastSequence = lastGlobal, lastSequence = lastGlobal,
lastSessionSequence = lastSession, lastSessionSequence = lastSession,
)) ))
@@ -82,6 +102,26 @@ class SessionEventBridge(
send(ServerMessage.SnapshotComplete) send(ServerMessage.SnapshotComplete)
} }
private fun eventToEntry(event: StoredEvent): EventEntryDto? {
val ts = event.metadata.timestamp.toEpochMilliseconds()
return when (val p = event.payload) {
is TransitionExecutedEvent -> EventEntryDto(ts, "StageStarted", p.to.value)
is StageCompletedEvent -> EventEntryDto(ts, "StageCompleted", p.stageId.value)
is StageFailedEvent -> EventEntryDto(ts, "StageFailed", p.stageId.value)
is InferenceStartedEvent -> EventEntryDto(ts, "InferenceStarted", p.stageId.value)
is InferenceCompletedEvent -> EventEntryDto(ts, "InferenceCompleted", p.stageId.value)
is InferenceTimeoutEvent -> EventEntryDto(ts, "InferenceTimeout", p.stageId.value)
is ToolInvocationRequestedEvent -> EventEntryDto(ts, "ToolStarted", p.toolName)
is ToolExecutionCompletedEvent -> EventEntryDto(ts, "ToolCompleted", p.toolName)
is ToolExecutionFailedEvent -> EventEntryDto(ts, "ToolFailed", p.toolName)
is ToolExecutionRejectedEvent -> EventEntryDto(ts, "ToolRejected", p.toolName)
is OrchestrationPausedEvent -> EventEntryDto(ts, "SessionPaused", p.reason)
is WorkflowCompletedEvent -> EventEntryDto(ts, "SessionCompleted", "")
is WorkflowFailedEvent -> EventEntryDto(ts, "SessionFailed", p.reason)
else -> null
}
}
suspend fun streamLive(sessionId: SessionId) { suspend fun streamLive(sessionId: SessionId) {
var sessionSequence = 0L var sessionSequence = 0L
eventStore.subscribe(sessionId).collect { event -> eventStore.subscribe(sessionId).collect { event ->
@@ -54,3 +54,10 @@ data class ToolDecl(
val name: String, val name: String,
val tier: Int, val tier: Int,
) )
@Serializable
data class EventEntryDto(
val timestamp: Long,
val type: String,
val detail: String,
)
@@ -79,6 +79,7 @@ sealed interface ServerMessage {
val workflowId: String, val workflowId: String,
val state: SessionStateDto, val state: SessionStateDto,
val pendingApprovals: List<ApprovalDto>, val pendingApprovals: List<ApprovalDto>,
val recentEvents: List<EventEntryDto> = emptyList(),
val lastSequence: Long, val lastSequence: Long,
val lastSessionSequence: Long, val lastSessionSequence: Long,
override val sequence: Long? = null, override val sequence: Long? = null,
@@ -5,6 +5,7 @@ import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.DisplayState import com.correx.apps.tui.state.DisplayState
import com.correx.apps.tui.state.InputMode import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.ProviderType
import com.correx.apps.tui.state.TuiState import com.correx.apps.tui.state.TuiState
import com.correx.apps.tui.state.displayState import com.correx.apps.tui.state.displayState
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@@ -138,11 +139,25 @@ object RootReducer {
} }
// Auto-enter a newly started session when SessionStarted arrives. // Auto-enter a newly started session when SessionStarted arrives.
// When a ProviderStatusChanged arrives, update model/provider labels.
is Action.ServerEventReceived -> { is Action.ServerEventReceived -> {
if (action.message is ServerMessage.SessionStarted) { val msg = action.message
withBgReset.copy(sessionEntered = true) when {
} else { msg is ServerMessage.SessionStarted -> {
withBgReset withBgReset.copy(
sessionEntered = true,
sessions = withBgReset.sessions.copy(selectedId = msg.sessionId.value),
)
}
msg is ServerMessage.ProviderStatusChanged -> {
withBgReset.copy(
currentModel = msg.providerId,
providerType = if (msg.providerId.contains("llama", ignoreCase = true) ||
msg.providerId.contains("local", ignoreCase = true)
) ProviderType.LOCAL else ProviderType.REMOTE,
)
}
else -> withBgReset
} }
} }
@@ -217,6 +217,13 @@ object SessionsReducer {
) )
} }
val status = if (firstPending != null) "PAUSED awaiting approval" else msg.state.status val status = if (firstPending != null) "PAUSED awaiting approval" else msg.state.status
val eventEntries = msg.recentEvents.map { entry ->
TuiEventEntry(
timestamp = formatTime(entry.timestamp),
type = entry.type,
detail = entry.detail,
)
}
val summary = SessionSummary( val summary = SessionSummary(
id = msg.sessionId.value, id = msg.sessionId.value,
status = status, status = status,
@@ -226,6 +233,7 @@ object SessionsReducer {
currentStage = msg.state.currentStageId, currentStage = msg.state.currentStageId,
currentStageId = msg.state.currentStageId, currentStageId = msg.state.currentStageId,
pendingApproval = approvalInfo, pendingApproval = approvalInfo,
recentEvents = eventEntries,
) )
val selected = sessionState.selectedId ?: msg.sessionId.value val selected = sessionState.selectedId ?: msg.sessionId.value
return sessionState.copy( return sessionState.copy(
@@ -1,12 +1,14 @@
package com.correx.apps.tui.reducer package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.ClientMessage import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.server.protocol.ProviderHealthDto
import com.correx.apps.server.protocol.RiskSummaryDto import com.correx.apps.server.protocol.RiskSummaryDto
import com.correx.apps.server.protocol.ServerMessage import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.ConnectionState import com.correx.apps.tui.state.ConnectionState
import com.correx.apps.tui.state.DisplayState import com.correx.apps.tui.state.DisplayState
import com.correx.apps.tui.state.InputMode import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.ProviderType
import com.correx.apps.tui.state.SessionSummary import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.SessionsState import com.correx.apps.tui.state.SessionsState
import com.correx.apps.tui.state.TuiState import com.correx.apps.tui.state.TuiState
@@ -341,4 +343,48 @@ class RootReducerTest {
assertEquals(null, state.sessions.sessions.find { it.id == "s1" }?.pendingApproval) assertEquals(null, state.sessions.sessions.find { it.id == "s1" }?.pendingApproval)
assertEquals("r1", state.sessions.sessions.find { it.id == "s2" }?.pendingApproval?.requestId) assertEquals("r1", state.sessions.sessions.find { it.id == "s2" }?.pendingApproval?.requestId)
} }
@Test
fun `ProviderStatusChanged updates currentModel and providerType`() {
val initial = TuiState(snapshotPhase = false, currentModel = null, providerType = ProviderType.LOCAL)
val msg = ServerMessage.ProviderStatusChanged(
providerId = "llama.cpp",
status = ProviderHealthDto("llama.cpp", "healthy", null),
)
val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(msg), fixedClock)
assertEquals("llama.cpp", state.currentModel)
assertEquals(ProviderType.LOCAL, state.providerType)
}
@Test
fun `ProviderStatusChanged for remote provider sets providerType to REMOTE`() {
val initial = TuiState(snapshotPhase = false, currentModel = null, providerType = ProviderType.LOCAL)
val msg = ServerMessage.ProviderStatusChanged(
providerId = "openai",
status = ProviderHealthDto("openai", "healthy", null),
)
val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(msg), fixedClock)
assertEquals("openai", state.currentModel)
assertEquals(ProviderType.REMOTE, state.providerType)
}
@Test
fun `SessionStarted switches selectedId to new session when another session is selected`() {
val initial = TuiState(
snapshotPhase = false,
sessions = SessionsState(
sessions = listOf(session("s1"), session("s2")),
selectedId = "s1",
),
)
val startedMsg = ServerMessage.SessionStarted(
sessionId = SessionId("s2"),
workflowId = "wf",
sequence = 1L,
sessionSequence = 1L,
)
val (state, _) = RootReducer.reduce(initial, Action.ServerEventReceived(startedMsg), fixedClock)
assertEquals("s2", state.sessions.selectedId)
assertTrue(state.sessionEntered)
}
} }