feat(tui-03): wire SnapshotPhaseReducer into RootReducer with replay and cursor tracking

SnapshotComplete now flips snapshotPhase, replays buffered events through
SnapshotPhaseReducer for dedup/cursor updates, then through sub-reducers.
This commit is contained in:
2026-05-25 11:42:07 +04:00
parent d841e1c4b2
commit e5df09ef93
3 changed files with 88 additions and 1 deletions
@@ -12,6 +12,34 @@ object RootReducer {
state: TuiState,
action: Action,
clock: () -> Long = System::currentTimeMillis,
): Pair<TuiState, List<Effect>> {
val phase = SnapshotPhaseReducer.process(state, action)
var current = phase.state
val allEffects = mutableListOf<Effect>()
val isReplay = action is Action.ServerEventReceived &&
action.message is ServerMessage.SnapshotComplete
for (a in phase.actionsToDispatch) {
if (isReplay) {
val inner = SnapshotPhaseReducer.process(current, a)
current = inner.state
for (ia in inner.actionsToDispatch) {
val (next, effects) = reduceThroughSubReducers(current, ia, clock)
current = next
allEffects += effects
}
} else {
val (next, effects) = reduceThroughSubReducers(current, a, clock)
current = next
allEffects += effects
}
}
return current to allEffects
}
private fun reduceThroughSubReducers(
state: TuiState,
action: Action,
clock: () -> Long,
): Pair<TuiState, List<Effect>> {
log.debug("action={}", action::class.simpleName)
val quitEffects: List<Effect> = if (action is Action.Quit) listOf(Effect.Quit) else emptyList()
@@ -47,7 +47,7 @@ class RootReducerTest {
@Test
fun `NavigateUp with sessions adjusts selected id`() {
val state0 = TuiState()
val state0 = TuiState(snapshotPhase = false)
val startMsg = com.correx.apps.server.protocol.ServerMessage.SessionStarted(
sessionId = com.correx.core.events.types.SessionId("s1"),
workflowId = "wf",
@@ -71,6 +71,36 @@ class RootReducerTest {
assertEquals("s2", state3.sessions.selectedId)
}
@Test
fun `SnapshotComplete flips snapshotPhase and replays buffered events in order`() {
val startMsg1 = com.correx.apps.server.protocol.ServerMessage.SessionStarted(
sessionId = com.correx.core.events.types.SessionId("s1"),
workflowId = "wf",
sequence = 1L,
sessionSequence = 1L,
)
val startMsg2 = com.correx.apps.server.protocol.ServerMessage.SessionStarted(
sessionId = com.correx.core.events.types.SessionId("s2"),
workflowId = "wf",
sequence = 2L,
sessionSequence = 1L,
)
val initial = TuiState(
snapshotPhase = true,
pendingEvents = listOf(startMsg1, startMsg2),
)
val (result, _) = RootReducer.reduce(
initial,
Action.ServerEventReceived(com.correx.apps.server.protocol.ServerMessage.SnapshotComplete),
fixedClock,
)
assertEquals(false, result.snapshotPhase)
assertTrue(result.pendingEvents.isEmpty())
assertEquals(2, result.sessions.sessions.size)
assertEquals(1L, result.cursors["s1"])
assertEquals(1L, result.cursors["s2"])
}
@Test
fun `AppendChar followed by Backspace returns to original buffer`() {
val initial = TuiState(inputMode = InputMode.ROUTER, inputBuffer = "ab")
@@ -1,5 +1,6 @@
package com.correx.apps.tui.reducer
import com.correx.apps.server.protocol.SessionStateDto
import com.correx.apps.server.protocol.ServerMessage
import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.TuiState
@@ -31,4 +32,32 @@ class SnapshotPhaseReducerTest {
assertTrue(allDispatched.isEmpty())
assertTrue(state.sessions.sessions.isEmpty())
}
@Test
fun `during snapshotPhase SessionSnapshot installs cursor and is forwarded to sub-reducers`() {
val state = TuiState(snapshotPhase = true)
val snapshot = ServerMessage.SessionSnapshot(
sessionId = SessionId("s1"),
workflowId = "wf",
status = "ACTIVE",
currentStageId = null,
pauseReason = null,
pendingApproval = false,
approvalRequestId = null,
approvalTier = null,
approvalToolName = null,
approvalPreview = null,
state = SessionStateDto(status = "ACTIVE", currentStageId = null, pauseReason = null),
pendingApprovals = emptyList(),
lastSequence = 1L,
lastSessionSequence = 42L,
)
val action = Action.ServerEventReceived(snapshot)
val result = SnapshotPhaseReducer.process(state, action)
assertEquals(42L, result.state.cursors["s1"])
assertEquals(listOf(action), result.actionsToDispatch)
assertTrue(result.state.pendingEvents.isEmpty())
}
}