From bc1302810766f36ef051d14ce7debb4b69bb59eb Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Wed, 8 Jul 2026 18:28:24 +0900 Subject: [PATCH] fix: queue on-demand ref extracts in a map so none get stranded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JIRA: https://sendbird.atlassian.net/browse/CPLAT-10756 Some sessions stayed on "Resolving PR/Jira status…" forever. Cause: the refs preview reached from the View/render path sets refsInFlight[id]=true and records the session as the single pending on-demand extract, then discards the returned cmd (the render path can't dispatch). handleTick was supposed to flush that pending extract on the next 3s tick — but there was only ONE pending slot. Switching sessions before the tick fired overwrote it, so the earlier session kept refsInFlight=true with no extract ever dispatched, and its "Resolving…" placeholder never cleared. The refsInFlight guard then blocked any retry. Replace the single pending slot (sessRefsResolveID/Path) with a map keyed by session ID (sessRefsPending). handleTick flushes every queued entry as a tea.Batch and clears the map, so rapidly switching sessions can no longer drop a pending extract. Adds TestRefsPendingFlushDoesNotDropSessions, which queues two sessions and asserts handleTick dispatches a refsExtractedMsg for both and empties the queue. --- internal/tui/app.go | 29 ++++++++++------- internal/tui/refs_preview_test.go | 53 +++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/internal/tui/app.go b/internal/tui/app.go index 6d5f8d5..cfd5de2 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -339,8 +339,7 @@ type App struct { sessPreviewRefs []session.SessionRef // ordered refs shown in the References preview (open PRs first) sessRefsCursor int // cursor within the References preview list sessRefsResolved bool // whether the currently-previewed session's refs have been resolved - sessRefsResolveID string // session ID awaiting on-demand ref resolution (flushed to a cmd in handleTick) - sessRefsResolvePath string // transcript path for the pending on-demand resolve + sessRefsPending map[string]string // session ID → transcript path awaiting on-demand extract (flushed in handleTick); a map so switching sessions before the tick fires never drops an earlier session's pending extract refsInFlight map[string]bool // session IDs with a resolve pass currently running (prevents re-targeting every tick) // Conversation preview state @@ -753,6 +752,7 @@ func NewApp(sessions []session.Session, cfg Config) *App { selectedSet: make(map[string]bool), hiddenBadges: make(map[string]bool), refsInFlight: make(map[string]bool), + sessRefsPending: make(map[string]string), notifyPrev: make(map[string]session.LifecycleState), sessionRowCache: newSessionRowCache(1024), convPreviewRowCache: newSessionRowCache(4096), @@ -4271,14 +4271,19 @@ func (a *App) refreshRespondingState() { } func (a *App) handleTick() tea.Cmd { - // Flush any pending on-demand ref extract first (set from the render path, - // where the cmd would otherwise be discarded). This guarantees the refs - // preview populates even when liveUpdate is off and no navigation follows. + // Flush any pending on-demand ref extracts first (set from the render path, + // where the returned cmd would otherwise be discarded). This guarantees the + // refs preview populates even when liveUpdate is off and no navigation + // follows. It is a map, not a single slot, so switching sessions before the + // tick fires never strands an earlier session on "Resolving…" forever. var refsCmd tea.Cmd - if a.sessRefsResolveID != "" { - refsCmd = a.extractSessionRefsCmd(a.sessRefsResolveID, a.sessRefsResolvePath) - a.sessRefsResolveID = "" - a.sessRefsResolvePath = "" + if len(a.sessRefsPending) > 0 { + cmds := make([]tea.Cmd, 0, len(a.sessRefsPending)) + for id, path := range a.sessRefsPending { + cmds = append(cmds, a.extractSessionRefsCmd(id, path)) + } + a.sessRefsPending = make(map[string]string) + refsCmd = tea.Batch(cmds...) } // Always refresh conversation preview for live sessions (regardless of liveUpdate) if a.state == viewSessions && a.sessSplit.Show && a.sessPreviewMode == sessPreviewConversation { @@ -5572,8 +5577,10 @@ func (a *App) updateSessionRefsPreview(sess session.Session) tea.Cmd { // refsInFlight so repeated renders/ticks don't re-parse + re-resolve the // same session while a pass is already running (that spiked CPU). resolveCmd = a.extractSessionRefsCmd(sess.ID, sess.FilePath) - a.sessRefsResolveID = sess.ID - a.sessRefsResolvePath = sess.FilePath + if a.sessRefsPending == nil { + a.sessRefsPending = make(map[string]string) + } + a.sessRefsPending[sess.ID] = sess.FilePath a.refsInFlight[sess.ID] = true } // Order refs (open PRs first) and stash them so the focused-pane key handler diff --git a/internal/tui/refs_preview_test.go b/internal/tui/refs_preview_test.go index 3d0d68d..33520f2 100644 --- a/internal/tui/refs_preview_test.go +++ b/internal/tui/refs_preview_test.go @@ -4,9 +4,62 @@ import ( "strings" "testing" + tea "github.com/charmbracelet/bubbletea" + "github.com/sendbird/ccx/internal/session" ) +// TestRefsPendingFlushDoesNotDropSessions guards the "stuck on Resolving…" bug: +// the on-demand extract is queued in a map (sessRefsPending), not a single slot, +// so switching sessions before the 3s tick fires can no longer strand an earlier +// session with refsInFlight=true but no extract ever dispatched. handleTick must +// flush ALL pending entries and clear the queue. +func TestRefsPendingFlushDoesNotDropSessions(t *testing.T) { + a := newTestApp(fakeSessions()) + a.liveUpdate = false // so handleTick returns only the pending-flush batch + + // Two sessions queued their extract via the render path (cmd discarded there). + a.sessRefsPending["aaa"] = "/tmp/proj-a/aaa.jsonl" + a.sessRefsPending["bbb"] = "/tmp/proj-b/bbb.jsonl" + a.refsInFlight["aaa"] = true + a.refsInFlight["bbb"] = true + + cmd := a.handleTick() + + if len(a.sessRefsPending) != 0 { + t.Errorf("pending not fully flushed: %v", a.sessRefsPending) + } + if cmd == nil { + t.Fatal("handleTick returned nil cmd despite pending extracts") + } + // Draining the batch must yield a refsExtractedMsg for BOTH queued sessions, + // proving neither was dropped. + got := map[string]bool{} + collectExtractedIDs(cmd, got) + for _, id := range []string{"aaa", "bbb"} { + if !got[id] { + t.Errorf("no refsExtractedMsg dispatched for session %q (would stay stuck on Resolving…)", id) + } + } +} + +// collectExtractedIDs runs a (possibly batched) tea.Cmd and records the session +// IDs of any refsExtractedMsg it produces. +func collectExtractedIDs(cmd tea.Cmd, out map[string]bool) { + if cmd == nil { + return + } + msg := cmd() + switch m := msg.(type) { + case refsExtractedMsg: + out[m.id] = true + case tea.BatchMsg: + for _, c := range m { + collectExtractedIDs(c, out) + } + } +} + func TestRenderSessionRefs(t *testing.T) { a := &App{} // No refs at all.