From a0de8c58e3b41bb0276aaa8a8150b93a482a73f7 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sun, 9 Aug 2026 18:15:40 +0200 Subject: [PATCH] fix(tui): fade the resumed-session notice instead of pinning it The "resumed session " notice was posted as a sticky note, so it stayed on screen until six newer notices pushed it out. Make it transient like the other info traces. Same symptom, second cause: transient notes posted outside handleEvent (ctrl+t, ctrl+e, cancel acks, session resume) never armed the noticeExpireMsg sweep, so they only faded when an unrelated render happened. Add transientNoteCmd, which posts the note and returns the sweep cmd, and batch it at every non-event call site. --- internal/tui/dispatch_test.go | 42 ++++++++++++++++++++++++++++++----- internal/tui/events.go | 10 +++++++++ internal/tui/model.go | 14 ++++++------ internal/tui/panels.go | 18 ++++++++------- 4 files changed, 63 insertions(+), 21 deletions(-) diff --git a/internal/tui/dispatch_test.go b/internal/tui/dispatch_test.go index 441835e..542e9c3 100644 --- a/internal/tui/dispatch_test.go +++ b/internal/tui/dispatch_test.go @@ -1,6 +1,7 @@ package tui import ( + "strings" "testing" "time" @@ -81,14 +82,43 @@ func TestPanelKeyBoundsAndQuit(t *testing.T) { func TestCancelRunGuards(t *testing.T) { m := wired(t) - // Not busy → nil. - if m.cancelRun() != nil { - t.Error("cancelRun not busy should be nil") + // Not busy → no cancel API call, just the "nothing to cancel" note and + // its expiry sweep. + if cmd := m.cancelRun(); cmd == nil { + t.Error("cancelRun not busy should still return the notice sweep") } - // Busy but no session id → nil. + if n := len(m.notices); n == 0 || m.notices[n-1] != "nothing to cancel" || + m.noticeExp[n-1].IsZero() { + t.Errorf("idle cancelRun should post a transient note, got %v", m.notices) + } + // Busy but no session id → same note path, no cancel. m.busy = true - if m.cancelRun() != nil { - t.Error("cancelRun without session should be nil") + m.notices = nil + m.noticeExp = nil + if cmd := m.cancelRun(); cmd == nil { + t.Error("cancelRun without session should still return the notice sweep") + } + if n := len(m.notices); n == 0 || m.notices[n-1] != "nothing to cancel" { + t.Errorf("sessionless cancelRun should post the note, got %v", m.notices) + } +} + +func TestResumedSessionNoteFades(t *testing.T) { + m := wired(t) + cmd := m.handleSessionDetail(sessionDetailMsg{sess: client.Session{ID: "20260808-f25bd6fd-9f2a"}}) + if cmd == nil { + t.Fatal("resume should arm the notice expiry sweep") + } + n := len(m.notices) + if n == 0 || !strings.Contains(m.notices[n-1], "resumed session") { + t.Fatalf("resume note missing: %v", m.notices) + } + if m.noticeExp[n-1].IsZero() { + t.Fatal("resume note must be transient (expiry set), not sticky") + } + m.pruneNotices(time.Now().Add(noticeTTL + time.Second)) + if len(m.notices) != 0 { + t.Errorf("resume note should fade after the TTL, got %v", m.notices) } } diff --git a/internal/tui/events.go b/internal/tui/events.go index 3bf1a4e..553aea3 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -395,6 +395,16 @@ func (m *Model) addTransientNote(s string) { m.noticeSeq++ } +// transientNoteCmd adds a transient note and returns the cmd that sweeps it +// after noticeTTL. handleEvent arms the sweep itself; every other caller +// (key handlers, async results) must batch this cmd or the note only fades +// on the next unrelated render. +func (m *Model) transientNoteCmd(s string) tea.Cmd { + prev := m.noticeSeq + m.addTransientNote(s) + return m.noticeTimer(prev) +} + func (m *Model) pushNote(s string, exp time.Time) { m.notices = append(m.notices, sanitize(s)) m.noticeExp = append(m.noticeExp, exp) diff --git a/internal/tui/model.go b/internal/tui/model.go index 62ba995..bf1cf9e 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -289,8 +289,7 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case sessionDetailMsg: - m.handleSessionDetail(msg) - return m, nil + return m, m.handleSessionDetail(msg) case sessionDeletedMsg: return m, m.handleSessionDeleted(msg) @@ -302,8 +301,9 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } else { // The API accepted the abort; the turn's done event settles the // status shortly after. Acknowledge the keypress in the meantime. - m.addTransientNote("cancelled") + cmd := m.transientNoteCmd("cancelled") m.refresh() + return m, cmd } return m, nil @@ -403,9 +403,9 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.thinkOn { state = "on" } - m.addTransientNote("thinking " + state) + cmd := m.transientNoteCmd("thinking " + state) m.refresh() - return m, nil + return m, cmd case "ctrl+l": if !m.busy { m.clearConversation() @@ -419,10 +419,10 @@ func (m *Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.expandAll { state = "on" } - m.addTransientNote("tool details " + state) + cmd := m.transientNoteCmd("tool details " + state) m.convCount = -1 // re-render the cached transcript prefix too m.refresh() - return m, nil + return m, cmd case "ctrl+p": // Prompt-history recall lives on this dedicated readline-style // binding so bare ↑/↓ are free to scroll the transcript — the far diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 604a53a..1be766f 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -179,10 +179,11 @@ func (m *Model) deleteSelected() tea.Cmd { // for editing instead of firing them into a cancelled session. func (m *Model) cancelRun() tea.Cmd { if !m.busy || m.sessionID == "" { - m.addTransientNote("nothing to cancel") + cmd := m.transientNoteCmd("nothing to cancel") m.refresh() - return nil + return cmd } + var note tea.Cmd if len(m.queue) > 0 { draft := strings.Join(m.queue, "\n") if cur := m.ta.Value(); cur != "" { @@ -192,15 +193,15 @@ func (m *Model) cancelRun() tea.Cmd { m.ta.CursorEnd() m.queue = nil // The textarea content just changed out from under the user — say why. - m.addTransientNote("queued prompts returned to the input") + note = m.transientNoteCmd("queued prompts returned to the input") } m.status = "cancelling" m.refresh() cl := m.cl sid, tok := m.sessionID, m.authToken - return func() tea.Msg { + return tea.Batch(note, func() tea.Msg { return cancelDoneMsg{err: cl.Cancel(sid, tok)} - } + }) } // ── async result handling ──────────────────────────────────────────────────── @@ -244,10 +245,10 @@ func (m *Model) handleLimitsMsg(msg limitsMsg) { m.limits = msg.resp.Limits } -func (m *Model) handleSessionDetail(msg sessionDetailMsg) { +func (m *Model) handleSessionDetail(msg sessionDetailMsg) tea.Cmd { if msg.err != nil { m.panelMsg = "error: " + msg.err.Error() - return + return nil } // Replay the saved transcript into the local view and resume server-side // on the next prompt via session_id + auth_token. @@ -274,8 +275,9 @@ func (m *Model) handleSessionDetail(msg sessionDetailMsg) { m.msgs = m.msgs[:0] m.convCount = -1 // transcript swapped for the resumed one — drop the cache m.replayTranscript(msg.sess.Messages) - m.addNote("resumed session " + shortID(msg.sess.ID)) + note := m.transientNoteCmd("resumed session " + shortID(msg.sess.ID)) m.closePanel() + return note } // replayTranscript rebuilds a saved transcript turn by turn so a resumed