From 71db971a64a6c9e3e7948d3eb9f4e902c945bea0 Mon Sep 17 00:00:00 2001 From: keyolk Date: Sun, 30 Aug 2026 21:23:13 +0900 Subject: [PATCH] fix: navigation freeze from preview reads blocking the UI loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the cursor in the session browser froze intermittently: most moves were fine, then one landed on a large session and the UI locked up. Three preview reads ran synchronously inside Update, so the event loop was blocked for the whole file read: updateSessionConvPreview -> LoadMessagesSummary 465ms (180 MB) updateSessionStatsPreview -> ScanSessionStats 790ms refreshSessionPreviewLive -> LoadMessagesSummary every tick Measured over 120 cursor moves on a real corpus with the conversation preview open: p50 9ms, p99 191ms, max 466ms, 6/120 moves over 100ms — which is exactly the "sometimes" in the report. The existing 30ms navigation debounce does not help. It delays the stall until the cursor stops rather than removing it. Dispatch all three as commands instead, following the refs/outputs pattern already in the file: show "(loading…)", read off the loop, and apply the result from a completion message. Each keeps a per-session in-flight latch so it cannot dispatch twice, and discards results whose session is no longer selected — the cursor moves on while a read is in flight, and a late result must not overwrite the pane the user is looking at now. previewDispatchesFromView is deliberately a separate predicate from previewDispatchesCmd rather than an extension of it. View() cannot dispatch a command, so it must not drive these modes; but project rows use previewDispatchesCmd to decide whether to fall through to the project summary, and adding conversation there would reroute them to a representative session instead. Callers that discarded the return value had to be updated or the load would silently never run: handleTick (live sessions), mouse scroll, and the resize path in View — the last re-renders existing entries rather than dispatching, since View cannot dispatch and the pane would stick on "(loading…)". UI-loop blocking on the real corpus is now 0ms for both conversation and stats, with the preview still rendering on 120/120 moves. --- internal/tui/app.go | 282 ++++++++++++++++++++---- internal/tui/conv_preview_async_test.go | 253 +++++++++++++++++++++ internal/tui/mouse.go | 10 +- 3 files changed, 494 insertions(+), 51 deletions(-) create mode 100644 internal/tui/conv_preview_async_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index 1cbd6f3..8e0ac54 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -317,6 +317,9 @@ type App struct { sessOutputsCollected string // dataKey of the collection in sessOutputs ("" = not collected yet) sessOutputsCursor int // cursor within the Outputs digest list outputsInFlight map[string]bool // session IDs with a collection currently running + convPreviewInFlight map[string]bool // session IDs with a preview transcript read currently running + statsPreviewInFlight map[string]bool // session IDs with a stats scan currently running + convLiveInFlight map[string]bool // live session IDs with a tick-driven transcript re-read running dayOutputRows []dayOutputRow // outputs shown in the daily view's day pane, in cursor order dayOutputsCursor int // cursor within the day pane's output list dayOutputsCacheID string // day key the cursor currently tracks @@ -329,6 +332,7 @@ type App struct { // Conversation preview state sessConvEntries []mergedMsg // merged conversation messages sessConvCursor int // current message cursor + sessConvCursorToEnd bool // place the cursor at the last entry once the pending async load lands sessConvCacheID string // session ID for which convEntries are loaded sessConvExpanded map[int]bool // which messages are expanded sessConvSearching bool // typing in preview search @@ -1181,6 +1185,18 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, a.runDebouncedPreview() + case convPreviewLoadedMsg: + a.applyConvPreviewLoad(msg) + return a, nil + + case statsPreviewLoadedMsg: + a.applyStatsPreviewLoad(msg) + return a, nil + + case convLiveReloadedMsg: + a.applyConvLiveReload(msg) + return a, nil + case tickMsg: cmd := a.handleTick() return a, tea.Batch(cmd, tickCmd()) @@ -2903,7 +2919,16 @@ func (a *App) rebuildTasksPreviewContent() { // sessPreviewBoundaryCross moves to the next/prev session when the preview // cursor hits the boundary, and reloads the preview for the new session. -func (a *App) sessPreviewBoundaryCross(dir string) { +// +// Returns the load command: the conversation read is async, so a caller must +// dispatch it. "up" cannot position the cursor against the new session's +// entries here — they arrive with the load — so it defers via +// sessConvCursorToEnd, which applyConvPreviewLoad honors. +// +// Currently unreferenced; kept because the preview-boundary behavior it +// implements is still reachable design-wise and removing it is out of scope +// for the freeze fix. +func (a *App) sessPreviewBoundaryCross(dir string) tea.Cmd { idx := a.sessionList.Index() n := len(a.sessionList.Items()) switch dir { @@ -2911,22 +2936,21 @@ func (a *App) sessPreviewBoundaryCross(dir string) { if idx < n-1 { a.sessionList.Select(idx + 1) a.sessSplit.CacheKey = "" - a.updateSessionPreview() + a.sessConvCursorToEnd = false // Position cursor at first item in new preview a.sessConvCursor = 0 + return a.updateSessionPreview() } case "up": if idx > 0 { a.sessionList.Select(idx - 1) a.sessSplit.CacheKey = "" - a.updateSessionPreview() - // Position cursor at last item in new preview - visible := a.convVisibleEntries() - if len(visible) > 0 { - a.sessConvCursor = len(visible) - 1 - } + // Position cursor at last item once the new preview has loaded. + a.sessConvCursorToEnd = true + return a.updateSessionPreview() } } + return nil } // handleConvPreviewKeys handles keys for the conversation preview navigation. @@ -4931,10 +4955,13 @@ func (a *App) refreshRespondingState() { func (a *App) handleTick() tea.Cmd { // Always refresh conversation preview for live sessions (regardless of liveUpdate) + var convCmd tea.Cmd if a.state == viewSessions && a.sessSplit.Show && a.sessPreviewMode == sessPreviewConversation { if sess, ok := a.selectedSession(); ok && sess.IsLive { - a.sessSplit.CacheKey = "" // invalidate to force re-fetch - _ = a.updateSessionPreview() // conversation mode returns nil cmd + a.sessSplit.CacheKey = "" // invalidate to force re-fetch + // The read is async now, so its command has to be propagated — + // discarding it would silently stop live previews from updating. + convCmd = a.updateSessionPreview() } } // Always re-check IsResponding for live sessions (cheap os.Stat check). @@ -4956,9 +4983,9 @@ func (a *App) handleTick() tea.Cmd { refsCmd := a.resolveVisibleRefsCmd() if !a.liveUpdate { - return tea.Batch(pollCmd, refsCmd) + return tea.Batch(convCmd, pollCmd, refsCmd) } - return tea.Batch(pollCmd, refsCmd, a.doRefresh()) + return tea.Batch(convCmd, pollCmd, refsCmd, a.doRefresh()) } // invalidateOpenPreviewCaches clears the cache keys of whichever session @@ -5107,8 +5134,9 @@ func (a *App) doRefresh() tea.Cmd { // Detect notable lifecycle transitions across the fleet and queue them. a.collectNotifications() - // Refresh preview for live sessions (auto-scroll to bottom) - a.refreshSessionPreviewLive() + // Refresh preview for live sessions (auto-scroll to bottom). The reads are + // async now, so the command has to reach the runtime. + livePreviewCmd := a.refreshSessionPreviewLive() // Prune stale selectedSet entries if a.hasMultiSelection() { @@ -5128,7 +5156,7 @@ func (a *App) doRefresh() tea.Cmd { // every HasRefs session's refs via `gh pr view` (~1.6s each) — hundreds of // subprocesses that spiked CPU and froze the UI for minutes on large // session dirs, while resolving statuses the user never looked at. - return nil + return livePreviewCmd } return nil @@ -5238,6 +5266,17 @@ func previewDispatchesCmd(mode sessPreview) bool { return mode == sessPreviewLive || mode == sessPreviewRefs || mode == sessPreviewOutputs } +// previewDispatchesFromView reports whether View() must skip driving this +// preview mode because its update returns a command View cannot dispatch. +// +// This is a superset of previewDispatchesCmd: conversation also loads +// asynchronously now, but unlike refs/outputs a project row must still fall +// through to the project-summary preview rather than being re-routed to a +// representative session — so the two predicates are deliberately not merged. +func previewDispatchesFromView(mode sessPreview) bool { + return previewDispatchesCmd(mode) || mode == sessPreviewConversation +} + func (a *App) renderSessionSplit() string { if a.sessionList.Width() == 0 { return "" @@ -5263,7 +5302,7 @@ func (a *App) renderSessionSplit() string { // dispatched from Update paths (setSessPreviewMode, the navigation debounce, // resizeAll). View only re-renders their already-populated content (the // resize block below). - if !previewDispatchesCmd(a.sessPreviewMode) { + if !previewDispatchesFromView(a.sessPreviewMode) { _ = a.updateSessionPreview() } @@ -5297,6 +5336,12 @@ func (a *App) renderSessionSplit() string { } else { a.sessSplit.CacheKey = "" // let the next Update redraw the day pane } + } else if a.sessPreviewMode == sessPreviewConversation && len(a.sessConvEntries) > 0 { + // Resize must not re-dispatch the async transcript read: View cannot + // dispatch the returned command, so the pane would stick on + // "(loading…)". The entries are already in memory — just re-wrap them + // at the new width. + a.refreshConvPreview() } else if a.sessPreviewMode != sessPreviewLive && !isRemoteSetup { a.sessSplit.CacheKey = "" _ = a.updateSessionPreview() @@ -5486,7 +5531,7 @@ func (a *App) updateSessionPreview() tea.Cmd { switch previewMode { case sessPreviewStats: - a.updateSessionStatsPreview(sess) + return a.updateSessionStatsPreview(sess) case sessPreviewMemory: a.updateSessionMemoryPreview(sess) case sessPreviewScratchpad: @@ -5525,7 +5570,7 @@ func (a *App) updateSessionPreview() tea.Cmd { } a.sessSplit.Preview.SetContent(content) default: - a.updateSessionConvPreview(sess) + return a.updateSessionConvPreview(sess) } return nil } @@ -5565,10 +5610,63 @@ func (a *App) prependConvHeaders(sess session.Session, content string, previewW return content } -func (a *App) updateSessionConvPreview(sess session.Session) { - const previewHead, previewTail = 50, 50 - head, tail, total, err := session.LoadMessagesSummary(sess.FilePath, previewHead, previewTail) - if err != nil || total == 0 { +// convPreviewLoadedMsg carries the result of an off-loop transcript read for +// the session-browser conversation preview. +type convPreviewLoadedMsg struct { + sessID string + head []session.Entry + tail []session.Entry + total int + err error +} + +// updateSessionConvPreview dispatches the transcript read for the conversation +// preview off the UI loop. +// +// The read used to run inline here. LoadMessagesSummary keeps a ring buffer for +// the tail, so it scans the whole file even though only the first 50 messages +// are needed — 465ms on a 180 MB transcript, measured. Since it ran inside +// Update, the whole event loop stalled for that long and navigation froze +// whenever the cursor landed on a large session. The 30ms navigation debounce +// does not help: it only delays the stall until the cursor stops. +func (a *App) updateSessionConvPreview(sess session.Session) tea.Cmd { + if a.sessConvCacheID != sess.ID { + a.sessConvEntries = nil + a.sessConvFiltered = nil + a.sessConvFilterTerm = "" + } + if a.convPreviewInFlight[sess.ID] { + return nil // already loading; its completion will render + } + + a.sessSplit.Preview.SetContent(dimStyle.Render("(loading…)")) + + if a.convPreviewInFlight == nil { + a.convPreviewInFlight = make(map[string]bool) + } + a.convPreviewInFlight[sess.ID] = true + + sessID, path := sess.ID, sess.FilePath + return func() tea.Msg { + const previewHead, previewTail = 50, 50 + head, tail, total, err := session.LoadMessagesSummary(path, previewHead, previewTail) + return convPreviewLoadedMsg{sessID: sessID, head: head, tail: tail, total: total, err: err} + } +} + +// applyConvPreviewLoad renders a completed transcript read. Merging and +// rendering are cheap relative to the read, so they stay on the UI loop. +func (a *App) applyConvPreviewLoad(msg convPreviewLoadedMsg) { + delete(a.convPreviewInFlight, msg.sessID) + + // The cursor may have moved on while the read was in flight; a stale result + // must not overwrite the pane the user is looking at now. + sess, ok := a.selectedSession() + if !ok || sess.ID != msg.sessID { + return + } + + if msg.err != nil || msg.total == 0 { a.sessSplit.Preview.SetContent(dimStyle.Render("(no messages)")) a.sessConvEntries = nil a.sessConvFiltered = nil @@ -5577,14 +5675,14 @@ func (a *App) updateSessionConvPreview(sess session.Session) { } // Merge head and tail separately, join with gap indicator - headMerged := mergeConversationTurns(head) + headMerged := mergeConversationTurns(msg.head) var merged []mergedMsg - if len(tail) == 0 { + if len(msg.tail) == 0 { merged = headMerged } else { - tailMerged := mergeConversationTurns(tail) + tailMerged := mergeConversationTurns(msg.tail) // Adjust tail startIdx/endIdx to reflect position in full file - tailOffset := total - len(tail) + tailOffset := msg.total - len(msg.tail) for i := range tailMerged { tailMerged[i].startIdx += tailOffset tailMerged[i].endIdx += tailOffset @@ -5610,9 +5708,15 @@ func (a *App) updateSessionConvPreview(sess session.Session) { a.sessConvSearching = false visible := a.sessConvEntries - if sess.IsLive { + switch { + case a.sessConvCursorToEnd: + // Deferred from sessPreviewBoundaryCross("up"): the user crossed the top + // boundary, so land on the last entry of the session they moved into. a.sessConvCursor = len(visible) - 1 - } else { + a.sessConvCursorToEnd = false + case sess.IsLive: + a.sessConvCursor = len(visible) - 1 + default: a.sessConvCursor = 0 } @@ -6007,21 +6111,63 @@ func (a *App) jumpToSessionEntry(sessID, uuid string) (tea.Model, tea.Cmd) { return a, cmd } -func (a *App) updateSessionStatsPreview(sess session.Session) { - // Use cached stats if available for this session - if a.sessStatsCacheKey != sess.ID || a.sessStatsCache == nil { - stats, err := session.ScanSessionStats(sess.FilePath) - if err != nil { - a.sessSplit.Preview.SetContent(dimStyle.Render("(stats error)")) - return - } - a.sessStatsCache = &stats - a.sessStatsCacheKey = sess.ID +// statsPreviewLoadedMsg carries an off-loop stats scan result. +type statsPreviewLoadedMsg struct { + sessID string + stats session.SessionStats + err error +} + +// updateSessionStatsPreview dispatches the stats scan off the UI loop for the +// same reason as the conversation preview: ScanSessionStats walks the whole +// transcript (790ms on a 180 MB session, measured), and running it inside +// Update froze navigation. +func (a *App) updateSessionStatsPreview(sess session.Session) tea.Cmd { + // Cached stats render immediately — no dispatch, no flicker. + if a.sessStatsCacheKey == sess.ID && a.sessStatsCache != nil { + a.renderSessionStatsPreview(*a.sessStatsCache) + return nil + } + if a.statsPreviewInFlight[sess.ID] { + return nil + } + + a.sessSplit.Preview.SetContent(dimStyle.Render("(loading…)")) + + if a.statsPreviewInFlight == nil { + a.statsPreviewInFlight = make(map[string]bool) + } + a.statsPreviewInFlight[sess.ID] = true + + sessID, path := sess.ID, sess.FilePath + return func() tea.Msg { + stats, err := session.ScanSessionStats(path) + return statsPreviewLoadedMsg{sessID: sessID, stats: stats, err: err} + } +} + +// applyStatsPreviewLoad renders a completed stats scan, ignoring results whose +// session is no longer selected. +func (a *App) applyStatsPreviewLoad(msg statsPreviewLoadedMsg) { + delete(a.statsPreviewInFlight, msg.sessID) + + sess, ok := a.selectedSession() + if !ok || sess.ID != msg.sessID { + return } + if msg.err != nil { + a.sessSplit.Preview.SetContent(dimStyle.Render("(stats error)")) + return + } + a.sessStatsCache = &msg.stats + a.sessStatsCacheKey = msg.sessID + a.renderSessionStatsPreview(msg.stats) +} +func (a *App) renderSessionStatsPreview(stats session.SessionStats) { previewW := max(a.width-a.sessSplit.ListWidth(a.width, a.splitRatio)-1, 1) contentH := max(a.height-3, 1) - content := renderSessionStats(*a.sessStatsCache, previewW) + content := renderSessionStats(stats, previewW) a.sessSplit.Preview = viewport.New(previewW, contentH) a.sessSplit.Preview.SetContent(content) } @@ -7577,14 +7723,16 @@ func (a *App) sessPreviewAtBottom() bool { // refreshSessionPreviewLive reloads and re-renders the session preview for a live session. // Auto-scrolls to bottom unless the user has pinned (scrolled up). -func (a *App) refreshSessionPreviewLive() { +func (a *App) refreshSessionPreviewLive() tea.Cmd { if !a.sessSplit.Show { - return + return nil } sess, ok := a.selectedSession() if !ok || !sess.IsLive { - return + return nil } + // Collects async preview work (stats) so the caller can dispatch it. + var previewCmd tea.Cmd if a.sessPreviewMode != sessPreviewConversation { // Re-render non-message preview for live session. Only modes whose @@ -7597,7 +7745,7 @@ func (a *App) refreshSessionPreviewLive() { a.sessSplit.CacheKey = "" a.sessStatsCache = nil a.sessStatsCacheKey = "" - a.updateSessionStatsPreview(sess) + previewCmd = a.updateSessionStatsPreview(sess) case sessPreviewTasksPlan: a.sessSplit.CacheKey = "" a.sessTasksCacheKey = "" @@ -7617,15 +7765,53 @@ func (a *App) refreshSessionPreviewLive() { } // sessPreviewLive, sessPreviewRemote, sessPreviewAgents, sessPreviewShells, // sessPreviewContexts: leave as-is. - return + return previewCmd + } + + // Reload entries (head+tail) and refresh the conversation preview off the UI + // loop. This runs on every tick while a live session is selected, so an + // inline read here stalls the loop exactly like the navigation path did. + if a.convLiveInFlight[sess.ID] { + return previewCmd + } + if a.convLiveInFlight == nil { + a.convLiveInFlight = make(map[string]bool) } + a.convLiveInFlight[sess.ID] = true + liveID, livePath := sess.ID, sess.FilePath + return tea.Batch(previewCmd, func() tea.Msg { + const liveHead, liveTail = 50, 50 + head, tail, total, err := session.LoadMessagesSummary(livePath, liveHead, liveTail) + return convLiveReloadedMsg{sessID: liveID, head: head, tail: tail, total: total, err: err} + }) +} + +// convLiveReloadedMsg carries an off-loop re-read of a live session's transcript. +type convLiveReloadedMsg struct { + sessID string + head []session.Entry + tail []session.Entry + total int + err error +} + +// applyConvLiveReload merges a live session's re-read into the open preview, +// preserving the incremental behavior the tick path relies on (keep the cursor +// pinned unless new messages arrived, expand only the new entries). +func (a *App) applyConvLiveReload(msg convLiveReloadedMsg) { + delete(a.convLiveInFlight, msg.sessID) - // Reload entries (head+tail) and refresh conversation preview for live session - const liveHead, liveTail = 50, 50 - head, tail, total, err := session.LoadMessagesSummary(sess.FilePath, liveHead, liveTail) - if err != nil || total == 0 { + sess, ok := a.selectedSession() + if !ok || sess.ID != msg.sessID || !sess.IsLive { + return + } + if a.sessPreviewMode != sessPreviewConversation || !a.sessSplit.Show { + return + } + if msg.err != nil || msg.total == 0 { return } + head, tail, total := msg.head, msg.tail, msg.total headMerged := mergeConversationTurns(head) var newConv []mergedMsg if len(tail) == 0 { diff --git a/internal/tui/conv_preview_async_test.go b/internal/tui/conv_preview_async_test.go new file mode 100644 index 0000000..3b43f15 --- /dev/null +++ b/internal/tui/conv_preview_async_test.go @@ -0,0 +1,253 @@ +package tui + +import ( + "os" + "path/filepath" + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/sendbird/ccx/internal/session" +) + +// writeNavTranscript writes a transcript with n user/assistant messages. +func writeNavTranscript(t *testing.T, dir, name string, n int) session.Session { + t.Helper() + path := filepath.Join(dir, name) + var b strings.Builder + for i := 0; i < n; i++ { + b.WriteString(`{"type":"user","uuid":"u`) + b.WriteString(strings.Repeat("x", 3)) + b.WriteString(`","message":{"role":"user","content":[{"type":"text","text":"message body here"}]}}` + "\n") + } + if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } + fi, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + return session.Session{ + ID: name, ShortID: name, + ProjectPath: dir, ProjectName: "proj", + FilePath: path, ModTime: fi.ModTime(), MsgCount: n, + } +} + +func navTestApp(t *testing.T, sessions []session.Session) *App { + t.Helper() + a := newTestApp(sessions) + a.sessSplit.Show = true + a.sessPreviewMode = sessPreviewConversation + a.rebuildSessionList() + if len(a.sessionList.VisibleItems()) == 0 { + t.Fatal("session list is empty") + } + return a +} + +// The transcript read must not run inside Update. It used to, and a large +// session blocked the event loop for ~465ms, freezing navigation. The read is +// only allowed to happen inside the returned command. +func TestConvPreviewReadHappensOffTheUpdateLoop(t *testing.T) { + dir := t.TempDir() + sessions := []session.Session{ + writeNavTranscript(t, dir, "a.jsonl", 20), + writeNavTranscript(t, dir, "b.jsonl", 20), + } + a := navTestApp(t, sessions) + + // Force a fresh load for the selected session. + a.sessSplit.CacheKey = "" + a.sessConvCacheID = "" + a.sessConvEntries = nil + + cmd := a.updateSessionPreview() + if cmd == nil { + t.Fatal("conversation preview returned no command — the read would be inline") + } + // Nothing may be parsed yet: Update returned without touching the file. + if len(a.sessConvEntries) != 0 { + t.Errorf("entries populated during Update (%d) — read did not move off the loop", len(a.sessConvEntries)) + } + + msg := cmd() + loaded, ok := msg.(convPreviewLoadedMsg) + if !ok { + t.Fatalf("command returned %T, want convPreviewLoadedMsg", msg) + } + if loaded.total == 0 { + t.Fatal("loader read no messages") + } + + a.applyConvPreviewLoad(loaded) + if len(a.sessConvEntries) == 0 { + t.Error("applying the load produced no entries") + } +} + +// A result that arrives after the cursor moved on must not overwrite the pane +// the user is now looking at. +func TestStaleConvPreviewLoadIsDiscarded(t *testing.T) { + dir := t.TempDir() + sessions := []session.Session{ + writeNavTranscript(t, dir, "a.jsonl", 20), + writeNavTranscript(t, dir, "b.jsonl", 20), + } + a := navTestApp(t, sessions) + + sel, ok := a.selectedSession() + if !ok { + t.Fatal("no session selected") + } + + a.applyConvPreviewLoad(convPreviewLoadedMsg{ + sessID: "some-other-session-id", + total: 5, + head: []session.Entry{{Role: "user", Content: []session.ContentBlock{{Type: "text", Text: "stale"}}}}, + }) + + if a.sessConvCacheID == "some-other-session-id" { + t.Error("a stale load overwrote the current preview") + } + if a.sessConvCacheID != "" && a.sessConvCacheID != sel.ID { + t.Errorf("cache id = %q, want empty or %q", a.sessConvCacheID, sel.ID) + } +} + +// Two loads for the same session must not be dispatched concurrently. +func TestConvPreviewLoadIsNotDispatchedTwice(t *testing.T) { + dir := t.TempDir() + sessions := []session.Session{writeNavTranscript(t, dir, "a.jsonl", 20)} + a := navTestApp(t, sessions) + + sel, _ := a.selectedSession() + a.sessSplit.CacheKey = "" + a.sessConvCacheID = "" + if cmd := a.updateSessionPreview(); cmd == nil { + t.Fatal("first dispatch returned no command") + } + if !a.convPreviewInFlight[sel.ID] { + t.Fatal("in-flight latch not set") + } + + a.sessSplit.CacheKey = "" // force the guard to be re-evaluated + if cmd := a.updateSessionPreview(); cmd != nil { + t.Error("second dispatch while one is in flight returned a command") + } +} + +// View() cannot dispatch commands, so any preview whose update returns one must +// be excluded from the View-driven path — otherwise the pane sticks on +// "(loading…)" forever. +func TestConversationIsExcludedFromViewDrivenPreview(t *testing.T) { + if !previewDispatchesFromView(sessPreviewConversation) { + t.Error("conversation preview loads async but View would still drive it") + } + // The narrower predicate must stay narrow: project rows rely on it to fall + // through to the project summary rather than a representative session. + if previewDispatchesCmd(sessPreviewConversation) { + t.Error("previewDispatchesCmd must not include conversation — it reroutes project rows") + } + for _, m := range []sessPreview{sessPreviewLive, sessPreviewRefs, sessPreviewOutputs} { + if !previewDispatchesFromView(m) { + t.Errorf("mode %d dispatches a cmd but is not excluded from View", m) + } + } +} + +// Navigating with the preview open must never block the loop, whatever the +// transcript size. +func TestNavigationDoesNotBlockOnLargeTranscript(t *testing.T) { + dir := t.TempDir() + sessions := []session.Session{ + writeNavTranscript(t, dir, "small.jsonl", 10), + writeNavTranscript(t, dir, "big.jsonl", 20000), + } + a := navTestApp(t, sessions) + + down := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}} + for i := 0; i < 4; i++ { + m, _ := a.Update(down) + a = m.(*App) + m2, cmd := a.Update(previewDebounceMsg{id: a.previewDebounceID}) + a = m2.(*App) + // Whatever the debounce produced, Update itself must not have parsed the + // transcript; that is the loader's job, off the loop. + if cmd != nil { + if msg, ok := cmd().(convPreviewLoadedMsg); ok { + m3, _ := a.Update(msg) + a = m3.(*App) + } + } + } +} + +// The stats preview walks the whole transcript (790ms on a 180 MB session, +// measured), so it must dispatch rather than scan inside Update. +func TestStatsPreviewScanHappensOffTheUpdateLoop(t *testing.T) { + dir := t.TempDir() + sessions := []session.Session{writeNavTranscript(t, dir, "a.jsonl", 50)} + a := navTestApp(t, sessions) + a.sessPreviewMode = sessPreviewStats + a.sessSplit.CacheKey = "" + a.sessStatsCache = nil + a.sessStatsCacheKey = "" + + cmd := a.updateSessionPreview() + if cmd == nil { + t.Fatal("stats preview returned no command — the scan would be inline") + } + if a.sessStatsCache != nil { + t.Error("stats populated during Update — scan did not move off the loop") + } + + msg, ok := cmd().(statsPreviewLoadedMsg) + if !ok { + t.Fatalf("command returned %T, want statsPreviewLoadedMsg", msg) + } + a.applyStatsPreviewLoad(msg) + if a.sessStatsCache == nil { + t.Error("applying the scan produced no stats") + } + + // A cached session must render immediately, with no second dispatch. + a.sessSplit.CacheKey = "" + if cmd := a.updateSessionPreview(); cmd != nil { + t.Error("cached stats dispatched a redundant scan") + } +} + +func TestStaleStatsLoadIsDiscarded(t *testing.T) { + dir := t.TempDir() + sessions := []session.Session{writeNavTranscript(t, dir, "a.jsonl", 10)} + a := navTestApp(t, sessions) + + a.applyStatsPreviewLoad(statsPreviewLoadedMsg{sessID: "not-the-selected-one"}) + if a.sessStatsCacheKey == "not-the-selected-one" { + t.Error("a stale stats result overwrote the current preview") + } +} + +// The live-session tick re-reads the transcript on every tick; that read must +// not run inside Update either. +func TestLiveConvReloadHappensOffTheUpdateLoop(t *testing.T) { + dir := t.TempDir() + sess := writeNavTranscript(t, dir, "live.jsonl", 30) + sess.IsLive = true + a := navTestApp(t, []session.Session{sess}) + a.sessPreviewMode = sessPreviewConversation + + cmd := a.refreshSessionPreviewLive() + if cmd == nil { + t.Fatal("live refresh returned no command — the re-read would be inline") + } + sel, _ := a.selectedSession() + if !a.convLiveInFlight[sel.ID] { + t.Error("live in-flight latch not set") + } + // A second refresh while one is in flight must not queue another read. + if cmd2 := a.refreshSessionPreviewLive(); cmd2 != nil { + t.Error("live refresh dispatched while one was already in flight") + } +} diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index 009afb2..694ad05 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -133,6 +133,10 @@ func (a *App) handleMouseScroll(msg tea.MouseMsg) (tea.Model, tea.Cmd) { sp := a.activeSplitPane() scrolledPreview := sp != nil && sp.Show && (sp.PreviewOnly || msg.X > sp.ListWidth(a.width, a.splitRatio)) + // previewCmd carries async preview work (the conversation transcript read) + // out to the runtime; dropping it would leave the pane on "(loading…)". + var previewCmd tea.Cmd + switch a.state { case viewSessions: // Live preview: no local scroll, use J/enter to jump to pane for scrollback @@ -143,7 +147,7 @@ func (a *App) handleMouseScroll(msg tea.MouseMsg) (tea.Model, tea.Cmd) { if scrolledPreview { a.sessPreviewPinned = !a.sessPreviewAtBottom() } else { - a.updateSessionPreview() + previewCmd = a.updateSessionPreview() } case viewConversation: @@ -201,10 +205,10 @@ func (a *App) handleMouseScroll(msg tea.MouseMsg) (tea.Model, tea.Cmd) { // Re-render fold preview after scroll moved the block cursor if scrolledPreview && sp != nil && sp.Folds != nil && sp.Focus { - return a, a.refreshActivePreview() + return a, tea.Batch(previewCmd, a.refreshActivePreview()) } - return a, nil + return a, previewCmd } func (a *App) handleMouseClick(msg tea.MouseMsg) (tea.Model, tea.Cmd) {