refactor(cleanup-01): move approval truth to SessionSummary.pendingApproval

Add pendingApproval: ApprovalInfo? = null to SessionSummary and remove
the global approval: ApprovalState slot from TuiState. All production
code derives active approval from the selected session; RootReducer
bridges ApprovalState in/out of the selected session for the duration
of the existing ApprovalReducer (removed in Cleanup 03).
This commit is contained in:
2026-05-25 14:24:20 +04:00
parent cba0314197
commit d2c6789c4d
9 changed files with 51 additions and 26 deletions
@@ -47,7 +47,6 @@ fun main(args: Array<String>) {
fun dispatch(action: Action) { fun dispatch(action: Action) {
val (next, effects) = RootReducer.reduce(state, action) val (next, effects) = RootReducer.reduce(state, action)
log.debug("got connection state after reducers: {}", next.connection) log.debug("got connection state after reducers: {}", next.connection)
log.debug("got approval state after reducers: {}", next.approval)
log.debug("got input state after reducers: {}", next.input) log.debug("got input state after reducers: {}", next.input)
log.debug( log.debug(
"got session state after reducers: {}", "got session state after reducers: {}",
@@ -13,7 +13,7 @@ import dev.tamboui.widgets.paragraph.Paragraph
fun inputBarWidget(state: TuiState): Paragraph { fun inputBarWidget(state: TuiState): Paragraph {
val dimStyle = Style.create().dim().gray() val dimStyle = Style.create().dim().gray()
val hasApproval = state.approval.active != null val hasApproval = state.sessions.sessions.find { it.id == state.sessions.selectedId }?.pendingApproval != null
val hasSession = state.sessions.selectedId != null val hasSession = state.sessions.selectedId != null
val sessionName = state.sessions.sessions val sessionName = state.sessions.sessions
@@ -23,18 +23,21 @@ fun routerPanelWidget(state: TuiState): Paragraph {
} }
} }
val activeApproval = state.sessions.sessions.find { it.id == state.sessions.selectedId }?.pendingApproval
val overlayLines: List<Line> = when { val overlayLines: List<Line> = when {
state.approvalOverlayVisible && state.approval.active != null -> { state.approvalOverlayVisible && activeApproval != null -> {
val a = state.approval.active
buildList { buildList {
add(Line.from(Span.styled("╭─ approval [alt+h to hide] ─────────────╮", Style.create().yellow()))) add(Line.from(Span.styled("╭─ approval [alt+h to hide] ─────────────╮", Style.create().yellow())))
val tierLine = "│ ⚠ ${a.tier}${a.toolName ?: "no tool name"}${a.riskSummary.take(12)}... │" val tierLine = "│ ⚠ ${activeApproval.tier}${activeApproval.toolName ?: "no tool name"}${
activeApproval.riskSummary.take(12)
}... "
add(Line.from(Span.styled(tierLine, Style.create().yellow()))) add(Line.from(Span.styled(tierLine, Style.create().yellow())))
val previewLine = "${(a.preview ?: "no preview").take(40)}… │" val previewLine = "${(activeApproval.preview ?: "no preview").take(40)}… │"
add(Line.from(Span.styled(previewLine, dimStyle))) add(Line.from(Span.styled(previewLine, dimStyle)))
add(Line.from(Span.styled("╰──────────────────────────────────────────╯", Style.create().yellow()))) add(Line.from(Span.styled("╰──────────────────────────────────────────╯", Style.create().yellow())))
} }
} }
state.eventOverlayVisible -> { state.eventOverlayVisible -> {
val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId } val selectedSession = state.sessions.sessions.firstOrNull { it.id == state.sessions.selectedId }
val events = selectedSession?.recentEvents ?: emptyList() val events = selectedSession?.recentEvents ?: emptyList()
@@ -47,6 +50,7 @@ fun routerPanelWidget(state: TuiState): Paragraph {
add(Line.from(Span.styled("╰──────────────────────────────────────────╯", Style.create().magenta()))) add(Line.from(Span.styled("╰──────────────────────────────────────────╯", Style.create().magenta())))
} }
} }
else -> emptyList() else -> emptyList()
} }
@@ -36,7 +36,7 @@ fun statusBarWidget(state: TuiState): Paragraph {
add(modelSpan) add(modelSpan)
add(sep) add(sep)
add(sessionsSpan) add(sessionsSpan)
if (state.approval.active != null) { if (state.sessions.sessions.find { it.id == state.sessions.selectedId }?.pendingApproval != null) {
add(sep) add(sep)
add(Span.styled("[⚠ approval]", Style.create().yellow())) add(Span.styled("[⚠ approval]", Style.create().yellow()))
} }
@@ -24,11 +24,14 @@ object InputReducer {
) to emptyList() ) to emptyList()
is Action.OpenNewSessionPrompt -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList() is Action.OpenNewSessionPrompt -> state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to emptyList()
is Action.OpenSteeringPrompt -> if (state.approval.active != null) { is Action.OpenSteeringPrompt -> {
val active = state.sessions.sessions.find { it.id == state.sessions.selectedId }?.pendingApproval
if (active != null) {
state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList() state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList()
} else { } else {
state to emptyList() state to emptyList()
} }
}
is Action.EnterSteer -> state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList() is Action.EnterSteer -> state.copy(inputMode = InputMode.STEER, inputBuffer = "") to emptyList()
is Action.OpenFilter -> state.copy(inputMode = InputMode.FILTER, inputBuffer = "") to emptyList() is Action.OpenFilter -> state.copy(inputMode = InputMode.FILTER, inputBuffer = "") to emptyList()
@@ -49,7 +52,7 @@ object InputReducer {
InputMode.STEER -> { InputMode.STEER -> {
val text = state.inputBuffer.trim() val text = state.inputBuffer.trim()
val active = state.approval.active val active = state.sessions.sessions.find { it.id == state.sessions.selectedId }?.pendingApproval
if (active != null && text.isNotBlank()) { if (active != null && text.isNotBlank()) {
state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to listOf( state.copy(inputMode = InputMode.ROUTER, inputBuffer = "") to listOf(
Effect.SendWs( Effect.SendWs(
@@ -2,6 +2,7 @@ package com.correx.apps.tui.reducer
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.ApprovalState
import com.correx.apps.tui.state.TuiState import com.correx.apps.tui.state.TuiState
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@@ -55,16 +56,23 @@ object RootReducer {
val (afterInput, ie) = InputReducer.reduce(state, action) val (afterInput, ie) = InputReducer.reduce(state, action)
log.debug("sessions state before reducers={}", state.sessions) log.debug("sessions state before reducers={}", state.sessions)
val (sessions, se) = SessionsReducer.reduce(afterInput.sessions, prevInputMode, prevInputBuffer, action, clock) val (sessions, se) = SessionsReducer.reduce(afterInput.sessions, prevInputMode, prevInputBuffer, action, clock)
log.debug("approval state before reducers={}", state.approval) val selectedId = afterInput.sessions.selectedId
val (approval, ae) = ApprovalReducer.reduce(afterInput.approval, prevInputMode, approvalAction) val bridgedApproval = ApprovalState(
active = afterInput.sessions.sessions.find { it.id == selectedId }?.pendingApproval,
)
val (approval, ae) = ApprovalReducer.reduce(bridgedApproval, prevInputMode, approvalAction)
val approvalJustArrived = approval.active != null && bridgedApproval.active == null
val sessionsWithApproval = sessions.copy(
sessions = sessions.sessions.map { s ->
if (s.id == selectedId) s.copy(pendingApproval = approval.active) else s
},
)
log.debug("connection state before reducers={}", state.connection) log.debug("connection state before reducers={}", state.connection)
val (connection, ce) = ConnectionReducer.reduce(afterInput.connection, action) val (connection, ce) = ConnectionReducer.reduce(afterInput.connection, action)
log.debug("provider state before reducers={}", state.provider) log.debug("provider state before reducers={}", state.provider)
val (provider, pe) = ProviderReducer.reduce(afterInput.provider, action) val (provider, pe) = ProviderReducer.reduce(afterInput.provider, action)
val approvalJustArrived = approval.active != null && afterInput.approval.active == null
val withSubReducers = afterInput.copy( val withSubReducers = afterInput.copy(
sessions = sessions, sessions = sessionsWithApproval,
approval = approval,
connection = connection, connection = connection,
provider = provider, provider = provider,
approvalOverlayVisible = if (approvalJustArrived) true else afterInput.approvalOverlayVisible, approvalOverlayVisible = if (approvalJustArrived) true else afterInput.approvalOverlayVisible,
@@ -25,7 +25,6 @@ data class TuiState(
val connection: ConnectionState = ConnectionState(), val connection: ConnectionState = ConnectionState(),
val sessions: SessionsState = SessionsState(), val sessions: SessionsState = SessionsState(),
val input: InputState = InputState(), val input: InputState = InputState(),
val approval: ApprovalState = ApprovalState(),
val provider: ProviderState = ProviderState(), val provider: ProviderState = ProviderState(),
val inputMode: InputMode = InputMode.ROUTER, val inputMode: InputMode = InputMode.ROUTER,
val inputBuffer: String = "", val inputBuffer: String = "",
@@ -65,6 +64,7 @@ data class SessionSummary(
val lastResponseText: String? = null, val lastResponseText: String? = null,
val tools: List<TuiToolRecord> = emptyList(), val tools: List<TuiToolRecord> = emptyList(),
val recentEvents: List<TuiEventEntry> = emptyList(), val recentEvents: List<TuiEventEntry> = emptyList(),
val pendingApproval: ApprovalInfo? = null,
) )
data class ApprovalInfo( data class ApprovalInfo(
@@ -4,8 +4,9 @@ import com.correx.apps.server.protocol.ApprovalDecision
import com.correx.apps.server.protocol.ClientMessage import com.correx.apps.server.protocol.ClientMessage
import com.correx.apps.tui.input.Action import com.correx.apps.tui.input.Action
import com.correx.apps.tui.state.ApprovalInfo import com.correx.apps.tui.state.ApprovalInfo
import com.correx.apps.tui.state.ApprovalState
import com.correx.apps.tui.state.InputMode import com.correx.apps.tui.state.InputMode
import com.correx.apps.tui.state.SessionSummary
import com.correx.apps.tui.state.SessionsState
import com.correx.apps.tui.state.TuiState import com.correx.apps.tui.state.TuiState
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Assertions.assertTrue
@@ -22,6 +23,21 @@ class InputReducerTest {
preview = null, preview = null,
) )
private val sessionWithApproval = SessionSummary(
id = "sess-1",
status = "running",
workflowId = "wf",
lastEventAt = 0L,
pendingApproval = activeApproval,
)
private fun stateWithActiveApproval(inputMode: InputMode = InputMode.ROUTER, inputBuffer: String = "") =
TuiState(
inputMode = inputMode,
inputBuffer = inputBuffer,
sessions = SessionsState(sessions = listOf(sessionWithApproval), selectedId = "sess-1"),
)
private fun reduce( private fun reduce(
state: TuiState = TuiState(), state: TuiState = TuiState(),
action: Action, action: Action,
@@ -37,7 +53,7 @@ class InputReducerTest {
@Test @Test
fun `OpenSteeringPrompt sets mode to STEER when approval active`() { fun `OpenSteeringPrompt sets mode to STEER when approval active`() {
val initial = TuiState(approval = ApprovalState(active = activeApproval)) val initial = stateWithActiveApproval()
val (state, effects) = reduce(state = initial, action = Action.OpenSteeringPrompt) val (state, effects) = reduce(state = initial, action = Action.OpenSteeringPrompt)
assertEquals(InputMode.STEER, state.inputMode) assertEquals(InputMode.STEER, state.inputMode)
assertEquals("", state.inputBuffer) assertEquals("", state.inputBuffer)
@@ -118,11 +134,7 @@ class InputReducerTest {
@Test @Test
fun `SubmitInput in STEER mode with active approval emits ApprovalResponse STEER`() { fun `SubmitInput in STEER mode with active approval emits ApprovalResponse STEER`() {
val (state, effects) = reduce( val (state, effects) = reduce(
state = TuiState( state = stateWithActiveApproval(inputMode = InputMode.STEER, inputBuffer = "steer this way"),
inputMode = InputMode.STEER,
inputBuffer = "steer this way",
approval = ApprovalState(active = activeApproval),
),
action = Action.SubmitInput, action = Action.SubmitInput,
) )
assertEquals(InputMode.ROUTER, state.inputMode) assertEquals(InputMode.ROUTER, state.inputMode)
@@ -42,7 +42,6 @@ class RootReducerTest {
assertEquals(initial.inputMode, state.inputMode) assertEquals(initial.inputMode, state.inputMode)
assertEquals(initial.inputBuffer, state.inputBuffer) assertEquals(initial.inputBuffer, state.inputBuffer)
assertEquals(initial.sessions, state.sessions) assertEquals(initial.sessions, state.sessions)
assertEquals(initial.approval, state.approval)
assertEquals(initial.provider, state.provider) assertEquals(initial.provider, state.provider)
} }