Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions internal/tui/refs_preview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading