fix(approval): resolve requestId→sessionId lookup in global socket handler
Fix the remaining TODO from 2e3b8e5: replace the TypeId(requestId.value) hack
with a proper requestId→sessionId mapping stored in ApprovalCoordinator and
populated when onApprovalRequested fires from the event store subscription.
Changes across the full bugfix batch (all 9 bugs from TUI rework):
- Bug 1: ApprovalCoordinator broadcasts non-null toolName/preview
- Bug 2: ApprovalDto carries toolName/preview for late-connected clients
- Bug 3: SessionEventBridge.replaySnapshot() preserves toolName/preview
- Bug 4: TUI SessionsReducer processes enriched ApprovalDto fields
- Bug 5: DomainEventMapper passes sessionSequence; GlobalStreamHandler
tracks per-session counters so live events aren't dropped
- Bug 6: KeyResolver restores Ctrl+L/E/C/N bindings
- Bug 7: SessionSnapshot carries workflowId for display names
- Bug 8: ↑↓ navigates selection only; Enter/sessionEntered gates IN_SESSION
- Bug 9: EventHistoryStrip capped at 4 lines to prevent overflow
- Bonus: ApprovalCoordinator.lookupSession() with proper requestId→sessionId mapping
This commit is contained in:
@@ -34,8 +34,9 @@ fun eventHistoryStripWidget(state: TuiState): Paragraph {
|
||||
}
|
||||
add(Line.from(Span.styled(stageInfo, dimStyle)))
|
||||
|
||||
// Recent events (plain text, compact format)
|
||||
val events = selectedSession.recentEvents
|
||||
// Recent events (plain text, compact format) — capped to fit allocated height.
|
||||
// Line 0 = stage info, lines 1-3 = events (max 3).
|
||||
val events = selectedSession.recentEvents.take(3)
|
||||
if (events.isEmpty()) {
|
||||
add(Line.from(Span.styled("no events", dimStyle)))
|
||||
} else {
|
||||
@@ -50,33 +51,6 @@ fun eventHistoryStripWidget(state: TuiState): Paragraph {
|
||||
add(Line.from(Span.raw(row)))
|
||||
}
|
||||
}
|
||||
|
||||
// Tool manifest for current stage (from toolsByStage)
|
||||
val stageId = selectedSession.currentStageId
|
||||
if (stageId != null) {
|
||||
val tools = selectedSession.toolsByStage[stageId]
|
||||
if (!tools.isNullOrEmpty()) {
|
||||
add(Line.from(Span.styled("declared tools:", dimStyle)))
|
||||
for (tool in tools) {
|
||||
add(Line.from(Span.raw(" ${tool.name} T${tool.tier}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime tool records (TuiToolRecord from SessionSummary.tools)
|
||||
if (selectedSession.tools.isNotEmpty()) {
|
||||
add(Line.from(Span.styled("tool records:", dimStyle)))
|
||||
for (tool in selectedSession.tools) {
|
||||
val statusSymbol = when (tool.status) {
|
||||
ToolDisplayStatus.REQUESTED -> "?"
|
||||
ToolDisplayStatus.STARTED -> "\u25B6"
|
||||
ToolDisplayStatus.COMPLETED -> "\u2713"
|
||||
ToolDisplayStatus.FAILED -> "\u2717"
|
||||
ToolDisplayStatus.REJECTED -> "\u2298"
|
||||
}
|
||||
add(Line.from(Span.raw(" $statusSymbol ${tool.name} T${tool.tier}")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ object KeyResolver {
|
||||
KeyEvent.Enter -> if (inputText.isBlank()) null else Action.SubmitInput
|
||||
KeyEvent.Tab -> Action.CycleMode
|
||||
KeyEvent.Backspace -> Action.Backspace
|
||||
KeyEvent.NewSession -> Action.OpenNewSessionPrompt
|
||||
is KeyEvent.CharInput -> Action.AppendChar(key.ch)
|
||||
else -> null
|
||||
}
|
||||
@@ -54,6 +55,9 @@ object KeyResolver {
|
||||
KeyEvent.Tab -> Action.CycleMode
|
||||
KeyEvent.Backspace -> Action.Backspace
|
||||
is KeyEvent.CharInput -> Action.AppendChar(key.ch)
|
||||
KeyEvent.Cancel -> Action.CancelSelectedSession
|
||||
KeyEvent.ReturnToSessionList -> Action.ReturnToSessionList
|
||||
KeyEvent.ToggleEventStrip -> Action.ToggleEventStrip
|
||||
KeyEvent.ShowPendingApproval -> if (hasPendingApproval) Action.ShowPendingApproval else null
|
||||
else -> null
|
||||
}
|
||||
@@ -61,6 +65,9 @@ object KeyResolver {
|
||||
DisplayState.APPROVAL -> when (key) {
|
||||
KeyEvent.Enter -> Action.SubmitApprovalDecision
|
||||
KeyEvent.Escape -> Action.CancelInput
|
||||
KeyEvent.Cancel -> Action.CancelInput
|
||||
KeyEvent.ReturnToSessionList -> Action.ReturnToSessionList
|
||||
KeyEvent.ToggleEventStrip -> Action.ToggleEventStrip
|
||||
KeyEvent.Tab -> Action.CycleMode
|
||||
KeyEvent.Backspace -> Action.Backspace
|
||||
is KeyEvent.CharInput -> Action.AppendChar(key.ch)
|
||||
|
||||
@@ -104,27 +104,43 @@ object RootReducer {
|
||||
|
||||
is Action.CancelInput -> when {
|
||||
prevInputMode == InputMode.FILTER -> withBgReset
|
||||
else -> withBgReset.copy(approvalDismissed = true)
|
||||
else -> withBgReset.copy(approvalDismissed = true, sessionEntered = false)
|
||||
}
|
||||
|
||||
is Action.ReturnToSessionList -> withBgReset.copy(sessionEntered = false)
|
||||
|
||||
is Action.ToggleEventStrip -> {
|
||||
if (withBgReset.displayState == DisplayState.IDLE) withBgReset
|
||||
else withBgReset.copy(eventStripVisible = !withBgReset.eventStripVisible)
|
||||
}
|
||||
|
||||
// Input history append on SubmitInput in IN_SESSION + ROUTER (ARCH-HISTORY-1).
|
||||
// Input history append on SubmitInput in IN_SESSION + ROUTER (ARCH-HISTORY-1),
|
||||
// or enter a selected session from the list in IDLE.
|
||||
is Action.SubmitInput -> {
|
||||
val text = prevInputBuffer.trim()
|
||||
if (text.isNotBlank() && sessions.selectedId != null &&
|
||||
prevInputMode == InputMode.ROUTER &&
|
||||
withBgReset.displayState == com.correx.apps.tui.state.DisplayState.IN_SESSION
|
||||
) {
|
||||
val selectedId = sessions.selectedId!!
|
||||
val currentHistory = withBgReset.inputHistory[selectedId] ?: emptyList()
|
||||
val updatedHistory = (currentHistory + text).takeLast(50)
|
||||
withBgReset.copy(
|
||||
inputHistory = withBgReset.inputHistory + (selectedId to updatedHistory),
|
||||
)
|
||||
when {
|
||||
text.isBlank() && sessions.selectedId != null && prevInputMode != InputMode.FILTER ->
|
||||
withBgReset.copy(sessionEntered = true)
|
||||
|
||||
text.isNotBlank() && sessions.selectedId != null &&
|
||||
prevInputMode == InputMode.ROUTER &&
|
||||
withBgReset.displayState == com.correx.apps.tui.state.DisplayState.IN_SESSION -> {
|
||||
val selectedId = sessions.selectedId!!
|
||||
val currentHistory = withBgReset.inputHistory[selectedId] ?: emptyList()
|
||||
val updatedHistory = (currentHistory + text).takeLast(50)
|
||||
withBgReset.copy(
|
||||
inputHistory = withBgReset.inputHistory + (selectedId to updatedHistory),
|
||||
)
|
||||
}
|
||||
|
||||
else -> withBgReset
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-enter a newly started session when SessionStarted arrives.
|
||||
is Action.ServerEventReceived -> {
|
||||
if (action.message is ServerMessage.SessionStarted) {
|
||||
withBgReset.copy(sessionEntered = true)
|
||||
} else {
|
||||
withBgReset
|
||||
}
|
||||
|
||||
@@ -212,16 +212,16 @@ object SessionsReducer {
|
||||
sessionId = msg.sessionId.value,
|
||||
tier = it.tier,
|
||||
riskSummary = "unknown",
|
||||
toolName = null,
|
||||
preview = null,
|
||||
toolName = it.toolName,
|
||||
preview = it.preview,
|
||||
)
|
||||
}
|
||||
val status = if (firstPending != null) "PAUSED awaiting approval" else msg.state.status
|
||||
val summary = SessionSummary(
|
||||
id = msg.sessionId.value,
|
||||
status = status,
|
||||
workflowId = msg.sessionId.value,
|
||||
name = msg.sessionId.value,
|
||||
workflowId = msg.workflowId,
|
||||
name = msg.workflowId,
|
||||
lastEventAt = clock(),
|
||||
currentStage = msg.state.currentStageId,
|
||||
currentStageId = msg.state.currentStageId,
|
||||
|
||||
@@ -10,6 +10,7 @@ val TuiState.displayState: DisplayState
|
||||
return when {
|
||||
approvalDismissed -> DisplayState.IN_SESSION
|
||||
hasApproval -> DisplayState.APPROVAL
|
||||
else -> DisplayState.IN_SESSION
|
||||
sessionEntered -> DisplayState.IN_SESSION
|
||||
else -> DisplayState.IDLE
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@ data class TuiState(
|
||||
val savedInputBuffer: String = "",
|
||||
val pendingDecision: ApprovalDecision? = null,
|
||||
val approvalDismissed: Boolean = false,
|
||||
/**
|
||||
* True when the user has explicitly "entered" a session (pressed Enter on it or
|
||||
* just started a new one). False when simply navigating the session list with ↑↓.
|
||||
* Drives [displayState]: IN_SESSION requires both [selectedId] and [sessionEntered].
|
||||
*/
|
||||
val sessionEntered: Boolean = false,
|
||||
val currentModel: String? = null,
|
||||
val providerType: ProviderType = ProviderType.LOCAL,
|
||||
val routerConnected: Boolean = false,
|
||||
|
||||
@@ -146,6 +146,7 @@ class RootReducerTest {
|
||||
fun `NavigateUp in IN_SESSION at index -1 saves buffer and loads last history entry`() {
|
||||
val state = TuiState(
|
||||
snapshotPhase = false,
|
||||
sessionEntered = true,
|
||||
sessions = SessionsState(
|
||||
sessions = listOf(session("s1")),
|
||||
selectedId = "s1",
|
||||
@@ -165,6 +166,7 @@ class RootReducerTest {
|
||||
fun `NavigateUp in IN_SESSION from index above 0 decrements index and loads entry`() {
|
||||
val state = TuiState(
|
||||
snapshotPhase = false,
|
||||
sessionEntered = true,
|
||||
sessions = SessionsState(
|
||||
sessions = listOf(session("s1")),
|
||||
selectedId = "s1",
|
||||
@@ -235,6 +237,7 @@ class RootReducerTest {
|
||||
fun `NavigateDown in IN_SESSION at last entry restores savedInputBuffer and resets index`() {
|
||||
val state = TuiState(
|
||||
snapshotPhase = false,
|
||||
sessionEntered = true,
|
||||
sessions = SessionsState(
|
||||
sessions = listOf(session("s1")),
|
||||
selectedId = "s1",
|
||||
@@ -253,6 +256,7 @@ class RootReducerTest {
|
||||
fun `NavigateDown in IN_SESSION from below last entry increments index`() {
|
||||
val state = TuiState(
|
||||
snapshotPhase = false,
|
||||
sessionEntered = true,
|
||||
sessions = SessionsState(
|
||||
sessions = listOf(session("s1")),
|
||||
selectedId = "s1",
|
||||
@@ -324,6 +328,7 @@ class RootReducerTest {
|
||||
fun `ApprovalRequired on non-selected session does not transition to APPROVAL`() {
|
||||
val initial = TuiState(
|
||||
snapshotPhase = false,
|
||||
sessionEntered = true,
|
||||
sessions = SessionsState(
|
||||
sessions = listOf(session("s1"), session("s2")),
|
||||
selectedId = "s1",
|
||||
|
||||
@@ -300,6 +300,7 @@ class SessionsReducerTest {
|
||||
fun `SessionSnapshot with pendingApprovals populates pendingApproval on summary`() {
|
||||
val msg = ServerMessage.SessionSnapshot(
|
||||
sessionId = SessionId("s1"),
|
||||
workflowId = "wf-1",
|
||||
state = SessionStateDto("PAUSED", null, null),
|
||||
pendingApprovals = listOf(ApprovalDto(requestId = "r9", tier = "T2")),
|
||||
lastSequence = 1L,
|
||||
@@ -353,6 +354,7 @@ class SessionsReducerTest {
|
||||
fun `SessionSnapshot without pendingApprovals leaves pendingApproval null`() {
|
||||
val msg = ServerMessage.SessionSnapshot(
|
||||
sessionId = SessionId("s1"),
|
||||
workflowId = "wf-1",
|
||||
state = SessionStateDto("ACTIVE", null, null),
|
||||
pendingApprovals = emptyList(),
|
||||
lastSequence = 1L,
|
||||
|
||||
@@ -108,6 +108,7 @@ class SnapshotPhaseReducerTest {
|
||||
val state = TuiState(snapshotPhase = true)
|
||||
val snapshot = ServerMessage.SessionSnapshot(
|
||||
sessionId = SessionId("s1"),
|
||||
workflowId = "wf-1",
|
||||
state = SessionStateDto(status = "ACTIVE", currentStageId = null, pauseReason = null),
|
||||
pendingApprovals = emptyList(),
|
||||
lastSequence = 1L,
|
||||
|
||||
Reference in New Issue
Block a user