feat(tui-go): approval ergonomics — queue + tier-gated confirm (E §3)

- pending approvals are a navigable queue (PendingQueue/PendingIdx; ↑↓/jk, count
  shown), no modal stacking; resolve removes the specific gate by request id
- y/n/e keys (approve/reject/steer); T0–T2 act on one key, T3+ requires arm+confirm
  ("press y again"), any other key disarms; reject/steer/auto never gated
- snapshot restore now rehydrates the full pending list, not just the first

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 16:40:49 +00:00
parent 35596dc723
commit d5afcd345a
6 changed files with 483 additions and 53 deletions
+14 -10
View File
@@ -125,7 +125,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
case protocol.TypeSessionResumed:
if s := m.session(msg.SessionID); s != nil {
s.Status = "ACTIVE"
s.Pending = nil
s.clearApprovals()
s.LastEventAt = nowMillis()
}
case protocol.TypeSessionCompleted:
@@ -134,6 +134,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
s.Active = false
s.Clar = nil
s.Propose = nil
s.clearApprovals()
}
case protocol.TypeSessionFailed:
m.touch(msg.SessionID, "FAILED")
@@ -141,6 +142,7 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
s.Active = false
s.Clar = nil
s.Propose = nil
s.clearApprovals()
}
case protocol.TypeStageStarted:
if s := m.session(msg.SessionID); s != nil {
@@ -252,7 +254,9 @@ func (m *Model) applyServer(msg protocol.ServerMessage) {
m.onWorkflowProposed(msg)
case protocol.TypeApprovalResolved:
if s := m.session(msg.SessionID); s != nil {
s.Pending = nil
// Drop just the resolved gate; any others stay queued and the band
// advances to the next rather than vanishing entirely.
s.removeApproval(msg.RequestID)
detail := msg.Outcome
if msg.Reason != "" {
detail += " — " + msg.Reason
@@ -411,7 +415,7 @@ func (m *Model) onApprovalRequired(msg protocol.ServerMessage) {
Rationale: rationale,
}
if s := m.session(msg.SessionID); s != nil {
s.Pending = info
s.enqueueApproval(info)
}
}
@@ -453,25 +457,25 @@ func (m *Model) onWorkflowProposed(msg protocol.ServerMessage) {
}
func (m *Model) onSnapshot(msg protocol.ServerMessage) {
var pending *Approval
if len(msg.PendingAppr) > 0 {
a := msg.PendingAppr[0]
pending = &Approval{
var queue []*Approval
for _, a := range msg.PendingAppr {
queue = append(queue, &Approval{
RequestID: a.RequestID, SessionID: msg.SessionID, Tier: a.Tier,
Risk: "unknown", ToolName: derefp(a.ToolName), Preview: derefp(a.Preview),
}
})
}
status := "running"
if msg.State != nil {
status = msg.State.Status
}
if pending != nil {
if len(queue) > 0 {
status = "PAUSED awaiting approval"
}
sess := Session{
ID: msg.SessionID, Status: status, WorkflowID: msg.WorkflowID,
Name: msg.WorkflowID, LastEventAt: nowMillis(), Pending: pending,
Name: msg.WorkflowID, LastEventAt: nowMillis(), PendingQueue: queue,
}
sess.syncPending()
if msg.State != nil && msg.State.CurrentStageID != nil {
sess.CurrentStage = *msg.State.CurrentStageID
}