From eb4a8e414448f089cd93b0570ae7d8d8337195e2 Mon Sep 17 00:00:00 2001 From: egdev6 Date: Thu, 28 May 2026 17:43:16 +0200 Subject: [PATCH 1/4] feat(tui): add session delete confirmation --- internal/tui/model.go | 23 ++++++-- internal/tui/update.go | 46 +++++++++++++++ internal/tui/update_test.go | 114 ++++++++++++++++++++++++++++++++++++ internal/tui/view.go | 14 ++++- internal/tui/view_test.go | 20 +++++++ 5 files changed, 212 insertions(+), 5 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 144240d5..0ffc811a 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -78,6 +78,11 @@ type sessionObservationsMsg struct { err error } +type sessionDeletedMsg struct { + sessionID string + err error +} + type setupInstallMsg struct { result *setup.Result err error @@ -121,10 +126,13 @@ type Model struct { Timeline *store.TimelineResult // Sessions - Sessions []store.SessionSummary - SelectedSessionIdx int - SessionObservations []store.Observation - SessionDetailScroll int + Sessions []store.SessionSummary + SelectedSessionIdx int + SessionObservations []store.Observation + SessionDetailScroll int + SessionDeletePrompt bool + SessionDeleteID string + SessionDeleteProject string // Clipboard feedback CopyFeedback string // "✓ Copied!" or "" — shown for 2 s after copy @@ -228,6 +236,13 @@ func loadSessionObservations(s *store.Store, sessionID string) tea.Cmd { } } +func deleteSession(s *store.Store, sessionID string) tea.Cmd { + return func() tea.Msg { + err := s.DeleteSession(sessionID) + return sessionDeletedMsg{sessionID: sessionID, err: err} + } +} + func installAgent(agentName string) tea.Cmd { return func() tea.Msg { result, err := installAgentFn(agentName) diff --git a/internal/tui/update.go b/internal/tui/update.go index 1c5cfe00..f9040ab3 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -89,6 +89,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.Sessions = msg.sessions + if len(m.Sessions) == 0 { + m.Cursor = 0 + m.Scroll = 0 + } else if m.Cursor >= len(m.Sessions) { + m.Cursor = len(m.Sessions) - 1 + if m.Scroll > m.Cursor { + m.Scroll = m.Cursor + } + } return m, nil case sessionObservationsMsg: @@ -102,6 +111,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.SessionDetailScroll = 0 return m, nil + case sessionDeletedMsg: + m.SessionDeletePrompt = false + m.SessionDeleteID = "" + m.SessionDeleteProject = "" + if msg.err != nil { + m.ErrorMsg = msg.err.Error() + return m, nil + } + return m, loadRecentSessions(m.store) + case setupInstallMsg: m.SetupInstalling = false if msg.err != nil { @@ -440,6 +459,23 @@ func (m Model) handleTimelineKeys(key string) (tea.Model, tea.Cmd) { // ─── Sessions ──────────────────────────────────────────────────────────────── func (m Model) handleSessionsKeys(key string) (tea.Model, tea.Cmd) { + if m.SessionDeletePrompt { + switch key { + case "y", "Y": + if m.SessionDeleteID == "" { + m.SessionDeletePrompt = false + return m, nil + } + return m, deleteSession(m.store, m.SessionDeleteID) + case "n", "N", "esc": + m.SessionDeletePrompt = false + m.SessionDeleteID = "" + m.SessionDeleteProject = "" + return m, nil + } + return m, nil + } + visibleItems := m.Height - 8 if visibleItems < 5 { visibleItems = 5 @@ -467,10 +503,20 @@ func (m Model) handleSessionsKeys(key string) (tea.Model, tea.Cmd) { sessionID := m.Sessions[m.Cursor].ID return m, loadSessionObservations(m.store, sessionID) } + case "d": + if len(m.Sessions) > 0 && m.Cursor < len(m.Sessions) { + session := m.Sessions[m.Cursor] + m.SessionDeletePrompt = true + m.SessionDeleteID = session.ID + m.SessionDeleteProject = session.Project + } case "esc", "q": m.Screen = ScreenDashboard m.Cursor = 0 m.Scroll = 0 + m.SessionDeletePrompt = false + m.SessionDeleteID = "" + m.SessionDeleteProject = "" return m, loadStats(m.store) } return m, nil diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 3bb15de7..0f8e32a1 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -302,6 +302,120 @@ func TestHandleRecentTimelineSessionsAndDetailKeyPaths(t *testing.T) { } } +func TestSessionDeletePromptFlow(t *testing.T) { + t.Run("opens and cancels prompt", func(t *testing.T) { + fx := newTestFixture(t) + m := New(fx.store, "") + m.Screen = ScreenSessions + m.Sessions = []store.SessionSummary{{ID: fx.sessionID, Project: "engram"}, {ID: fx.otherSession, Project: "engram"}} + m.Cursor = 1 + + updatedModel, cmd := m.handleSessionsKeys("d") + updated := updatedModel.(Model) + if cmd != nil { + t.Fatal("opening delete prompt should not return command") + } + if !updated.SessionDeletePrompt || updated.SessionDeleteID != fx.otherSession || updated.SessionDeleteProject != "engram" { + t.Fatalf("delete prompt state = prompt:%v id:%q project:%q", updated.SessionDeletePrompt, updated.SessionDeleteID, updated.SessionDeleteProject) + } + + updatedModel, cmd = updated.handleSessionsKeys("esc") + updated = updatedModel.(Model) + if cmd != nil { + t.Fatal("esc cancel should not return command") + } + if updated.SessionDeletePrompt || updated.SessionDeleteID != "" || updated.SessionDeleteProject != "" { + t.Fatal("esc cancel should clear delete prompt state") + } + + updatedModel, _ = m.handleSessionsKeys("d") + updated = updatedModel.(Model) + updatedModel, cmd = updated.handleSessionsKeys("n") + updated = updatedModel.(Model) + if cmd != nil { + t.Fatal("n cancel should not return command") + } + if updated.SessionDeletePrompt || updated.SessionDeleteID != "" || updated.SessionDeleteProject != "" { + t.Fatal("n cancel should clear delete prompt state") + } + }) + + t.Run("confirm deletes empty session and refreshes", func(t *testing.T) { + fx := newTestFixture(t) + m := New(fx.store, "") + m.Screen = ScreenSessions + m.Sessions = []store.SessionSummary{{ID: fx.sessionID, Project: "engram"}, {ID: fx.otherSession, Project: "engram"}} + m.Cursor = 1 + + updatedModel, _ := m.handleSessionsKeys("d") + updated := updatedModel.(Model) + updatedModel, cmd := updated.handleSessionsKeys("y") + updated = updatedModel.(Model) + if cmd == nil { + t.Fatal("confirm should return delete command") + } + + msg := cmd().(sessionDeletedMsg) + if msg.err != nil { + t.Fatalf("delete command error: %v", msg.err) + } + updatedModel, refreshCmd := updated.Update(msg) + updated = updatedModel.(Model) + if updated.SessionDeletePrompt || updated.SessionDeleteID != "" { + t.Fatal("delete result should clear prompt state") + } + if refreshCmd == nil { + t.Fatal("successful delete should refresh sessions") + } + if err := fx.store.DeleteSession(fx.otherSession); !errors.Is(err, store.ErrSessionNotFound) { + t.Fatalf("session should be deleted, got err %v", err) + } + }) + + t.Run("blocked delete shows store error", func(t *testing.T) { + fx := newTestFixture(t) + m := New(fx.store, "") + m.Screen = ScreenSessions + m.Sessions = []store.SessionSummary{{ID: fx.sessionID, Project: "engram"}} + + updatedModel, _ := m.handleSessionsKeys("d") + updated := updatedModel.(Model) + _, cmd := updated.handleSessionsKeys("y") + if cmd == nil { + t.Fatal("confirm should return delete command") + } + + msg := cmd().(sessionDeletedMsg) + if !errors.Is(msg.err, store.ErrSessionHasObservations) { + t.Fatalf("expected ErrSessionHasObservations, got %v", msg.err) + } + updatedModel, refreshCmd := updated.Update(msg) + updated = updatedModel.(Model) + if refreshCmd != nil { + t.Fatal("failed delete should not refresh sessions") + } + if updated.ErrorMsg == "" { + t.Fatal("failed delete should surface error message") + } + if updated.SessionDeletePrompt { + t.Fatal("failed delete should close prompt") + } + }) + + t.Run("delete key ignored without sessions", func(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenSessions + updatedModel, cmd := m.handleSessionsKeys("d") + updated := updatedModel.(Model) + if cmd != nil { + t.Fatal("delete with no sessions should not return command") + } + if updated.SessionDeletePrompt { + t.Fatal("delete with no sessions should not open prompt") + } + }) +} + func TestRefreshScreen(t *testing.T) { m := New(newTestFixture(t).store, "") diff --git a/internal/tui/view.go b/internal/tui/view.go index 68a1958b..5808d21a 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -451,6 +451,18 @@ func (m Model) viewSessions() string { b.WriteString(headerStyle.Render(header)) b.WriteString("\n") + if m.SessionDeletePrompt { + b.WriteString("\n") + b.WriteString(sectionHeadingStyle.Render(" Confirm Session Delete")) + b.WriteString("\n\n") + b.WriteString(detailContentStyle.Render(fmt.Sprintf(" Delete session %q from project %q?", m.SessionDeleteID, m.SessionDeleteProject))) + b.WriteString("\n") + b.WriteString(timestampStyle.Render(" Sessions with observations cannot be deleted; Engram will refuse unsafe deletes.")) + b.WriteString("\n\n") + b.WriteString(helpStyle.Render(" [y] Delete [n] Cancel [esc] Cancel")) + return b.String() + } + if count == 0 { b.WriteString(noResultsStyle.Render("No sessions yet.")) b.WriteString("\n\n") @@ -498,7 +510,7 @@ func (m Model) viewSessions() string { timestampStyle.Render(fmt.Sprintf("showing %d-%d of %d", m.Scroll+1, end, count)))) } - b.WriteString(helpStyle.Render("\n j/k navigate • enter view session • esc back")) + b.WriteString(helpStyle.Render("\n j/k navigate • enter view session • d delete • esc back")) return b.String() } diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index f68aa2eb..44500e73 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -310,6 +310,26 @@ func TestViewObservationDetailTimelineSessionsAndSessionDetail(t *testing.T) { } } +func TestViewSessionsDeletePrompt(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenSessions + m.Sessions = []store.SessionSummary{{ID: "session-1", Project: "engram", StartedAt: "2026-01-01"}} + m.SessionDeletePrompt = true + m.SessionDeleteID = "session-1" + m.SessionDeleteProject = "engram" + + out := m.viewSessions() + if !strings.Contains(out, "Confirm Session Delete") { + t.Fatal("delete prompt should render heading") + } + if !strings.Contains(out, "session-1") || !strings.Contains(out, "engram") { + t.Fatal("delete prompt should render selected session context") + } + if !strings.Contains(out, "[y] Delete") || !strings.Contains(out, "[n] Cancel") || !strings.Contains(out, "[esc] Cancel") { + t.Fatal("delete prompt should render y/n/esc options") + } +} + func TestViewRouterCoversAllScreens(t *testing.T) { m := New(nil, "") m.Stats = &store.Stats{} From 8dca55e7f77e35ee7b0e4148d78840f29596687d Mon Sep 17 00:00:00 2001 From: egdev6 Date: Thu, 28 May 2026 17:52:45 +0200 Subject: [PATCH 2/4] fix(tui): harden session delete flow --- internal/tui/model.go | 6 +++++ internal/tui/update.go | 45 +++++++++++++++++++++++++++---------- internal/tui/update_test.go | 37 +++++++++++++++++++++++++----- internal/tui/view.go | 9 ++++++++ internal/tui/view_test.go | 7 ++++++ 5 files changed, 86 insertions(+), 18 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index 0ffc811a..ecb1d190 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -10,6 +10,8 @@ package tui import ( + "errors" + "github.com/Gentleman-Programming/engram/internal/setup" "github.com/Gentleman-Programming/engram/internal/store" "github.com/Gentleman-Programming/engram/internal/version" @@ -131,6 +133,7 @@ type Model struct { SessionObservations []store.Observation SessionDetailScroll int SessionDeletePrompt bool + SessionDeleting bool SessionDeleteID string SessionDeleteProject string @@ -238,6 +241,9 @@ func loadSessionObservations(s *store.Store, sessionID string) tea.Cmd { func deleteSession(s *store.Store, sessionID string) tea.Cmd { return func() tea.Msg { + if s == nil { + return sessionDeletedMsg{sessionID: sessionID, err: errors.New("store is unavailable")} + } err := s.DeleteSession(sessionID) return sessionDeletedMsg{sessionID: sessionID, err: err} } diff --git a/internal/tui/update.go b/internal/tui/update.go index f9040ab3..b2ae4b76 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -1,9 +1,12 @@ package tui import ( + "errors" + "fmt" "time" "github.com/Gentleman-Programming/engram/internal/setup" + "github.com/Gentleman-Programming/engram/internal/store" "github.com/charmbracelet/bubbles/spinner" tea "github.com/charmbracelet/bubbletea" ) @@ -112,11 +115,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case sessionDeletedMsg: - m.SessionDeletePrompt = false - m.SessionDeleteID = "" - m.SessionDeleteProject = "" + m = m.resetSessionDeleteState() if msg.err != nil { - m.ErrorMsg = msg.err.Error() + m.ErrorMsg = sessionDeleteErrorMessage(msg.sessionID, msg.err) return m, nil } return m, loadRecentSessions(m.store) @@ -459,18 +460,22 @@ func (m Model) handleTimelineKeys(key string) (tea.Model, tea.Cmd) { // ─── Sessions ──────────────────────────────────────────────────────────────── func (m Model) handleSessionsKeys(key string) (tea.Model, tea.Cmd) { + if m.SessionDeleting { + return m, nil + } if m.SessionDeletePrompt { switch key { case "y", "Y": if m.SessionDeleteID == "" { - m.SessionDeletePrompt = false + m = m.resetSessionDeleteState() return m, nil } - return m, deleteSession(m.store, m.SessionDeleteID) - case "n", "N", "esc": + sessionID := m.SessionDeleteID m.SessionDeletePrompt = false - m.SessionDeleteID = "" - m.SessionDeleteProject = "" + m.SessionDeleting = true + return m, deleteSession(m.store, sessionID) + case "n", "N", "esc": + m = m.resetSessionDeleteState() return m, nil } return m, nil @@ -514,9 +519,7 @@ func (m Model) handleSessionsKeys(key string) (tea.Model, tea.Cmd) { m.Screen = ScreenDashboard m.Cursor = 0 m.Scroll = 0 - m.SessionDeletePrompt = false - m.SessionDeleteID = "" - m.SessionDeleteProject = "" + m = m.resetSessionDeleteState() return m, loadStats(m.store) } return m, nil @@ -640,6 +643,24 @@ func (m Model) handleSetupKeys(key string) (tea.Model, tea.Cmd) { // ─── Helpers ───────────────────────────────────────────────────────────────── +func (m Model) resetSessionDeleteState() Model { + m.SessionDeletePrompt = false + m.SessionDeleting = false + m.SessionDeleteID = "" + m.SessionDeleteProject = "" + return m +} + +func sessionDeleteErrorMessage(sessionID string, err error) string { + if errors.Is(err, store.ErrSessionHasObservations) { + return fmt.Sprintf("Cannot delete session %q: it still has observations. Delete or move observations first.", sessionID) + } + if errors.Is(err, store.ErrSessionNotFound) { + return fmt.Sprintf("Cannot delete session %q: session not found.", sessionID) + } + return fmt.Sprintf("Failed to delete session %q: %v", sessionID, err) +} + // refreshScreen returns the appropriate data-loading Cmd for a given screen. // Used when navigating back so lists show fresh data from the DB. func (m Model) refreshScreen(screen Screen) tea.Cmd { diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 0f8e32a1..ca359c0b 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -2,6 +2,7 @@ package tui import ( "errors" + "strings" "testing" "github.com/Gentleman-Programming/engram/internal/setup" @@ -324,7 +325,7 @@ func TestSessionDeletePromptFlow(t *testing.T) { if cmd != nil { t.Fatal("esc cancel should not return command") } - if updated.SessionDeletePrompt || updated.SessionDeleteID != "" || updated.SessionDeleteProject != "" { + if updated.SessionDeletePrompt || updated.SessionDeleting || updated.SessionDeleteID != "" || updated.SessionDeleteProject != "" { t.Fatal("esc cancel should clear delete prompt state") } @@ -335,7 +336,7 @@ func TestSessionDeletePromptFlow(t *testing.T) { if cmd != nil { t.Fatal("n cancel should not return command") } - if updated.SessionDeletePrompt || updated.SessionDeleteID != "" || updated.SessionDeleteProject != "" { + if updated.SessionDeletePrompt || updated.SessionDeleting || updated.SessionDeleteID != "" || updated.SessionDeleteProject != "" { t.Fatal("n cancel should clear delete prompt state") } }) @@ -354,6 +355,12 @@ func TestSessionDeletePromptFlow(t *testing.T) { if cmd == nil { t.Fatal("confirm should return delete command") } + if updated.SessionDeletePrompt || !updated.SessionDeleting { + t.Fatal("confirm should close prompt and mark delete in progress") + } + if _, secondCmd := updated.handleSessionsKeys("y"); secondCmd != nil { + t.Fatal("second confirm while deleting should be ignored") + } msg := cmd().(sessionDeletedMsg) if msg.err != nil { @@ -361,7 +368,7 @@ func TestSessionDeletePromptFlow(t *testing.T) { } updatedModel, refreshCmd := updated.Update(msg) updated = updatedModel.(Model) - if updated.SessionDeletePrompt || updated.SessionDeleteID != "" { + if updated.SessionDeletePrompt || updated.SessionDeleting || updated.SessionDeleteID != "" { t.Fatal("delete result should clear prompt state") } if refreshCmd == nil { @@ -394,14 +401,32 @@ func TestSessionDeletePromptFlow(t *testing.T) { if refreshCmd != nil { t.Fatal("failed delete should not refresh sessions") } - if updated.ErrorMsg == "" { - t.Fatal("failed delete should surface error message") + if updated.ErrorMsg == "" || !strings.Contains(updated.ErrorMsg, "Cannot delete session") { + t.Fatalf("failed delete should surface contextual error message, got %q", updated.ErrorMsg) } - if updated.SessionDeletePrompt { + if updated.SessionDeletePrompt || updated.SessionDeleting { t.Fatal("failed delete should close prompt") } }) + t.Run("nil store returns graceful error", func(t *testing.T) { + m := New(nil, "") + m.Screen = ScreenSessions + m.Sessions = []store.SessionSummary{{ID: "session-missing-store", Project: "engram"}} + + updatedModel, _ := m.handleSessionsKeys("d") + updated := updatedModel.(Model) + _, cmd := updated.handleSessionsKeys("y") + if cmd == nil { + t.Fatal("confirm should return delete command") + } + + msg := cmd().(sessionDeletedMsg) + if msg.err == nil { + t.Fatal("nil store delete should return an error message") + } + }) + t.Run("delete key ignored without sessions", func(t *testing.T) { m := New(nil, "") m.Screen = ScreenSessions diff --git a/internal/tui/view.go b/internal/tui/view.go index 5808d21a..c9bd61a0 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -451,6 +451,15 @@ func (m Model) viewSessions() string { b.WriteString(headerStyle.Render(header)) b.WriteString("\n") + if m.SessionDeleting { + b.WriteString("\n") + b.WriteString(sectionHeadingStyle.Render(" Deleting Session")) + b.WriteString("\n\n") + b.WriteString(detailContentStyle.Render(fmt.Sprintf(" Deleting session %q...", m.SessionDeleteID))) + b.WriteString("\n") + return b.String() + } + if m.SessionDeletePrompt { b.WriteString("\n") b.WriteString(sectionHeadingStyle.Render(" Confirm Session Delete")) diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index 44500e73..e756c86e 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -328,6 +328,13 @@ func TestViewSessionsDeletePrompt(t *testing.T) { if !strings.Contains(out, "[y] Delete") || !strings.Contains(out, "[n] Cancel") || !strings.Contains(out, "[esc] Cancel") { t.Fatal("delete prompt should render y/n/esc options") } + + m.SessionDeletePrompt = false + m.SessionDeleting = true + out = m.viewSessions() + if !strings.Contains(out, "Deleting Session") || !strings.Contains(out, "session-1") { + t.Fatal("deleting state should render selected session context") + } } func TestViewRouterCoversAllScreens(t *testing.T) { From 93e1b1c78be9cd5484d1af6b699465cf51d0e8b4 Mon Sep 17 00:00:00 2001 From: egdev6 Date: Thu, 28 May 2026 18:05:11 +0200 Subject: [PATCH 3/4] fix(tui): refine session refresh state --- internal/tui/update.go | 17 ++++++++++------- internal/tui/update_test.go | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/internal/tui/update.go b/internal/tui/update.go index b2ae4b76..f1c0c7be 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -92,13 +92,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } m.Sessions = msg.sessions - if len(m.Sessions) == 0 { - m.Cursor = 0 - m.Scroll = 0 - } else if m.Cursor >= len(m.Sessions) { - m.Cursor = len(m.Sessions) - 1 - if m.Scroll > m.Cursor { - m.Scroll = m.Cursor + if m.Screen == ScreenSessions { + if len(m.Sessions) == 0 { + m.Cursor = 0 + m.Scroll = 0 + } else if m.Cursor >= len(m.Sessions) { + m.Cursor = len(m.Sessions) - 1 + if m.Scroll > m.Cursor { + m.Scroll = m.Cursor + } } } return m, nil @@ -120,6 +122,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ErrorMsg = sessionDeleteErrorMessage(msg.sessionID, msg.err) return m, nil } + m.ErrorMsg = "" return m, loadRecentSessions(m.store) case setupInstallMsg: diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index ca359c0b..87ad5626 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -366,11 +366,15 @@ func TestSessionDeletePromptFlow(t *testing.T) { if msg.err != nil { t.Fatalf("delete command error: %v", msg.err) } + updated.ErrorMsg = "stale delete error" updatedModel, refreshCmd := updated.Update(msg) updated = updatedModel.(Model) if updated.SessionDeletePrompt || updated.SessionDeleting || updated.SessionDeleteID != "" { t.Fatal("delete result should clear prompt state") } + if updated.ErrorMsg != "" { + t.Fatalf("successful delete should clear stale error, got %q", updated.ErrorMsg) + } if refreshCmd == nil { t.Fatal("successful delete should refresh sessions") } @@ -545,6 +549,23 @@ func TestUpdateDataMessageBranches(t *testing.T) { t.Fatal("sessions should be updated") } + screenModel := New(nil, "") + screenModel.Screen = ScreenSearch + screenModel.Cursor = 5 + screenModel.Scroll = 4 + updatedModel, _ = screenModel.Update(recentSessionsMsg{sessions: sessions}) + updated = updatedModel.(Model) + if updated.Cursor != 5 || updated.Scroll != 4 { + t.Fatalf("sessions refresh outside sessions screen should not clamp cursor/scroll, got %d/%d", updated.Cursor, updated.Scroll) + } + + screenModel.Screen = ScreenSessions + updatedModel, _ = screenModel.Update(recentSessionsMsg{sessions: sessions}) + updated = updatedModel.(Model) + if updated.Cursor != 0 || updated.Scroll != 0 { + t.Fatalf("sessions refresh on sessions screen should clamp cursor/scroll, got %d/%d", updated.Cursor, updated.Scroll) + } + updatedModel, _ = m.Update(sessionObservationsMsg{err: errors.New("session detail err")}) updated = updatedModel.(Model) if updated.ErrorMsg != "session detail err" { From 1ffa160d0a1f926c2c7ef61a7fbb109d5a2146d4 Mon Sep 17 00:00:00 2001 From: egdev6 Date: Thu, 28 May 2026 19:20:12 +0200 Subject: [PATCH 4/4] fix(tui): make session delete state explicit --- internal/tui/model.go | 11 +++++++++-- internal/tui/update.go | 16 +++++++--------- internal/tui/update_test.go | 18 +++++++++--------- internal/tui/view.go | 7 +++---- internal/tui/view_test.go | 5 ++--- 5 files changed, 30 insertions(+), 27 deletions(-) diff --git a/internal/tui/model.go b/internal/tui/model.go index ecb1d190..536d97d7 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -38,6 +38,14 @@ const ( ScreenSetup ) +type SessionDeleteState int + +const ( + SessionDeleteStateNone SessionDeleteState = iota + SessionDeleteStatePrompt + SessionDeleteStateDeleting +) + // ─── Custom Messages ───────────────────────────────────────────────────────── type updateCheckMsg struct { @@ -132,8 +140,7 @@ type Model struct { SelectedSessionIdx int SessionObservations []store.Observation SessionDetailScroll int - SessionDeletePrompt bool - SessionDeleting bool + SessionDeleteState SessionDeleteState SessionDeleteID string SessionDeleteProject string diff --git a/internal/tui/update.go b/internal/tui/update.go index f1c0c7be..c8f09b11 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -463,10 +463,10 @@ func (m Model) handleTimelineKeys(key string) (tea.Model, tea.Cmd) { // ─── Sessions ──────────────────────────────────────────────────────────────── func (m Model) handleSessionsKeys(key string) (tea.Model, tea.Cmd) { - if m.SessionDeleting { + switch m.SessionDeleteState { + case SessionDeleteStateDeleting: return m, nil - } - if m.SessionDeletePrompt { + case SessionDeleteStatePrompt: switch key { case "y", "Y": if m.SessionDeleteID == "" { @@ -474,8 +474,7 @@ func (m Model) handleSessionsKeys(key string) (tea.Model, tea.Cmd) { return m, nil } sessionID := m.SessionDeleteID - m.SessionDeletePrompt = false - m.SessionDeleting = true + m.SessionDeleteState = SessionDeleteStateDeleting return m, deleteSession(m.store, sessionID) case "n", "N", "esc": m = m.resetSessionDeleteState() @@ -511,10 +510,10 @@ func (m Model) handleSessionsKeys(key string) (tea.Model, tea.Cmd) { sessionID := m.Sessions[m.Cursor].ID return m, loadSessionObservations(m.store, sessionID) } - case "d": + case "d", "D": if len(m.Sessions) > 0 && m.Cursor < len(m.Sessions) { session := m.Sessions[m.Cursor] - m.SessionDeletePrompt = true + m.SessionDeleteState = SessionDeleteStatePrompt m.SessionDeleteID = session.ID m.SessionDeleteProject = session.Project } @@ -647,8 +646,7 @@ func (m Model) handleSetupKeys(key string) (tea.Model, tea.Cmd) { // ─── Helpers ───────────────────────────────────────────────────────────────── func (m Model) resetSessionDeleteState() Model { - m.SessionDeletePrompt = false - m.SessionDeleting = false + m.SessionDeleteState = SessionDeleteStateNone m.SessionDeleteID = "" m.SessionDeleteProject = "" return m diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go index 87ad5626..2e9eaccd 100644 --- a/internal/tui/update_test.go +++ b/internal/tui/update_test.go @@ -311,13 +311,13 @@ func TestSessionDeletePromptFlow(t *testing.T) { m.Sessions = []store.SessionSummary{{ID: fx.sessionID, Project: "engram"}, {ID: fx.otherSession, Project: "engram"}} m.Cursor = 1 - updatedModel, cmd := m.handleSessionsKeys("d") + updatedModel, cmd := m.handleSessionsKeys("D") updated := updatedModel.(Model) if cmd != nil { t.Fatal("opening delete prompt should not return command") } - if !updated.SessionDeletePrompt || updated.SessionDeleteID != fx.otherSession || updated.SessionDeleteProject != "engram" { - t.Fatalf("delete prompt state = prompt:%v id:%q project:%q", updated.SessionDeletePrompt, updated.SessionDeleteID, updated.SessionDeleteProject) + if updated.SessionDeleteState != SessionDeleteStatePrompt || updated.SessionDeleteID != fx.otherSession || updated.SessionDeleteProject != "engram" { + t.Fatalf("delete prompt state = state:%v id:%q project:%q", updated.SessionDeleteState, updated.SessionDeleteID, updated.SessionDeleteProject) } updatedModel, cmd = updated.handleSessionsKeys("esc") @@ -325,7 +325,7 @@ func TestSessionDeletePromptFlow(t *testing.T) { if cmd != nil { t.Fatal("esc cancel should not return command") } - if updated.SessionDeletePrompt || updated.SessionDeleting || updated.SessionDeleteID != "" || updated.SessionDeleteProject != "" { + if updated.SessionDeleteState != SessionDeleteStateNone || updated.SessionDeleteID != "" || updated.SessionDeleteProject != "" { t.Fatal("esc cancel should clear delete prompt state") } @@ -336,7 +336,7 @@ func TestSessionDeletePromptFlow(t *testing.T) { if cmd != nil { t.Fatal("n cancel should not return command") } - if updated.SessionDeletePrompt || updated.SessionDeleting || updated.SessionDeleteID != "" || updated.SessionDeleteProject != "" { + if updated.SessionDeleteState != SessionDeleteStateNone || updated.SessionDeleteID != "" || updated.SessionDeleteProject != "" { t.Fatal("n cancel should clear delete prompt state") } }) @@ -355,7 +355,7 @@ func TestSessionDeletePromptFlow(t *testing.T) { if cmd == nil { t.Fatal("confirm should return delete command") } - if updated.SessionDeletePrompt || !updated.SessionDeleting { + if updated.SessionDeleteState != SessionDeleteStateDeleting { t.Fatal("confirm should close prompt and mark delete in progress") } if _, secondCmd := updated.handleSessionsKeys("y"); secondCmd != nil { @@ -369,7 +369,7 @@ func TestSessionDeletePromptFlow(t *testing.T) { updated.ErrorMsg = "stale delete error" updatedModel, refreshCmd := updated.Update(msg) updated = updatedModel.(Model) - if updated.SessionDeletePrompt || updated.SessionDeleting || updated.SessionDeleteID != "" { + if updated.SessionDeleteState != SessionDeleteStateNone || updated.SessionDeleteID != "" { t.Fatal("delete result should clear prompt state") } if updated.ErrorMsg != "" { @@ -408,7 +408,7 @@ func TestSessionDeletePromptFlow(t *testing.T) { if updated.ErrorMsg == "" || !strings.Contains(updated.ErrorMsg, "Cannot delete session") { t.Fatalf("failed delete should surface contextual error message, got %q", updated.ErrorMsg) } - if updated.SessionDeletePrompt || updated.SessionDeleting { + if updated.SessionDeleteState != SessionDeleteStateNone { t.Fatal("failed delete should close prompt") } }) @@ -439,7 +439,7 @@ func TestSessionDeletePromptFlow(t *testing.T) { if cmd != nil { t.Fatal("delete with no sessions should not return command") } - if updated.SessionDeletePrompt { + if updated.SessionDeleteState != SessionDeleteStateNone { t.Fatal("delete with no sessions should not open prompt") } }) diff --git a/internal/tui/view.go b/internal/tui/view.go index c9bd61a0..465332e3 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -451,16 +451,15 @@ func (m Model) viewSessions() string { b.WriteString(headerStyle.Render(header)) b.WriteString("\n") - if m.SessionDeleting { + switch m.SessionDeleteState { + case SessionDeleteStateDeleting: b.WriteString("\n") b.WriteString(sectionHeadingStyle.Render(" Deleting Session")) b.WriteString("\n\n") b.WriteString(detailContentStyle.Render(fmt.Sprintf(" Deleting session %q...", m.SessionDeleteID))) b.WriteString("\n") return b.String() - } - - if m.SessionDeletePrompt { + case SessionDeleteStatePrompt: b.WriteString("\n") b.WriteString(sectionHeadingStyle.Render(" Confirm Session Delete")) b.WriteString("\n\n") diff --git a/internal/tui/view_test.go b/internal/tui/view_test.go index e756c86e..8f161289 100644 --- a/internal/tui/view_test.go +++ b/internal/tui/view_test.go @@ -314,7 +314,7 @@ func TestViewSessionsDeletePrompt(t *testing.T) { m := New(nil, "") m.Screen = ScreenSessions m.Sessions = []store.SessionSummary{{ID: "session-1", Project: "engram", StartedAt: "2026-01-01"}} - m.SessionDeletePrompt = true + m.SessionDeleteState = SessionDeleteStatePrompt m.SessionDeleteID = "session-1" m.SessionDeleteProject = "engram" @@ -329,8 +329,7 @@ func TestViewSessionsDeletePrompt(t *testing.T) { t.Fatal("delete prompt should render y/n/esc options") } - m.SessionDeletePrompt = false - m.SessionDeleting = true + m.SessionDeleteState = SessionDeleteStateDeleting out = m.viewSessions() if !strings.Contains(out, "Deleting Session") || !strings.Contains(out, "session-1") { t.Fatal("deleting state should render selected session context")