diff --git a/internal/session/refs.go b/internal/session/refs.go index 5b1f9f6..2c032a2 100644 --- a/internal/session/refs.go +++ b/internal/session/refs.go @@ -1,6 +1,8 @@ package session import ( + "bufio" + "bytes" "context" "encoding/json" "io" @@ -46,6 +48,8 @@ type SessionRef struct { URL string Label string // e.g. "sendbird/ccx#52" or "CPLAT-1234" + FirstSeen time.Time // timestamp of the entry where this ref first appeared + State RefState // resolved lifecycle state (RefStateUnknown until fetched) // PR-only detail. @@ -80,24 +84,97 @@ func (r SessionRef) IsOpen() bool { // has no such references. Intended for lazy enrichment when a session preview // or badge needs ref detail. func LoadSessionRefs(ctx context.Context, filePath string) []SessionRef { - entries, err := LoadMessages(filePath) - if err != nil { - return nil - } - refs := ExtractSessionRefs(entries) + refs := ExtractSessionRefsFromFile(filePath) if len(refs) == 0 { return nil } return ResolveRefs(ctx, refs) } +// ExtractSessionRefsFromFile reads a session's JSONL and extracts its PR/Jira +// references WITHOUT resolving status. This is the cheap, offline half of +// LoadSessionRefs — the preview uses it to render URLs/labels/timestamps +// instantly, then resolves status asynchronously. +// +// It deliberately does NOT go through LoadMessages/ParseEntry: fully unmarshaling +// every entry of a multi-megabyte transcript (hooks, tool blocks, content) just +// to find a handful of URLs took ~150ms. Instead we scan raw lines, run the URL +// regex over each, and only when a line contains a ref do we pull its timestamp +// out with a cheap string scan (no JSON decode). Measured ~10x faster. +func ExtractSessionRefsFromFile(filePath string) []SessionRef { + f, err := os.Open(filePath) + if err != nil { + return nil + } + defer f.Close() + + seen := make(map[string]bool) // by canonical Label + var refs []SessionRef + + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 1024*1024), 10*1024*1024) + for sc.Scan() { + line := sc.Bytes() + locs := refURLRegex.FindAll(line, -1) + if len(locs) == 0 { + continue + } + var ts time.Time + tsParsed := false + for _, raw := range locs { + u := cleanRefURL(string(raw)) + if u == "" { + continue + } + ref, ok := classifyRef(u) + if !ok || seen[ref.Label] { + continue + } + if !tsParsed { + ts = lineTimestamp(line) + tsParsed = true + } + seen[ref.Label] = true + ref.FirstSeen = ts + refs = append(refs, ref) + } + } + sortRefs(refs) + return refs +} + +// lineTimestamp pulls the "timestamp":"" value out of a raw JSONL line +// without a full JSON decode. Returns the zero time if absent/unparseable. +func lineTimestamp(line []byte) time.Time { + i := bytes.Index(line, bTimestampKey) + if i < 0 { + return time.Time{} + } + rest := line[i+len(bTimestampKey):] + end := bytes.IndexByte(rest, '"') + if end < 0 { + return time.Time{} + } + t, err := time.Parse(time.RFC3339, string(rest[:end])) + if err != nil { + return time.Time{} + } + return t +} + +var bTimestampKey = []byte(`"timestamp":"`) + // ExtractSessionRefs scans a session's entries for PR and Jira URLs and returns -// a deduplicated, ordered list (PRs first, then Jira). Status is NOT resolved -// here — callers use ResolveRefs for that (it is network-bound and cached). +// a deduplicated, ordered list (PRs first, then Jira). References are keyed by +// their canonical Label (e.g. "sendbird/ccx#52"), so the same PR referenced via +// different URL forms (#discussion anchors, query strings) collapses into one +// entry, keeping the earliest appearance's timestamp in FirstSeen. Status is +// NOT resolved here — callers use ResolveRefs for that (network-bound, cached). func ExtractSessionRefs(entries []Entry) []SessionRef { - seen := make(map[string]bool) + seen := make(map[string]bool) // by canonical Label var refs []SessionRef for i := range entries { + ts := entries[i].Timestamp for _, b := range entries[i].Content { for _, text := range [2]string{b.Text, b.ToolInput} { if text == "" { @@ -105,14 +182,15 @@ func ExtractSessionRefs(entries []Entry) []SessionRef { } for _, raw := range refURLRegex.FindAllString(text, -1) { u := cleanRefURL(raw) - if u == "" || seen[u] { + if u == "" { continue } ref, ok := classifyRef(u) - if !ok { + if !ok || seen[ref.Label] { continue } - seen[u] = true + seen[ref.Label] = true + ref.FirstSeen = ts refs = append(refs, ref) } } @@ -122,16 +200,26 @@ func ExtractSessionRefs(entries []Entry) []SessionRef { return refs } +// sortRefs orders PRs before Jira, then most-recent-first within each kind by +// first appearance so the newest work surfaces at the top of the preview. func sortRefs(refs []SessionRef) { order := map[RefKind]int{RefPR: 0, RefJira: 1} sort.SliceStable(refs, func(i, j int) bool { - return order[refs[i].Kind] < order[refs[j].Kind] + if order[refs[i].Kind] != order[refs[j].Kind] { + return order[refs[i].Kind] < order[refs[j].Kind] + } + return refs[i].FirstSeen.After(refs[j].FirstSeen) }) } // ---- URL classification (kept local so the session package has no dependency // on internal/extract, which itself depends on this package). +// refURLRegex matches URL candidates. A literal backslash terminates the match +// so two URLs glued by a raw "\n" in a JSONL line (or any "\"-escape) do not +// merge into one; cleanRefURL further restores "\/"→"/" and trims trailing +// punctuation. Claude Code JSONL does not escape slashes, so normal URLs are +// unaffected. var refURLRegex = regexp.MustCompile(`https?://[^\s<>"'` + "`" + `\)\]\\]+`) // jiraKeyRegex matches a canonical Jira issue key: uppercase project + number. @@ -222,6 +310,24 @@ var ( jiraAvailable bool ) +// jiraAuthFailed is a process-wide circuit breaker: once a Jira request comes +// back 401/403, the configured token is unusable, so we stop attempting further +// Jira calls (each would otherwise cost a full network round-trip) for the rest +// of the process. Guarded by refCacheMu. +var jiraAuthFailed bool + +func jiraAuthIsFailed() bool { + refCacheMu.Lock() + defer refCacheMu.Unlock() + return jiraAuthFailed +} + +func markJiraAuthFailed() { + refCacheMu.Lock() + jiraAuthFailed = true + refCacheMu.Unlock() +} + func initJiraAuth() { jiraAuthOnce.Do(func() { jiraToken = firstNonEmpty(os.Getenv("JIRA_API_TOKEN"), os.Getenv("JIRA_API_KEY"), os.Getenv("ATLASSIAN_TOKEN")) @@ -238,19 +344,51 @@ func initJiraAuth() { func ResolveRefs(ctx context.Context, refs []SessionRef) []SessionRef { out := make([]SessionRef, len(refs)) for i, r := range refs { - if cached, ok := getCachedRef(r.URL); ok { - out[i] = cached - continue - } - resolved := resolveOne(ctx, r) - resolved.Resolved = true - resolved.FetchedAt = time.Now() - setCachedRef(resolved) - out[i] = resolved + out[i] = ResolveRef(ctx, r) } return out } +// ResolveRef fetches and fills status for a single ref, honoring the URL-keyed +// TTL cache. Safe for concurrent use (the cache is mutex-guarded). Callers that +// want to stream results as they land resolve refs individually via this. +// +// Cache hits return immediately. Cache misses acquire a slot from a small +// process-wide semaphore before spawning gh / making a Jira call, so a preview +// with many refs (plus the background enrich sweep) cannot fan out dozens of gh +// subprocesses at once — that spiked CPU and starved the UI event loop. +func ResolveRef(ctx context.Context, r SessionRef) SessionRef { + if cached, ok := getCachedRef(r.URL); ok { + // The cache is keyed by URL and stores only resolved status; keep this + // occurrence's FirstSeen/Label rather than the cached entry's. + cached.FirstSeen = r.FirstSeen + cached.Label = r.Label + return cached + } + // Bound concurrent network work. Respect ctx cancellation while waiting. + select { + case resolveSem <- struct{}{}: + defer func() { <-resolveSem }() + case <-ctx.Done(): + return r // leave unresolved; a later pass retries + } + // Another goroutine may have resolved this URL while we waited for a slot. + if cached, ok := getCachedRef(r.URL); ok { + cached.FirstSeen = r.FirstSeen + cached.Label = r.Label + return cached + } + resolved := resolveOne(ctx, r) + resolved.Resolved = true + resolved.FetchedAt = time.Now() + setCachedRef(resolved) + return resolved +} + +// resolveSem caps concurrent PR/Jira status resolutions process-wide. gh spawns +// a subprocess per call, so this is deliberately small to protect the UI. +var resolveSem = make(chan struct{}, 4) + func getCachedRef(url string) (SessionRef, bool) { refCacheMu.Lock() defer refCacheMu.Unlock() @@ -360,8 +498,8 @@ type jiraIssueView struct { func resolveJira(ctx context.Context, r SessionRef) SessionRef { initJiraAuth() - if !jiraAvailable { - return r // no creds → link only + if !jiraAvailable || jiraAuthIsFailed() { + return r // no creds, or the token already 401'd → link only, no round-trip } key := r.Label if key == "" { @@ -374,8 +512,13 @@ func resolveJira(ctx context.Context, r SessionRef) SessionRef { } req.SetBasicAuth(jiraEmail, jiraToken) req.Header.Set("Accept", "application/json") - body, ok := httpDoJSON(req) + body, status, ok := httpDoJSON(req) if !ok { + // A bad token fails identically for every issue; trip the breaker so we + // don't pay a network round-trip per Jira ref for the rest of the process. + if status == http.StatusUnauthorized || status == http.StatusForbidden { + markJiraAuthFailed() + } return r } var v jiraIssueView @@ -457,21 +600,22 @@ func httpNewRequest(ctx context.Context, method, url string) (*http.Request, err var refHTTPClient = &http.Client{Timeout: 8 * time.Second} -// httpDoJSON performs the request and returns the body if the status is 2xx. -func httpDoJSON(req *http.Request) ([]byte, bool) { +// httpDoJSON performs the request and returns the body and HTTP status. ok is +// true only on a 2xx response with a readable body. +func httpDoJSON(req *http.Request) (body []byte, status int, ok bool) { resp, err := refHTTPClient.Do(req) if err != nil { - return nil, false + return nil, 0, false } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, false + return nil, resp.StatusCode, false } - body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { - return nil, false + return nil, resp.StatusCode, false } - return body, true + return b, resp.StatusCode, true } // OpenRefCounts returns how many of the session's resolved refs are open PRs diff --git a/internal/session/refs_test.go b/internal/session/refs_test.go index 98c4c6f..f317aba 100644 --- a/internal/session/refs_test.go +++ b/internal/session/refs_test.go @@ -1,10 +1,52 @@ package session import ( + "os" + "path/filepath" "strings" "testing" + "time" ) +// TestExtractSessionRefsFromFile verifies the raw-line fast path: it parses +// URLs and per-line timestamps out of real JSONL without a full JSON decode, +// dedups by label, and orders most-recent-first. +func TestExtractSessionRefsFromFile(t *testing.T) { + dir := t.TempDir() + fp := filepath.Join(dir, "s.jsonl") + // Two assistant lines with RFC3339 timestamps; #52 appears first (older), + // #99 later (newer). Claude Code JSONL does not escape slashes, so URLs + // appear in normal form. + lines := []string{ + `{"type":"assistant","timestamp":"2026-07-01T10:00:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"PR https://github.com/sendbird/ccx/pull/52 and CPLAT-1 https://sendbird.atlassian.net/browse/CPLAT-1234"}]}}`, + `{"type":"assistant","timestamp":"2026-07-02T10:00:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"newer https://github.com/sendbird/ccx/pull/99"}]}}`, + `{"type":"assistant","timestamp":"2026-07-03T10:00:00.000Z","message":{"role":"assistant","content":[{"type":"text","text":"dup https://github.com/sendbird/ccx/pull/52#discussion_r1"}]}}`, + } + if err := os.WriteFile(fp, []byte(strings.Join(lines, "\n")+"\n"), 0o644); err != nil { + t.Fatal(err) + } + refs := ExtractSessionRefsFromFile(fp) + // #52, #99 (deduped), CPLAT-1234 → 3 refs; PRs before Jira, newest PR first. + if len(refs) != 3 { + t.Fatalf("want 3 refs, got %d: %+v", len(refs), refs) + } + if refs[0].Label != "sendbird/ccx#99" || refs[1].Label != "sendbird/ccx#52" { + t.Errorf("PR order = [%s, %s], want [#99, #52]", refs[0].Label, refs[1].Label) + } + if refs[2].Kind != RefJira || refs[2].Label != "CPLAT-1234" { + t.Errorf("ref[2] = %+v, want Jira CPLAT-1234", refs[2]) + } + // Timestamp parsed from the line, earliest kept on dedup for #52. + for _, r := range refs { + if r.FirstSeen.IsZero() { + t.Errorf("%s missing FirstSeen", r.Label) + } + if r.Label == "sendbird/ccx#52" && r.FirstSeen.Day() != 1 { + t.Errorf("#52 FirstSeen = %v, want Jul 1 (earliest)", r.FirstSeen) + } + } +} + func TestExtractSessionRefs(t *testing.T) { entries := []Entry{ {Content: []ContentBlock{ @@ -28,6 +70,41 @@ func TestExtractSessionRefs(t *testing.T) { } } +// TestExtractSessionRefsDedupAndTimestamp verifies label-keyed dedup (the same +// PR via different URL forms collapses to one) keeps the EARLIEST FirstSeen, and +// that within a kind refs sort most-recent-first. +func TestExtractSessionRefsDedupAndTimestamp(t *testing.T) { + t0 := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC) + t1 := t0.Add(1 * time.Hour) + t2 := t0.Add(2 * time.Hour) + entries := []Entry{ + {Timestamp: t0, Content: []ContentBlock{ + {Type: "text", Text: "first https://github.com/sendbird/ccx/pull/52"}, + }}, + {Timestamp: t1, Content: []ContentBlock{ + // same PR via an anchor URL — must dedup to #52, keep t0 as FirstSeen + {Type: "text", Text: "again https://github.com/sendbird/ccx/pull/52#discussion_r1"}, + }}, + {Timestamp: t2, Content: []ContentBlock{ + {Type: "text", Text: "newer https://github.com/sendbird/ccx/pull/99"}, + }}, + } + refs := ExtractSessionRefs(entries) + if len(refs) != 2 { + t.Fatalf("want 2 deduped PRs, got %d: %+v", len(refs), refs) + } + // Most-recent-first: #99 (t2) before #52 (t0). + if refs[0].Label != "sendbird/ccx#99" || refs[1].Label != "sendbird/ccx#52" { + t.Errorf("order = [%s, %s], want [#99, #52]", refs[0].Label, refs[1].Label) + } + // #52 keeps its earliest appearance (t0), not the anchor URL's t1. + for _, r := range refs { + if r.Label == "sendbird/ccx#52" && !r.FirstSeen.Equal(t0) { + t.Errorf("#52 FirstSeen = %v, want %v", r.FirstSeen, t0) + } + } +} + // TestExtractSessionRefsSkipsCompareURL guards that GitHub "/pull/new/" // compare-page URLs (which `gh pr view` can never resolve) are not tracked as PRs. func TestExtractSessionRefsSkipsCompareURL(t *testing.T) { diff --git a/internal/tui/app.go b/internal/tui/app.go index 1d7e150..3ca44c3 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -339,6 +339,10 @@ type App struct { sessAgentCursor int // cursor within agents list 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 + refsInFlight map[string]bool // session IDs with a resolve pass currently running (prevents re-targeting every tick) // Conversation preview state sessConvEntries []mergedMsg // merged conversation messages @@ -749,6 +753,7 @@ func NewApp(sessions []session.Session, cfg Config) *App { splitRatio: 35, selectedSet: make(map[string]bool), hiddenBadges: make(map[string]bool), + refsInFlight: make(map[string]bool), notifyPrev: make(map[string]session.LifecycleState), sessionRowCache: newSessionRowCache(1024), convPreviewRowCache: newSessionRowCache(4096), @@ -1124,6 +1129,67 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { a.updateSearchResults(msg.results) return a, nil + case refsExtractedMsg: + // Offline extract landed: store the link list (status still unresolved) + // and, if this session's refs preview is open, render it immediately so + // URLs/labels/timestamps appear without waiting on the network. Then kick + // off the status resolve, which comes back as refsEnrichedMsg. + var statusCmd tea.Cmd + for i := range a.sessions { + if a.sessions[i].ID != msg.id { + continue + } + a.sessions[i].Refs = msg.refs + if len(msg.refs) == 0 { + // Nothing to resolve — mark done and clear in-flight so we stop + // re-targeting it. + a.sessions[i].RefsResolved = true + delete(a.refsInFlight, msg.id) + } else { + statusCmd = a.resolveRefsStatusCmd(msg.id, msg.refs) + } + break + } + if a.state == viewSessions && a.sessSplit.Show && a.sessPreviewMode == sessPreviewRefs { + if sess, ok := a.selectedSession(); ok && sess.ID == msg.id { + a.sessRefsCacheKey = "" + previewCmd := a.updateSessionRefsPreview(sess) + return a, tea.Batch(previewCmd, statusCmd) + } + } + return a, statusCmd + + case refStatusMsg: + // One ref's status landed: merge it into the session (matched by URL) and, + // if that session's refs preview is open, re-render so it fills in live. + // Mark RefsResolved once every ref in the session has a status. + for i := range a.sessions { + if a.sessions[i].ID != msg.id { + continue + } + allResolved := true + for j := range a.sessions[i].Refs { + if a.sessions[i].Refs[j].URL == msg.ref.URL { + a.sessions[i].Refs[j] = msg.ref + } + if !a.sessions[i].Refs[j].Resolved { + allResolved = false + } + } + if allResolved { + a.sessions[i].RefsResolved = true + delete(a.refsInFlight, msg.id) + } + break + } + if a.state == viewSessions && a.sessSplit.Show && a.sessPreviewMode == sessPreviewRefs { + if sess, ok := a.selectedSession(); ok && sess.ID == msg.id { + a.sessRefsCacheKey = "" // force re-render with the newly-resolved ref + return a, a.updateSessionRefsPreview(sess) + } + } + return a, nil + case refsEnrichedMsg: if len(msg.attempted) == 0 { return a, nil @@ -1131,6 +1197,7 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { attempted := make(map[string]bool, len(msg.attempted)) for _, id := range msg.attempted { attempted[id] = true + delete(a.refsInFlight, id) // resolve pass finished; allow future re-targeting } changed := false for i := range a.sessions { @@ -1155,9 +1222,11 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // If the References preview is open on a session whose refs just resolved, // re-render it live so the "Resolving…" placeholder (or stale ○ badges) // updates to the fetched status without the user having to re-navigate. - if changed && a.state == viewSessions && a.sessSplit.Show && a.sessPreviewMode == sessPreviewRefs { + // This fires even when no refs landed (all links unresolvable) so the + // placeholder flips to "No resolvable…" instead of spinning forever. + if a.state == viewSessions && a.sessSplit.Show && a.sessPreviewMode == sessPreviewRefs { if sess, ok := a.selectedSession(); ok && attempted[sess.ID] { - a.sessRefsCacheKey = "" // force re-render with the new refs + a.sessRefsCacheKey = "" // force re-render with the new refs / resolved flag return a, a.updateSessionRefsPreview(sess) } } @@ -4245,6 +4314,15 @@ 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. + var refsCmd tea.Cmd + if a.sessRefsResolveID != "" { + refsCmd = a.extractSessionRefsCmd(a.sessRefsResolveID, a.sessRefsResolvePath) + a.sessRefsResolveID = "" + a.sessRefsResolvePath = "" + } // Always refresh conversation preview for live sessions (regardless of liveUpdate) if a.state == viewSessions && a.sessSplit.Show && a.sessPreviewMode == sessPreviewConversation { if sess, ok := a.selectedSession(); ok && sess.IsLive { @@ -4266,12 +4344,9 @@ func (a *App) handleTick() tea.Cmd { } if !a.liveUpdate { - return pollCmd - } - if pollCmd != nil { - return tea.Batch(a.doRefresh(), pollCmd) + return tea.Batch(refsCmd, pollCmd) } - return a.doRefresh() + return tea.Batch(refsCmd, pollCmd, a.doRefresh()) } func (a *App) doRefresh() tea.Cmd { @@ -4304,8 +4379,14 @@ func (a *App) doRefresh() tea.Cmd { needsSort = true // New activity may have introduced fresh PR/Jira links, so // allow one more resolve pass (the URL→status cache is still - // TTL-guarded, so this stays cheap when nothing changed). - a.sessions[i].RefsResolved = false + // TTL-guarded, so this stays cheap when nothing changed). Skip + // if a resolve is already in flight — a live session's mtime + // changes every few seconds, and resetting mid-pass made + // enrichRefsCmd re-parse the (large) transcript and re-run gh + // every tick, spiking CPU and stalling navigation. + if !a.refsInFlight[a.sessions[i].ID] { + a.sessions[i].RefsResolved = false + } } } type liveState struct{ live, responding bool } @@ -4479,9 +4560,11 @@ func (a *App) renderSessionSplit() string { a.sessionList.Select(idx) } - // Don't call updateSessionPreview for live mode from render path — the - // returned async cmd would be discarded. Live mode is initialized from - // Update paths (resizeAll, key handlers) where cmds are dispatched. + // Don't call updateSessionPreview for live mode from the render path — the + // returned async cmd would be discarded. Live mode is initialized from Update + // paths (resizeAll, key handlers). Refs mode renders its content here (pure), + // but its resolve cmd is dispatched via the pending-flush in handleTick so it + // is never lost to the render path. if a.sessPreviewMode != sessPreviewLive { _ = a.updateSessionPreview() } @@ -5517,10 +5600,18 @@ func (a *App) updateSessionRefsPreview(sess session.Session) tea.Cmd { var resolveCmd tea.Cmd refs := sess.Refs - if len(refs) == 0 && sess.HasRefs && !sess.RefsResolved { - // Not resolved yet: show the placeholder now and resolve in the - // background so we never block the UI on gh/Jira network calls. - resolveCmd = a.resolveOneSessionRefsCmd(sess.ID, sess.FilePath) + if len(refs) == 0 && sess.HasRefs && !sess.RefsResolved && !a.refsInFlight[sess.ID] { + // Not extracted yet: show the placeholder now and extract offline (no + // network) so URLs/labels appear fast; status resolves in a second step. + // Record the target as pending too — this method is also reached from the + // render path (View), where the returned cmd would be discarded; handleTick + // flushes the pending target so the extract always runs. Guard on + // 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 + a.refsInFlight[sess.ID] = true } // Order refs (open PRs first) and stash them so the focused-pane key handler // can map the cursor to a concrete URL to open. @@ -5528,20 +5619,56 @@ func (a *App) updateSessionRefsPreview(sess session.Session) tea.Cmd { if a.sessRefsCursor >= len(a.sessPreviewRefs) { a.sessRefsCursor = 0 } - cacheKey := fmt.Sprintf("%s:%d:%d:%d:%t", sess.ID, len(refs), previewW, a.sessRefsCursor, a.sessSplit.Focus) + cacheKey := fmt.Sprintf("%s:%d:%d:%d:%t:%t", sess.ID, len(refs), previewW, a.sessRefsCursor, a.sessSplit.Focus, sess.RefsResolved) if a.sessRefsCacheKey != cacheKey { - a.sessRefsCache = a.renderSessionRefs(a.sessPreviewRefs, previewW, sess.HasRefs) + a.sessRefsCache = a.renderSessionRefs(a.sessPreviewRefs, previewW, sess.HasRefs, sess.RefsResolved) a.sessRefsCacheKey = cacheKey } + a.sessRefsResolved = sess.RefsResolved a.sessSplit.Preview = viewport.New(previewW, contentH) a.sessSplit.Preview.SetContent(a.sessRefsCache) return resolveCmd } +// extractSessionRefsCmd reads a session's transcript and extracts its PR/Jira +// refs (offline — no status resolution). Fast enough to run on demand; the +// result comes back as refsExtractedMsg so the preview can render URLs/labels +// immediately, then a status resolve is kicked off separately. +func (a *App) extractSessionRefsCmd(id, filePath string) tea.Cmd { + if id == "" || filePath == "" { + return nil + } + return func() tea.Msg { + refs := session.ExtractSessionRefsFromFile(filePath) + return refsExtractedMsg{id: id, refs: refs} + } +} + +// resolveRefsStatusCmd resolves status (gh + Jira REST, TTL-cached) for an +// already-extracted set of refs. Each ref resolves in its own command so +// results stream back one at a time (refStatusMsg) as they land — a slow gh or +// a timing-out Jira call no longer blocks the whole list. refs are expected in +// display order (most-recent first) so the newest statuses fill in first. +func (a *App) resolveRefsStatusCmd(id string, refs []session.SessionRef) tea.Cmd { + if id == "" || len(refs) == 0 { + return nil + } + cmds := make([]tea.Cmd, 0, len(refs)) + for _, r := range refs { + ref := r + cmds = append(cmds, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + return refStatusMsg{id: id, ref: session.ResolveRef(ctx, ref)} + }) + } + return tea.Batch(cmds...) +} + // resolveOneSessionRefsCmd resolves a single session's PR/Jira refs off the UI -// thread and reports the result via refsEnrichedMsg. Used for on-demand -// resolution when the user opens the References preview for a session that has -// not been enriched in the background yet. +// thread (extract + status in one pass) and reports the result via +// refsEnrichedMsg. Used by the background enrich sweep for sessions that have +// not been touched yet. func (a *App) resolveOneSessionRefsCmd(id, filePath string) tea.Cmd { if id == "" || filePath == "" { return nil @@ -5558,7 +5685,8 @@ func (a *App) resolveOneSessionRefsCmd(id, filePath string) tea.Cmd { } } -// orderRefs returns refs sorted PRs-first, open items leading within each kind. +// orderRefs returns refs sorted PRs-first, then most-recently-seen first within +// each kind so the newest work is at the top of the preview. func orderRefs(refs []session.SessionRef) []session.SessionRef { ordered := make([]session.SessionRef, len(refs)) copy(ordered, refs) @@ -5567,15 +5695,18 @@ func orderRefs(refs []session.SessionRef) []session.SessionRef { if ki != kj { return ki == session.RefPR } - return ordered[i].IsOpen() && !ordered[j].IsOpen() + return ordered[i].FirstSeen.After(ordered[j].FirstSeen) }) return ordered } // renderSessionRefs draws the PR/Jira reference list with status indicators. // The item at sessRefsCursor is highlighted when the preview pane is focused so -// the user can pick a reference and open it in the browser. -func (a *App) renderSessionRefs(ordered []session.SessionRef, width int, hasRefs bool) string { +// the user can pick a reference and open it in the browser. resolved reports +// whether a resolve pass has completed — when true but the list is empty, the +// session's only links were unresolvable (e.g. compare-page URLs), so we say so +// instead of leaving a permanent "Resolving…" spinner. +func (a *App) renderSessionRefs(ordered []session.SessionRef, width int, hasRefs, resolved bool) string { var sb strings.Builder title := "── References ──" if len(ordered) > 0 && a.sessSplit.Focus { @@ -5583,10 +5714,13 @@ func (a *App) renderSessionRefs(ordered []session.SessionRef, width int, hasRefs } sb.WriteString(statTitleStyle.Render(title) + "\n\n") if len(ordered) == 0 { - if hasRefs { - sb.WriteString(dimStyle.Render("Resolving PR/Jira status…")) - } else { + switch { + case !hasRefs: sb.WriteString(dimStyle.Render("No PR or Jira links in this session.")) + case resolved: + sb.WriteString(dimStyle.Render("No resolvable PR/Jira references.")) + default: + sb.WriteString(dimStyle.Render("Resolving PR/Jira status…")) } return sb.String() } @@ -5637,6 +5771,10 @@ func refLine(r session.SessionRef, width int, selected bool) string { if detail != "" { line += " " + dimStyle.Render(detail) } + // Timestamp of first appearance, so the list reads newest-first with context. + if !r.FirstSeen.IsZero() { + line += " " + dimStyle.Render("· "+timeAgo(r.FirstSeen)) + } return line } @@ -7395,6 +7533,22 @@ func roleLabel(e session.Entry) string { return roleChip("assistant") } +// refsExtractedMsg carries the offline-extracted refs (URLs, labels, timestamps +// — no status) for one session, so the preview can render instantly before the +// network-bound status resolve completes. +type refsExtractedMsg struct { + id string + refs []session.SessionRef +} + +// refStatusMsg carries the resolved status of a SINGLE ref (matched by URL) +// within a session, so status streams into the preview one ref at a time as +// each gh/Jira call returns instead of waiting for the whole batch. +type refStatusMsg struct { + id string + ref session.SessionRef +} + // refsEnrichedMsg carries resolved refs for sessions, keyed by session ID. // attempted lists every session ID a resolve pass ran for (even those that // yielded no usable refs) so the handler can mark them resolved and stop @@ -7417,16 +7571,22 @@ func (a *App) enrichRefsCmd() tea.Cmd { var targets []target for i := range a.sessions { s := &a.sessions[i] - // Only target sessions that have links and have never been through a - // resolve pass. RefsResolved stays true afterward even if the pass found - // nothing usable, so a broken/absent link does not re-run gh every tick. - if s.HasRefs && !s.RefsResolved && s.FilePath != "" { + // Only target sessions that have links, have never been through a resolve + // pass, and are not already being resolved. RefsResolved stays true + // afterward even if the pass found nothing usable, so a broken/absent link + // does not re-run gh every tick; refsInFlight prevents re-targeting a + // session whose (slow) resolve is still running, which otherwise spawned + // duplicate gh fan-outs every 3s tick and spiked CPU. + if s.HasRefs && !s.RefsResolved && s.FilePath != "" && !a.refsInFlight[s.ID] { targets = append(targets, target{id: s.ID, filePath: s.FilePath}) } } if len(targets) == 0 { return nil } + for _, t := range targets { + a.refsInFlight[t.id] = true + } return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() diff --git a/internal/tui/refs_preview_test.go b/internal/tui/refs_preview_test.go index 4b61a31..3d0d68d 100644 --- a/internal/tui/refs_preview_test.go +++ b/internal/tui/refs_preview_test.go @@ -10,20 +10,24 @@ import ( func TestRenderSessionRefs(t *testing.T) { a := &App{} // No refs at all. - if got := a.renderSessionRefs(nil, 80, false); !strings.Contains(got, "No PR or Jira") { + if got := a.renderSessionRefs(nil, 80, false, false); !strings.Contains(got, "No PR or Jira") { t.Errorf("empty render missing hint: %q", got) } // Has links but not yet resolved. - if got := a.renderSessionRefs(nil, 80, true); !strings.Contains(got, "Resolving") { + if got := a.renderSessionRefs(nil, 80, true, false); !strings.Contains(got, "Resolving") { t.Errorf("pending render missing hint: %q", got) } + // Has links, resolve completed, but none were resolvable. + if got := a.renderSessionRefs(nil, 80, true, true); !strings.Contains(got, "No resolvable") { + t.Errorf("resolved-empty render missing hint: %q", got) + } refs := []session.SessionRef{ {Kind: session.RefPR, Label: "sendbird/ccx#52", State: session.RefStateOpen, ReviewDecision: "APPROVED", ChecksState: "SUCCESS", Resolved: true}, {Kind: session.RefPR, Label: "sendbird/ccx#40", State: session.RefStateMerged, Resolved: true}, {Kind: session.RefJira, Label: "CPLAT-1234", JiraStatus: "In Progress", Resolved: true}, } - out := a.renderSessionRefs(orderRefs(refs), 80, true) + out := a.renderSessionRefs(orderRefs(refs), 80, true, true) for _, want := range []string{"Pull Requests", "sendbird/ccx#52", "OPEN", "Jira Issues", "CPLAT-1234", "In Progress"} { if !strings.Contains(out, want) { t.Errorf("render missing %q in:\n%s", want, out) @@ -33,10 +37,10 @@ func TestRenderSessionRefs(t *testing.T) { // Cursor highlight only appears when the pane is focused. a.sessSplit.Focus = true a.sessRefsCursor = 0 - if got := a.renderSessionRefs(orderRefs(refs), 80, true); !strings.Contains(got, "> ") { + if got := a.renderSessionRefs(orderRefs(refs), 80, true, true); !strings.Contains(got, "> ") { t.Errorf("focused render missing cursor marker:\n%s", got) } - if !strings.Contains(a.renderSessionRefs(orderRefs(refs), 80, true), "↵:open") { + if !strings.Contains(a.renderSessionRefs(orderRefs(refs), 80, true, true), "↵:open") { t.Error("focused render missing open hint") } }