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
310 changes: 257 additions & 53 deletions internal/tui/app.go

Large diffs are not rendered by default.

24 changes: 14 additions & 10 deletions internal/tui/cmdmode.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,21 +85,21 @@ func buildCmdRegistry() []cmdEntry {

// Preview modes (sessions only)
{name: "preview:conv", aliases: []string{"p:conv"}, desc: "conversation preview", views: cmdSessions,
action: func(a *App) (tea.Model, tea.Cmd) { a.setSessPreviewMode(sessPreviewConversation); return a, nil }},
action: func(a *App) (tea.Model, tea.Cmd) { return a, a.setSessPreviewMode(sessPreviewConversation) }},
{name: "preview:stats", aliases: []string{"p:stats"}, desc: "stats preview", views: cmdSessions,
action: func(a *App) (tea.Model, tea.Cmd) { a.setSessPreviewMode(sessPreviewStats); return a, nil }},
action: func(a *App) (tea.Model, tea.Cmd) { return a, a.setSessPreviewMode(sessPreviewStats) }},
{name: "preview:mem", aliases: []string{"p:mem"}, desc: "memory preview", views: cmdSessions,
action: func(a *App) (tea.Model, tea.Cmd) { a.setSessPreviewMode(sessPreviewMemory); return a, nil }},
action: func(a *App) (tea.Model, tea.Cmd) { return a, a.setSessPreviewMode(sessPreviewMemory) }},
{name: "preview:tasks", aliases: []string{"p:tasks"}, desc: "tasks/plan preview", views: cmdSessions,
action: func(a *App) (tea.Model, tea.Cmd) { a.setSessPreviewMode(sessPreviewTasksPlan); return a, nil }},
action: func(a *App) (tea.Model, tea.Cmd) { return a, a.setSessPreviewMode(sessPreviewTasksPlan) }},
{name: "preview:agents", aliases: []string{"p:agents"}, desc: "agents preview", views: cmdSessions,
action: func(a *App) (tea.Model, tea.Cmd) { a.setSessPreviewMode(sessPreviewAgents); return a, nil }},
action: func(a *App) (tea.Model, tea.Cmd) { return a, a.setSessPreviewMode(sessPreviewAgents) }},
{name: "preview:wf", aliases: []string{"p:wf", "preview:workflows", "p:workflows"}, desc: "workflow preview", views: cmdSessions,
action: func(a *App) (tea.Model, tea.Cmd) { a.setSessPreviewMode(sessPreviewWorkflows); return a, nil }},
action: func(a *App) (tea.Model, tea.Cmd) { return a, a.setSessPreviewMode(sessPreviewWorkflows) }},
{name: "preview:contexts", aliases: []string{"p:contexts", "p:ctx", "contexts", "page:contexts"}, desc: "context tree preview", views: cmdSessions,
action: func(a *App) (tea.Model, tea.Cmd) { a.setSessPreviewMode(sessPreviewContexts); return a, nil }},
action: func(a *App) (tea.Model, tea.Cmd) { return a, a.setSessPreviewMode(sessPreviewContexts) }},
{name: "preview:refs", aliases: []string{"p:refs", "refs", "preview:pr", "p:pr"}, desc: "PR/Jira references preview", views: cmdSessions,
action: func(a *App) (tea.Model, tea.Cmd) { a.setSessPreviewMode(sessPreviewRefs); return a, nil }},
action: func(a *App) (tea.Model, tea.Cmd) { return a, a.setSessPreviewMode(sessPreviewRefs) }},
{name: "preview:live", aliases: []string{"p:live"}, desc: "live preview", views: cmdSessions,
action: func(a *App) (tea.Model, tea.Cmd) {
sess, ok := a.selectedSession()
Expand Down Expand Up @@ -398,8 +398,11 @@ func buildCmdRegistry() []cmdEntry {
}

// setSessPreviewMode switches the session preview to the given mode,
// opening the split pane if needed.
func (a *App) setSessPreviewMode(mode sessPreview) {
// opening the split pane if needed. It returns a tea.Cmd that must be
// dispatched by the caller (Update path) — for the References mode this is the
// offline extract + status resolve; discarding it would leave the pane stuck on
// "Resolving…" until the next tick (or forever, if navigation intervenes).
func (a *App) setSessPreviewMode(mode sessPreview) tea.Cmd {
a.closePaneProxy()
a.sessPreviewMode = mode
a.sessSplit.CacheKey = ""
Expand All @@ -410,6 +413,7 @@ func (a *App) setSessPreviewMode(mode sessPreview) {
a.sessionList.SetSize(a.sessSplit.ListWidth(a.width, a.splitRatio), contentH)
a.sessionList.Select(idx)
}
return a.updateSessionPreview()
}

// setConvDetailLevel sets the conversation preview detail level and re-renders.
Expand Down
6 changes: 6 additions & 0 deletions internal/tui/live_preview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ func newTestApp(sessions []session.Session) *App {
// developer's current browser state.
a.config.SearchQuery = ""
a.sessionList.ResetFilter()
// A persisted "refs" preview mode (preview_mode: refs in the dev's config)
// makes startup arm refsInFlight for on-screen sessions; clear it (and the
// split visibility) so ref tests start from a clean slate regardless of
// local prefs.
a.refsInFlight = make(map[string]bool)
a.sessSplit.Show = false
// Default to flat grouping for tests so existing index/visible-item
// assertions keep holding regardless of the production default.
a.sessGroupMode = groupFlat
Expand Down
45 changes: 19 additions & 26 deletions internal/tui/refs_preview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,37 +9,30 @@ import (
"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
// TestSetRefsPreviewModeDispatchesExtract guards the "stuck on Resolving…" bug:
// entering References mode must return a tea.Cmd that actually runs the offline
// extract for the selected session. Previously setSessPreviewMode returned no
// cmd and relied on the View render path (which discards cmds) plus a tick
// flush, so a session could sit on "Resolving…" indefinitely.
func TestSetRefsPreviewModeDispatchesExtract(t *testing.T) {
sessions := fakeSessions()
sessions[0].HasRefs = true // selected session has links to extract
sessions[0].FilePath = "/tmp/proj-a/aaa.jsonl" // non-empty so the extract cmd is created (file need not exist)
a := newTestApp(sessions)
a.sessionList.Select(0)

// 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)
}
cmd := a.setSessPreviewMode(sessPreviewRefs)
if cmd == nil {
t.Fatal("handleTick returned nil cmd despite pending extracts")
t.Fatal("setSessPreviewMode(refs) returned nil cmd — extract never dispatched, pane stays on Resolving…")
}
// 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)
}
if !got[sessions[0].ID] {
t.Errorf("no refsExtractedMsg dispatched for selected session %q", sessions[0].ID)
}
// The dedup guard must be armed so repeated renders don't re-parse.
if !a.refsInFlight[sessions[0].ID] {
t.Errorf("refsInFlight not set for %q after dispatching extract", sessions[0].ID)
}
}

Expand Down
205 changes: 205 additions & 0 deletions internal/tui/refs_repro_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
package tui

import (
"strings"
"testing"
"time"

"github.com/sendbird/ccx/internal/session"
)

// TestRefsExtractedReflectedInPreview guards the root cause of the recurring
// "stuck on Resolving…" bug. The async extract handler updates a.sessions[i].Refs
// but does NOT rebuild the session list, so selectedSession() (the list widget's
// snapshot copy) stays stale. The preview must read the authoritative session
// from a.sessions, or it renders "Resolving…" forever even after extraction
// completed.
func TestRefsExtractedReflectedInPreview(t *testing.T) {
sessions := []session.Session{
{ID: "aaa", ShortID: "aaa", ProjectPath: "/tmp/proj-a", ProjectName: "proj-a",
ModTime: time.Now(), HasRefs: true, FilePath: "/tmp/proj-a/aaa.jsonl"},
}
a := newTestApp(sessions)
a.sessionList.Select(0)
a.sessSplit.Show = true

// Enter refs mode (dispatches extract; arms refsInFlight).
_ = a.setSessPreviewMode(sessPreviewRefs)

// Simulate the offline extract landing with one PR ref.
extracted := []session.SessionRef{
{Kind: session.RefPR, URL: "https://github.com/sendbird/ccx/pull/52",
Label: "sendbird/ccx#52", FirstSeen: time.Now()},
}
m, _ := a.Update(refsExtractedMsg{id: "aaa", refs: extracted})
a = m.(*App)

// a.sessions is the source of truth — it must carry the extracted ref.
if len(a.sessions[0].Refs) != 1 {
t.Fatalf("a.sessions[0].Refs = %d, want 1", len(a.sessions[0].Refs))
}
// The rendered preview must show the ref, not the stale "Resolving…" state.
if !strings.Contains(a.sessRefsCache, "sendbird/ccx#52") {
t.Errorf("preview missing extracted ref; got:\n%s", a.sessRefsCache)
}
if strings.Contains(a.sessRefsCache, "Resolving") {
t.Errorf("preview stuck on Resolving after extract landed; got:\n%s", a.sessRefsCache)
}
}

// TestRefsSurviveRescan guards the second half of the same root cause: a full
// rescan (manual refresh / new-session autorefresh) replaces a.sessions with a
// freshly-scanned slice that has HasRefs but empty Refs. Resolved status must
// carry over, or the preview flips back to "Resolving…" / "No resolvable".
func TestRefsSurviveRescan(t *testing.T) {
sessions := []session.Session{
{ID: "aaa", ShortID: "aaa", ProjectPath: "/tmp/proj-a", ProjectName: "proj-a",
ModTime: time.Now(), HasRefs: true, FilePath: "/tmp/proj-a/aaa.jsonl"},
}
a := newTestApp(sessions)
// Pretend a resolve pass already completed. Pin ModTime so the transcript
// reads as unchanged across the rescan (the "grew since resolve" path is
// covered separately by TestCarryOverRefStateReresolvesOnTranscriptGrowth).
mtime := time.Now()
a.sessions[0].ModTime = mtime
a.sessions[0].Refs = []session.SessionRef{
{Kind: session.RefPR, URL: "https://github.com/sendbird/ccx/pull/52",
Label: "sendbird/ccx#52", State: session.RefStateOpen, Resolved: true},
}
a.sessions[0].RefsResolved = true

// A fresh scan yields the same session but without resolved ref state.
fresh := []session.Session{
{ID: "aaa", ShortID: "aaa", ProjectPath: "/tmp/proj-a", ProjectName: "proj-a",
ModTime: mtime, HasRefs: true, FilePath: "/tmp/proj-a/aaa.jsonl"},
}
a.carryOverRefState(fresh)

if len(fresh[0].Refs) != 1 || !fresh[0].RefsResolved {
t.Errorf("rescan dropped resolved refs: Refs=%d RefsResolved=%v",
len(fresh[0].Refs), fresh[0].RefsResolved)
}
}

// TestCarryOverRefStateReresolvesOnTranscriptGrowth: when a resolved session's
// transcript changed since resolution, the cached refs stay visible but
// RefsResolved is cleared so the next preview open re-resolves and picks up any
// newly-added links.
func TestCarryOverRefStateReresolvesOnTranscriptGrowth(t *testing.T) {
a := &App{refsInFlight: map[string]bool{}}
old := time.Now().Add(-time.Hour)
a.sessions = []session.Session{{
ID: "aaa", ModTime: old, HasRefs: true, RefsResolved: true,
Refs: []session.SessionRef{{Kind: session.RefPR, Label: "x#1", Resolved: true}},
}}
fresh := []session.Session{{ID: "aaa", ModTime: time.Now(), HasRefs: true}} // grew
a.carryOverRefState(fresh)

if len(fresh[0].Refs) != 1 {
t.Errorf("cached refs should stay visible across rescan, got %d", len(fresh[0].Refs))
}
if fresh[0].RefsResolved {
t.Error("RefsResolved should be cleared so re-resolve picks up new links")
}
}

// TestCarryOverRefStateKeepsMidFlight: a session still resolving (extracted Refs
// present, RefsResolved=false) must keep its Refs across a rescan so the in-
// flight refStatusMsg has a list to merge into — otherwise it strands on a bogus
// "No resolvable references".
func TestCarryOverRefStateKeepsMidFlight(t *testing.T) {
a := &App{refsInFlight: map[string]bool{"aaa": true}}
a.sessions = []session.Session{{
ID: "aaa", ModTime: time.Now(), HasRefs: true, RefsResolved: false,
Refs: []session.SessionRef{{Kind: session.RefPR, Label: "x#1"}},
}}
fresh := []session.Session{{ID: "aaa", ModTime: time.Now(), HasRefs: true}}
a.carryOverRefState(fresh)

if len(fresh[0].Refs) != 1 {
t.Errorf("mid-flight refs dropped across rescan, got %d", len(fresh[0].Refs))
}
}

// TestResolveVisibleRefsCmdOnlyVisibleUnresolved verifies async badge fill-in is
// scoped correctly: resolveVisibleRefsCmd dispatches an extract for on-screen
// sessions that have links but aren't resolved yet, and skips sessions that are
// already resolved, have no links, or have a resolve in flight. This is the
// guard against regressing into the fleet-wide sweep removed in #60.
func TestResolveVisibleRefsCmdOnlyVisibleUnresolved(t *testing.T) {
sessions := []session.Session{
{ID: "needs", ShortID: "needs", ProjectPath: "/tmp/a", ProjectName: "a",
ModTime: time.Now(), HasRefs: true, FilePath: "/tmp/a/needs.jsonl"},
{ID: "done", ShortID: "done", ProjectPath: "/tmp/b", ProjectName: "b",
ModTime: time.Now().Add(-time.Minute), HasRefs: true, RefsResolved: true,
FilePath: "/tmp/b/done.jsonl"},
{ID: "norefs", ShortID: "norefs", ProjectPath: "/tmp/c", ProjectName: "c",
ModTime: time.Now().Add(-2 * time.Minute), FilePath: "/tmp/c/norefs.jsonl"},
}
a := newTestApp(sessions)

cmd := a.resolveVisibleRefsCmd()
if cmd == nil {
t.Fatal("expected an extract cmd for the unresolved visible session")
}
got := map[string]bool{}
collectExtractedIDs(cmd, got)
if !got["needs"] {
t.Error("did not dispatch extract for the unresolved session with links")
}
if got["done"] {
t.Error("re-extracted an already-resolved session")
}
if got["norefs"] {
t.Error("extracted a session that has no links")
}
if !a.refsInFlight["needs"] {
t.Error("refsInFlight guard not armed for the dispatched session")
}

// Second call must be a no-op: refsInFlight dedups so a row visible across
// many ticks is only worked once (no fan-out storm).
if cmd2 := a.resolveVisibleRefsCmd(); cmd2 != nil {
got2 := map[string]bool{}
collectExtractedIDs(cmd2, got2)
if got2["needs"] {
t.Error("re-dispatched extract for an in-flight session")
}
}
}

// TestSyncSessionRefsToListUpdatesBadge verifies that once a ref resolves in
// a.sessions, syncing it to the list row makes the open-PR badge count reflect
// it — without a full list rebuild.
func TestSyncSessionRefsToListUpdatesBadge(t *testing.T) {
sessions := []session.Session{
{ID: "aaa", ShortID: "aaa", ProjectPath: "/tmp/a", ProjectName: "a",
ModTime: time.Now(), HasRefs: true, FilePath: "/tmp/a/aaa.jsonl"},
}
a := newTestApp(sessions)

// Before resolve: the list row shows no open PRs.
if row, ok := a.sessionByID("aaa"); ok {
if prs, _ := row.sess.OpenRefCounts(); prs != 0 {
t.Fatalf("precondition: expected 0 open PRs, got %d", prs)
}
}

// Resolve an open PR into the source of truth, then sync to the list.
a.sessions[0].Refs = []session.SessionRef{
{Kind: session.RefPR, URL: "https://github.com/sendbird/ccx/pull/52",
Label: "sendbird/ccx#52", State: session.RefStateOpen, Resolved: true},
}
a.sessions[0].RefsResolved = true
if !a.syncSessionRefsToList("aaa") {
t.Fatal("syncSessionRefsToList did not find the row")
}

row, ok := a.sessionByID("aaa")
if !ok {
t.Fatal("row missing after sync")
}
if prs, _ := row.sess.OpenRefCounts(); prs != 1 {
t.Errorf("badge count not updated: expected 1 open PR, got %d", prs)
}
}
Loading
Loading