diff --git a/internal/tui/app.go b/internal/tui/app.go index 607170f..394e3a2 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -240,6 +240,21 @@ type App struct { sessionList list.Model sessionRowCache *sessionRowCache convPreviewRowCache *sessionRowCache + // Collected outputs survive a row change so walking the list up and down + // does not rescan the same transcripts (see outputsStore). + sessOutputsStore *outputsStore + + // Preview payloads loaded off the UI loop, keyed by mode+session+mtime. + // previewLoadInFlight dedups a load that a repeated row visit would + // otherwise dispatch twice before the first one lands. + previewLoads *sessionStore[any] + previewLoadInFlight map[string]bool + + // Per-mode payloads the renderers read, filled by applyPreviewLoad. + sessCtxTree *session.SessionContextTree + sessCtxErr error + sessScratchpadFiles []session.ScratchpadFile + sessShellJobs []session.ShellJob // Split panes sessSplit SplitPane @@ -963,6 +978,8 @@ func NewApp(sessions []session.Session, cfg Config) *App { notifyPrev: make(map[string]session.LifecycleState), sessionRowCache: newSessionRowCache(1024), convPreviewRowCache: newSessionRowCache(4096), + sessOutputsStore: newOutputsStore(outputsStoreCap), + previewLoads: newSessionStore[any](previewStoreCap), termFocused: true, // Default to a true project-centric browser: ccx now opens with one // row per project (folder-like), and sessions of the same repo (and @@ -1258,6 +1275,9 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return a, nil + case previewLoadMsg: + return a, a.handlePreviewLoad(msg) + case previewDebounceMsg: if msg.id != a.previewDebounceID { return a, nil // stale: a newer navigation happened @@ -1516,6 +1536,9 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // different session — the digest state tracks exactly one session, and // adopting a stale result would show another session's outputs. delete(a.outputsInFlight, msg.id) + // Keep the result even when the cursor has moved on: the scan already + // happened, and storing it is what makes moving back free. + a.sessOutputsStore.Set(msg.dataKey, msg.outputs) if a.sessOutputsCacheID != msg.id { return a, nil } @@ -5392,7 +5415,8 @@ func previewDispatchesCmd(mode sessPreview) bool { // 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 + return previewDispatchesCmd(mode) || mode == sessPreviewConversation || + previewLoadsOffLoop(mode) } func (a *App) renderSessionSplit() string { @@ -5671,17 +5695,17 @@ func (a *App) updateSessionPreviewWith(dispatch bool) tea.Cmd { case sessPreviewMemory: a.updateSessionMemoryPreview(sess) case sessPreviewScratchpad: - a.updateSessionScratchpadPreview(sess) + return a.updateSessionScratchpadPreview(sess) case sessPreviewTasksPlan: a.updateSessionTasksPlanPreview(sess) case sessPreviewAgents: - a.updateSessionAgentsPreview(sess) + return a.updateSessionAgentsPreview(sess) case sessPreviewWorkflows: - a.updateSessionWorkflowsPreview(sess) + return a.updateSessionWorkflowsPreview(sess) case sessPreviewShells: - a.updateSessionShellsPreview(sess) + return a.updateSessionShellsPreview(sess) case sessPreviewContexts: - a.updateSessionContextsPreview(sess) + return a.updateSessionContextsPreview(sess) case sessPreviewRefs: return a.updateSessionRefsPreview(sess) case sessPreviewOutputs: @@ -6436,16 +6460,27 @@ func (a *App) updateSessionMemoryPreview(sess session.Session) { a.sessSplit.Preview.SetContent(a.sessMemoryCache) } -func (a *App) updateSessionScratchpadPreview(sess session.Session) { +func (a *App) updateSessionScratchpadPreview(sess session.Session) tea.Cmd { if a.sessScratchpadCacheKey != sess.ID { - a.sessScratchpadCache = a.buildScratchpadContent(sess) - a.sessScratchpadCacheKey = sess.ID + a.sessScratchpadFiles = nil + a.sessScratchpadCache = "" + a.sessScratchpadCacheKey = "" + } + cmd := a.adoptPreviewLoad(sessPreviewScratchpad, sess) + if a.sessScratchpadCacheKey != sess.ID { + if _, loaded := a.previewLoads.Get(previewLoadKey(sessPreviewScratchpad, sess)); loaded { + a.sessScratchpadCache = a.buildScratchpadContent(sess, a.sessScratchpadFiles) + a.sessScratchpadCacheKey = sess.ID + } else { + a.sessScratchpadCache = dimStyle.Render("(loading scratchpad…)") + } } previewW := max(a.width-a.sessSplit.ListWidth(a.width, a.splitRatio)-1, 1) contentH := max(a.height-3, 1) a.sessSplit.Preview = viewport.New(previewW, contentH) a.sessSplit.Preview.SetContent(a.sessScratchpadCache) + return cmd } func (a *App) updateSessionTasksPlanPreview(sess session.Session) { @@ -6460,27 +6495,39 @@ func (a *App) updateSessionTasksPlanPreview(sess session.Session) { a.sessSplit.Preview.SetContent(a.sessTasksCache) } -func (a *App) updateSessionAgentsPreview(sess session.Session) { +func (a *App) updateSessionAgentsPreview(sess session.Session) tea.Cmd { + cmd := a.adoptPreviewLoad(sessPreviewAgents, sess) previewW := max(a.width-a.sessSplit.ListWidth(a.width, a.splitRatio)-1, 1) contentH := max(a.height-3, 1) a.sessSplit.Preview = viewport.New(previewW, contentH) + if _, loaded := a.previewLoads.Get(previewLoadKey(sessPreviewAgents, sess)); !loaded { + a.sessSplit.Preview.SetContent(dimStyle.Render("(loading agents…)")) + return cmd + } a.sessSplit.Preview.SetContent(a.buildAgentsPreviewContent(sess)) + return cmd } -func (a *App) updateSessionWorkflowsPreview(sess session.Session) { - // Load + join workflow runs and their nested agents once per session, so the - // cursor can drill into any agent's transcript. +// updateSessionWorkflowsPreview renders the workflow runs for a session. The +// runs and their nested agents are loaded off the UI loop once per session, so +// the cursor can drill into any agent's transcript without re-reading it. +func (a *App) updateSessionWorkflowsPreview(sess session.Session) tea.Cmd { if a.sessWorkflowsCacheKey != sess.ID { - a.sessWfRuns, _ = session.FindWorkflows(sess.FilePath) - allAgents, _ := session.FindSubagents(sess.FilePath) - a.sessWfAgents = session.JoinWorkflowAgents(a.sessWfRuns, allAgents) + a.sessWfRuns, a.sessWfAgents = nil, nil a.sessWfCursor = 0 - a.sessWorkflowsCacheKey = sess.ID + a.sessWorkflowsCacheKey = "" } + cmd := a.adoptPreviewLoad(sessPreviewWorkflows, sess) previewW := max(a.width-a.sessSplit.ListWidth(a.width, a.splitRatio)-1, 1) contentH := max(a.height-3, 1) a.sessSplit.Preview = viewport.New(previewW, contentH) + if _, loaded := a.previewLoads.Get(previewLoadKey(sessPreviewWorkflows, sess)); !loaded { + a.sessSplit.Preview.SetContent(dimStyle.Render("(loading workflows…)")) + return cmd + } + a.sessWorkflowsCacheKey = sess.ID a.sessSplit.Preview.SetContent(a.buildWorkflowsPreviewContent(sess, previewW)) + return cmd } // buildWorkflowsPreviewContent renders the workflow runs recorded for a session @@ -6491,10 +6538,8 @@ func (a *App) updateSessionWorkflowsPreview(sess session.Session) { // result). Agent rows are indexed to match a.sessWfAgents so the cursor maps to // a real drill-down target. func (a *App) buildWorkflowsPreviewContent(sess session.Session, width int) string { + // Loaded off the UI loop into sessWfRuns; see preview_load.go. runs := a.sessWfRuns - if len(runs) == 0 { - runs, _ = session.FindWorkflows(sess.FilePath) - } if len(runs) == 0 { return dimStyle.Render("No workflow runs found.") } @@ -6654,18 +6699,33 @@ func formatDurationMS(ms int64) string { return fmt.Sprintf("%dh%dm", int(d.Hours()), int(d.Minutes())%60) } -func (a *App) updateSessionShellsPreview(sess session.Session) { +func (a *App) updateSessionShellsPreview(sess session.Session) tea.Cmd { if a.sessShellsCacheKey != sess.ID { - a.sessShellsCache = a.buildShellsPreviewContent(sess) - a.sessShellsCacheKey = sess.ID + a.sessShellJobs = nil + a.sessShellsCache = "" + a.sessShellsCacheKey = "" + } + cmd := a.adoptPreviewLoad(sessPreviewShells, sess) + if a.sessShellsCacheKey != sess.ID { + if _, loaded := a.previewLoads.Get(previewLoadKey(sessPreviewShells, sess)); loaded { + a.sessShellsCache = a.buildShellsPreviewContent(sess) + a.sessShellsCacheKey = sess.ID + } else { + a.sessShellsCache = dimStyle.Render("(loading shells…)") + } } previewW := max(a.width-a.sessSplit.ListWidth(a.width, a.splitRatio)-1, 1) contentH := max(a.height-3, 1) a.sessSplit.Preview = viewport.New(previewW, contentH) a.sessSplit.Preview.SetContent(a.sessShellsCache) + return cmd } -func (a *App) updateSessionContextsPreview(sess session.Session) { +// updateSessionContextsPreview renders the context tree for a session. The +// tree itself is loaded off the UI loop (previewLoadCmd) and cached by +// session+mtime; only the render below is keyed by cursor/width/focus, so +// moving the highlight inside the pane no longer re-reads the transcript. +func (a *App) updateSessionContextsPreview(sess session.Session) tea.Cmd { previewW := max(a.width-a.sessSplit.ListWidth(a.width, a.splitRatio)-1, 1) contentH := max(a.height-3, 1) // Reset the cursor when the selected session changes so we don't carry a @@ -6673,25 +6733,27 @@ func (a *App) updateSessionContextsPreview(sess session.Session) { if a.sessCtxCacheID != sess.ID { a.sessCtxCursor = 0 a.sessCtxCacheID = sess.ID + a.sessCtxTree, a.sessCtxNodes, a.sessCtxErr = nil, nil, nil } - // Cursor/focus are part of the key so highlight moves re-render. - cacheKey := fmt.Sprintf("%s:%d:%d:%d:%t", sess.ID, sess.ModTime.UnixNano(), previewW, a.sessCtxCursor, a.sessSplit.Focus) - if a.sessContextsCacheKey != cacheKey { - tree, err := session.BuildSessionContextTree(a.config.ClaudeDir, sess) - if err != nil { - a.sessContextsCache = dimStyle.Render("Failed to build context tree: " + err.Error()) - a.sessCtxNodes = nil - } else { - a.sessCtxNodes = flattenContextNodes(tree) - if a.sessCtxCursor >= len(a.sessCtxNodes) { - a.sessCtxCursor = max(len(a.sessCtxNodes)-1, 0) - } - a.sessContextsCache = renderSessionContextTreeCursor(tree, previewW, a.sessCtxCursor, a.sessSplit.Focus) + cmd := a.adoptPreviewLoad(sessPreviewContexts, sess) + a.sessSplit.Preview = viewport.New(previewW, contentH) + switch { + case a.sessCtxErr != nil: + a.sessContextsCache = dimStyle.Render("Failed to build context tree: " + a.sessCtxErr.Error()) + a.sessContextsCacheKey = "" + case a.sessCtxTree == nil: + a.sessContextsCache = dimStyle.Render("(loading context tree…)") + a.sessContextsCacheKey = "" + default: + // Cursor/focus/width are part of the key so highlight moves re-render. + cacheKey := fmt.Sprintf("%s:%d:%d:%d:%t", sess.ID, sess.ModTime.UnixNano(), previewW, a.sessCtxCursor, a.sessSplit.Focus) + if a.sessContextsCacheKey != cacheKey { + a.sessContextsCache = renderSessionContextTreeCursor(a.sessCtxTree, previewW, a.sessCtxCursor, a.sessSplit.Focus) + a.sessContextsCacheKey = cacheKey } - a.sessContextsCacheKey = cacheKey } - a.sessSplit.Preview = viewport.New(previewW, contentH) a.sessSplit.Preview.SetContent(a.sessContextsCache) + return cmd } // updateSessionRefsPreview renders the PR/Jira references for a session with @@ -7374,11 +7436,8 @@ func (a *App) buildShellsPreviewContent(sess session.Session) string { } jobs := sess.ShellJobs if len(jobs) == 0 { - entries, err := session.LoadMessages(sess.FilePath) - if err != nil { - return dimStyle.Render("Failed to load session: " + err.Error()) - } - jobs = session.LoadShellJobsFromEntries(entries) + // Parsed off the UI loop into sessShellJobs; see preview_load.go. + jobs = a.sessShellJobs } if len(jobs) == 0 { return dimStyle.Render("No background shells or monitors found for this session.") @@ -7612,15 +7671,15 @@ func (a *App) buildTasksPlanContent(sess session.Session) string { } func (a *App) buildAgentsPreviewContent(sess session.Session) string { - a.sessPreviewAgents = nil if !sess.HasAgents { + a.sessPreviewAgents = nil return dimStyle.Render("No agents found for this session.") } - agents, err := session.FindSubagents(sess.FilePath) - if err != nil || len(agents) == 0 { + // Extracted off the UI loop into sessPreviewAgents; see preview_load.go. + agents := a.sessPreviewAgents + if len(agents) == 0 { return dimStyle.Render("No agents found for this session.") } - a.sessPreviewAgents = agents if a.sessAgentCursor >= len(agents) { a.sessAgentCursor = 0 } @@ -7682,11 +7741,15 @@ func (a *App) buildAgentsPreviewContent(sess session.Session) string { // name + size + mtime, then the (markdown-rendered) body for text files. This // is the same level/surface as the memory preview — reachable via the `x` page // menu key, `tab` cycling, and `preview:scratch`. -func (a *App) buildScratchpadContent(sess session.Session) string { +// buildScratchpadContent renders an already-loaded set of scratchpad files. +// The files are passed in rather than read here so the sessions pane can hand +// over what previewLoadCmd fetched off the UI loop (see preview_load.go) while +// the conversation inspector, reached by an explicit node selection rather than +// by row navigation, can still load them on the spot. +func (a *App) buildScratchpadContent(sess session.Session, files []session.ScratchpadFile) string { if sess.ProjectPath == "" || sess.ID == "" { return dimStyle.Render("(no session context)") } - files := session.LoadScratchpadFiles(sess.ProjectPath, sess.ID) if len(files) == 0 { return dimStyle.Render("No scratchpad files.") } diff --git a/internal/tui/conversation_inspector.go b/internal/tui/conversation_inspector.go index 7d948c1..c0f79f0 100644 --- a/internal/tui/conversation_inspector.go +++ b/internal/tui/conversation_inspector.go @@ -516,7 +516,8 @@ func (a *App) renderInspectorOverview(item convItem, node session.FlowNode) stri case "refs": content = a.buildRefsListText() case "scratchpad": - content = a.buildScratchpadContent(a.conv.sess) + content = a.buildScratchpadContent(a.conv.sess, + session.LoadScratchpadFiles(a.conv.sess.ProjectPath, a.conv.sess.ID)) default: content = a.buildTasksPlanContent(a.conv.sess) } diff --git a/internal/tui/outputs.go b/internal/tui/outputs.go index 74ab5ef..06a9bd2 100644 --- a/internal/tui/outputs.go +++ b/internal/tui/outputs.go @@ -76,6 +76,17 @@ func (a *App) buildOutputsPreview(sess session.Session, dispatch bool) tea.Cmd { // A transcript that grew invalidates the collection; the ref count is not // part of the key because refs are merged in at render time, not collected. dataKey := fmt.Sprintf("%s:%d", sess.ID, sess.ModTime.UnixNano()) + // Adopt an earlier collection for this exact transcript before deciding to + // scan. Switching rows clears the single-session digest state above, so + // without this, moving the cursor off a row and back rescans its transcript + // — 8ms median, 117ms p95 per session, paid once per row per pass while + // walking the list. + if a.sessOutputsCollected != dataKey { + if cached, ok := a.sessOutputsStore.Get(dataKey); ok { + a.sessOutputs = cached + a.sessOutputsCollected = dataKey + } + } if dispatch && a.sessOutputsCollected != dataKey && !a.outputsInFlight[sess.ID] { a.outputsInFlight[sess.ID] = true cmds = append(cmds, collectOutputsCmd(sess, dataKey)) diff --git a/internal/tui/outputs_store.go b/internal/tui/outputs_store.go new file mode 100644 index 0000000..3a91569 --- /dev/null +++ b/internal/tui/outputs_store.go @@ -0,0 +1,95 @@ +package tui + +import "github.com/sendbird/ccx/internal/session" + +// outputsStoreCap bounds how many sessions' digests are kept. Each entry is a +// slice of SessionOutput (names, paths, timestamps — no message bodies), so a +// few hundred is cheap next to the transcript scan it saves. +const outputsStoreCap = 256 + +// previewStoreCap bounds how many sessions' preview loads are kept per mode. +// Entries here can hold parsed structures (a context tree, workflow runs), so +// this is deliberately smaller than the digest cap. +const previewStoreCap = 64 + +// sessionStore caches per-session work across row changes. +// +// Every preview mode keeps its loaded state for exactly one session, so moving +// the cursor dropped the previous row's result and moving back re-read its +// transcript from scratch. Walking a list up and down therefore paid a full +// disk load once per row per pass, which is the "navigation feels laggy" report +// this exists to fix. +// +// Keyed by the caller's dataKey (session ID + mtime, and the mode where one +// store is shared), so a transcript that grew misses and is loaded again. +// Eviction is insertion-order, not LRU: a pass over the list touches each key +// once, so recency carries no information a cheaper policy would miss. +type sessionStore[T any] struct { + items map[string]T + order []string + cap int +} + +func newSessionStore[T any](capacity int) *sessionStore[T] { + if capacity <= 0 { + capacity = outputsStoreCap + } + return &sessionStore[T]{items: make(map[string]T, capacity), cap: capacity} +} + +func (s *sessionStore[T]) Get(key string) (T, bool) { + var zero T + if s == nil || s.items == nil { + return zero, false + } + v, ok := s.items[key] + return v, ok +} + +func (s *sessionStore[T]) Set(key string, v T) { + if s == nil || key == "" { + return + } + if s.items == nil { + s.items = make(map[string]T, s.cap) + } + if _, exists := s.items[key]; exists { + s.items[key] = v + return + } + if s.cap <= 0 { + s.cap = outputsStoreCap + } + if len(s.order) >= s.cap { + oldest := s.order[0] + s.order = s.order[1:] + delete(s.items, oldest) + } + s.order = append(s.order, key) + s.items[key] = v +} + +// outputsStore is the digest cache. It exists as its own type only to keep the +// nil-normalizing Set below, which the generic store cannot express. +type outputsStore struct { + sessionStore[[]session.SessionOutput] +} + +func newOutputsStore(capacity int) *outputsStore { + if capacity <= 0 { + capacity = outputsStoreCap + } + return &outputsStore{sessionStore[[]session.SessionOutput]{ + items: make(map[string][]session.SessionOutput, capacity), cap: capacity, + }} +} + +// Set records a collection. A nil result is stored as an empty (non-nil) slice +// so "collected, produced nothing" stays distinguishable from "never collected" +// — otherwise a session with no outputs would be rescanned on every visit. +func (s *outputsStore) Set(key string, outs []session.SessionOutput) { + if outs == nil { + outs = []session.SessionOutput{} + } + s.sessionStore.Set(key, outs) +} diff --git a/internal/tui/preview_load.go b/internal/tui/preview_load.go new file mode 100644 index 0000000..40f9dc3 --- /dev/null +++ b/internal/tui/preview_load.go @@ -0,0 +1,223 @@ +package tui + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/sendbird/ccx/internal/session" +) + +// Several preview modes used to do their disk work inline: the builder read and +// JSON-parsed a whole transcript, and whichever of Update or View reached it +// first paid the cost on the UI loop. Measured on a 226-session store in the +// daily view, one cursor move cost (p50 / p90 / max): +// +// contexts 49.7ms / 219ms / 722ms +// scratchpad 372µs / 27ms / 3.10s +// shells 323µs / 158ms / 676ms +// agents 275µs / 320µs / 195ms +// workflows 276µs / 345µs / 196ms +// +// while the modes that already loaded in a tea.Cmd (outputs, refs, +// conversation, stats) all sat at ~260µs. Keys queue behind that work, so +// holding j/k in the contexts pane backed the loop up by seconds. +// +// The seam is the same in every one of these modes: a pure session.* load +// followed by a cheap App-side render. This file runs the load in a tea.Cmd, +// caches the payload per session, and leaves the render where it was. +// +// View cannot dispatch a command — one returned from View() is dropped — so a +// mode is only ever driven asynchronously from Update. View reads the cache and +// renders a placeholder on a miss, which is why previewLoadsOffLoop has to be +// consulted on the View path too. + +// previewLoadMsg carries a finished off-loop load back to the UI loop. The +// payload's concrete type is whatever that mode's loader produced; the switch +// in applyPreviewLoad is the only place that asserts it. +type previewLoadMsg struct { + mode sessPreview + sessID string + dataKey string + payload any +} + +// workflowsLoad is the workflows pane's payload: the runs plus the subagents +// joined onto them, which is the pair the renderer needs. +type workflowsLoad struct { + runs []session.WorkflowRun + agents []session.Subagent +} + +// previewLoadsOffLoop reports whether this mode's data is loaded in a tea.Cmd +// rather than inline. Keep it in sync with the switch in previewLoader. +func previewLoadsOffLoop(mode sessPreview) bool { + switch mode { + case sessPreviewContexts, sessPreviewScratchpad, sessPreviewShells, + sessPreviewAgents, sessPreviewWorkflows: + return true + } + return false +} + +// previewLoadKey identifies a cached load. The mtime is part of it so a +// transcript that grew is loaded again rather than served stale. +func previewLoadKey(mode sessPreview, sess session.Session) string { + return fmt.Sprintf("%d:%s:%d", mode, sess.ID, sess.ModTime.UnixNano()) +} + +// previewLoader returns the off-loop half of a preview mode: the disk work, +// closed over plain values so it touches no App state and is safe in a +// goroutine. It returns nil for modes that do not load off-loop. +func (a *App) previewLoader(mode sessPreview, sess session.Session) func() any { + claudeDir := a.config.ClaudeDir + switch mode { + case sessPreviewContexts: + return func() any { + tree, err := session.BuildSessionContextTree(claudeDir, sess) + if err != nil { + return err + } + return tree + } + case sessPreviewScratchpad: + projectPath, id := sess.ProjectPath, sess.ID + return func() any { return session.LoadScratchpadFiles(projectPath, id) } + case sessPreviewShells: + path := sess.FilePath + return func() any { + entries, err := session.LoadMessages(path) + if err != nil { + return []session.ShellJob{} + } + return session.LoadShellJobsFromEntries(entries) + } + case sessPreviewAgents: + path := sess.FilePath + return func() any { + agents, err := session.FindSubagents(path) + if err != nil { + return []session.Subagent{} + } + return agents + } + case sessPreviewWorkflows: + path := sess.FilePath + return func() any { + runs, _ := session.FindWorkflows(path) + all, _ := session.FindSubagents(path) + return workflowsLoad{runs: runs, agents: session.JoinWorkflowAgents(runs, all)} + } + } + return nil +} + +// previewLoadCmd dispatches a mode's load, deduplicating against one already in +// flight for the same key. It returns nil when the payload is already cached or +// a load is pending, so callers can treat nil as "nothing more to do". +func (a *App) previewLoadCmd(mode sessPreview, sess session.Session) tea.Cmd { + key := previewLoadKey(mode, sess) + if _, ok := a.previewLoads.Get(key); ok { + return nil + } + if a.previewLoadInFlight[key] { + return nil + } + load := a.previewLoader(mode, sess) + if load == nil { + return nil + } + if a.previewLoadInFlight == nil { + a.previewLoadInFlight = make(map[string]bool) + } + a.previewLoadInFlight[key] = true + id := sess.ID + return func() tea.Msg { + return previewLoadMsg{mode: mode, sessID: id, dataKey: key, payload: load()} + } +} + +// applyPreviewLoad copies a cached payload into the per-mode App fields the +// renderers read, and reports whether it recognised the payload. The cursor is +// only reset when the payload belongs to a different session than the one the +// pane is currently showing, so a reload of the same session (a grown +// transcript) does not jump the highlight. +func (a *App) applyPreviewLoad(mode sessPreview, sess session.Session, payload any) bool { + switch mode { + case sessPreviewContexts: + switch v := payload.(type) { + case *session.SessionContextTree: + a.sessCtxTree = v + a.sessCtxNodes = flattenContextNodes(v) + if a.sessCtxCursor >= len(a.sessCtxNodes) { + a.sessCtxCursor = max(len(a.sessCtxNodes)-1, 0) + } + a.sessCtxErr = nil + return true + case error: + a.sessCtxTree, a.sessCtxNodes, a.sessCtxErr = nil, nil, v + return true + } + case sessPreviewScratchpad: + if v, ok := payload.([]session.ScratchpadFile); ok { + a.sessScratchpadFiles = v + return true + } + case sessPreviewShells: + if v, ok := payload.([]session.ShellJob); ok { + a.sessShellJobs = v + return true + } + case sessPreviewAgents: + if v, ok := payload.([]session.Subagent); ok { + a.sessPreviewAgents = v + if a.sessAgentCursor >= len(v) { + a.sessAgentCursor = 0 + } + return true + } + case sessPreviewWorkflows: + if v, ok := payload.(workflowsLoad); ok { + a.sessWfRuns, a.sessWfAgents = v.runs, v.agents + if a.sessWfCursor >= len(v.agents) { + a.sessWfCursor = 0 + } + return true + } + } + return false +} + +// adoptPreviewLoad makes a mode's payload current for sess, returning a command +// to load it when it is not cached yet. +// +// Adopting before dispatching is what makes revisiting a row free: the +// per-mode App fields track one session, so a row change clears them, and +// without this the next visit would re-read a transcript already on hand. +func (a *App) adoptPreviewLoad(mode sessPreview, sess session.Session) tea.Cmd { + if payload, ok := a.previewLoads.Get(previewLoadKey(mode, sess)); ok { + a.applyPreviewLoad(mode, sess, payload) + return nil + } + return a.previewLoadCmd(mode, sess) +} + +// handlePreviewLoad records a finished load and applies it when the pane is +// still showing that session and mode. +// +// The payload is stored even when the cursor has moved on: the disk work +// already happened, and keeping it is what makes moving back free. +func (a *App) handlePreviewLoad(msg previewLoadMsg) tea.Cmd { + delete(a.previewLoadInFlight, msg.dataKey) + a.previewLoads.Set(msg.dataKey, msg.payload) + if a.sessPreviewMode != msg.mode { + return nil + } + sess, ok := a.selectedSession() + if !ok || sess.ID != msg.sessID || previewLoadKey(msg.mode, sess) != msg.dataKey { + return nil + } + // Re-run the mode's update so the payload lands in its App fields and the + // placeholder is replaced. The load is cached now, so this does no I/O. + a.sessSplit.CacheKey = "" + return a.updateSessionPreview() +} diff --git a/internal/tui/preview_load_test.go b/internal/tui/preview_load_test.go new file mode 100644 index 0000000..d69c809 --- /dev/null +++ b/internal/tui/preview_load_test.go @@ -0,0 +1,327 @@ +package tui + +import ( + "encoding/json" + tea "github.com/charmbracelet/bubbletea" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/sendbird/ccx/internal/session" +) + +// writeHeavySession lays down a transcript with a subagent, a background shell +// and a scratchpad file, so every off-loop preview mode has something to load. +func writeHeavySession(t *testing.T, claudeDir, project, id string) session.Session { + t.Helper() + projDir := filepath.Join(claudeDir, "projects", project) + if err := os.MkdirAll(projDir, 0o755); err != nil { + t.Fatal(err) + } + line := func(v any) string { + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + return string(b) + "\n" + } + var sb strings.Builder + sb.WriteString(line(map[string]any{ + "type": "user", "sessionId": id, "cwd": "/tmp/" + project, + "message": map[string]any{"role": "user", "content": "hello"}, + })) + sb.WriteString(line(map[string]any{ + "type": "assistant", "sessionId": id, + "message": map[string]any{"role": "assistant", "content": []any{ + map[string]any{"type": "tool_use", "id": "t1", "name": "Task", + "input": map[string]any{"description": "probe", "prompt": "go"}}, + map[string]any{"type": "tool_use", "id": "t2", "name": "Bash", + "input": map[string]any{"command": "sleep 1", "run_in_background": true}}, + }}, + })) + file := filepath.Join(projDir, id+".jsonl") + if err := os.WriteFile(file, []byte(sb.String()), 0o644); err != nil { + t.Fatal(err) + } + return session.Session{ + ID: id, FilePath: file, ProjectPath: projDir, + ModTime: time.Now(), HasAgents: true, HasShellJobs: true, + } +} + +// writeScratchpadFile lays down one scratchpad file for sess under a temporary +// scratchpad base, and returns what LoadScratchpadFiles then finds. +func writeScratchpadFile(t *testing.T, sess session.Session, name, body string) []session.ScratchpadFile { + t.Helper() + base := t.TempDir() + t.Cleanup(session.SetScratchpadBaseOverride(base)) + dir := filepath.Join(base, session.EncodeProjectPath(sess.ProjectPath), sess.ID, "scratchpad") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + files := session.LoadScratchpadFiles(sess.ProjectPath, sess.ID) + if len(files) == 0 { + t.Fatalf("fixture produced no scratchpad files under %s", dir) + } + return files +} + +// offLoopPreviewModes is the set this test covers; it must track +// previewLoadsOffLoop so a new off-loop mode cannot slip in uncovered. +var offLoopPreviewModes = []struct { + name string + mode sessPreview +}{ + {"contexts", sessPreviewContexts}, + {"scratchpad", sessPreviewScratchpad}, + {"shells", sessPreviewShells}, + {"agents", sessPreviewAgents}, + {"workflows", sessPreviewWorkflows}, +} + +func TestOffLoopPreviewModesMatchCoverage(t *testing.T) { + covered := map[sessPreview]bool{} + for _, m := range offLoopPreviewModes { + covered[m.mode] = true + if !previewLoadsOffLoop(m.mode) { + t.Errorf("%s is covered here but previewLoadsOffLoop says it is inline", m.name) + } + } + // Walk the whole preview-mode range so a mode added to previewLoadsOffLoop + // without a case in previewLoader (or coverage here) fails loudly. + for mode := sessPreview(0); mode < numSessPreviewModes; mode++ { + if previewLoadsOffLoop(mode) && !covered[mode] { + t.Errorf("preview mode %d loads off-loop but has no coverage here", mode) + } + } +} + +// TestOffLoopPreviewDoesNotTouchDiskInView is the regression guard. Before this +// change the heavy preview builders ran inline, so whichever of Update or View +// reached them first read and JSON-parsed a transcript on the UI loop — +// measured at 49.7ms median and 722ms worst case for the contexts pane, which +// is what made holding j/k feel frozen. +// +// Asserting a wall-clock number would go flaky under load, so this asserts the +// structural property instead: with the load not yet delivered, View must leave +// the mode's payload empty (it renders a placeholder), and the command the +// update returned must be what fills it. +func TestOffLoopPreviewDoesNotTouchDiskInView(t *testing.T) { + claudeDir := t.TempDir() + sess := writeHeavySession(t, claudeDir, "proj-a", "sess-a") + + for _, tc := range offLoopPreviewModes { + t.Run(tc.name, func(t *testing.T) { + app := newTestApp([]session.Session{sess}) + app.config.ClaudeDir = claudeDir + app.sessSplit.Show = true + app.sessPreviewMode = tc.mode + app.sessSplit.CacheKey = "" + + cmd := app.updateSessionPreviewWith(true) + if cmd == nil { + t.Fatalf("%s: no load command dispatched; the build would be inline", tc.name) + } + key := previewLoadKey(tc.mode, sess) + if !app.previewLoadInFlight[key] { + t.Fatalf("%s: load not marked in flight, so a repeat visit would dispatch again", tc.name) + } + if _, ok := app.previewLoads.Get(key); ok { + t.Fatalf("%s: payload cached before the command ran — the load happened inline", tc.name) + } + + // View must not complete the load behind the command's back. + _ = app.View() + if _, ok := app.previewLoads.Get(key); ok { + t.Fatalf("%s: View() populated the cache, so it did the disk work on the UI loop", tc.name) + } + + msg, ok := cmd().(previewLoadMsg) + if !ok { + t.Fatalf("%s: command returned %T, want previewLoadMsg", tc.name, cmd()) + } + if msg.dataKey != key || msg.mode != tc.mode { + t.Fatalf("%s: msg targets %q/%d, want %q/%d", tc.name, msg.dataKey, msg.mode, key, tc.mode) + } + if !app.applyPreviewLoad(tc.mode, sess, msg.payload) { + t.Fatalf("%s: applyPreviewLoad rejected payload %T", tc.name, msg.payload) + } + }) + } +} + +// TestOffLoopPreviewRevisitReusesLoad covers the other half: the per-mode App +// fields track one session, so a row change clears them. Without adopting the +// cached payload first, moving the cursor off a row and back would re-read a +// transcript already on hand. +func TestOffLoopPreviewRevisitReusesLoad(t *testing.T) { + claudeDir := t.TempDir() + a := writeHeavySession(t, claudeDir, "proj-a", "sess-a") + b := writeHeavySession(t, claudeDir, "proj-b", "sess-b") + + for _, tc := range offLoopPreviewModes { + t.Run(tc.name, func(t *testing.T) { + app := newTestApp([]session.Session{a, b}) + app.config.ClaudeDir = claudeDir + app.sessSplit.Show = true + app.sessPreviewMode = tc.mode + + app.sessGroupMode = groupFlat + app.rebuildSessionList() + + // Count the loads that actually ran, draining every command the + // way the runtime does — handlePreviewLoad re-renders and can + // return a command of its own, and a dropped one would leave + // previewLoadInFlight set and hide a missing cache. + loads := 0 + seen := map[string]bool{} + var drain func(cmd tea.Cmd, depth int) + drain = func(cmd tea.Cmd, depth int) { + if cmd == nil || depth > 8 { + return + } + msg := cmd() + lm, ok := msg.(previewLoadMsg) + if !ok { + return + } + loads++ + drain(app.handlePreviewLoad(lm), depth+1) + } + visit := func(idx int) { + app.sessionList.Select(idx) + app.sessSplit.CacheKey = "" + sel, ok := app.selectedSession() + if !ok { + t.Fatalf("%s: row %d is not a session row", tc.name, idx) + } + seen[sel.ID] = true + drain(app.updateSessionPreviewWith(true), 0) + } + // a → b → a → b: two sessions, so two loads and no more. + visit(0) + visit(1) + visit(0) + visit(1) + if len(seen) != 2 { + t.Fatalf("%s: the four visits only reached %d distinct session(s); the test would pass without caching", tc.name, len(seen)) + } + if loads != 2 { + t.Fatalf("%s: ran %d loads for 2 sessions over 4 visits; revisits are re-reading disk", tc.name, loads) + } + }) + } +} + +// TestOffLoopPreviewRendersAfterLoad makes sure the async path actually ends in +// content: a measurement showing "no work on the loop" would look identical if +// the pane were stuck on its placeholder forever. +func TestOffLoopPreviewRendersAfterLoad(t *testing.T) { + claudeDir := t.TempDir() + sess := writeHeavySession(t, claudeDir, "proj-a", "sess-a") + if err := os.MkdirAll(filepath.Join(sess.ProjectPath, "scratchpad"), 0o755); err != nil { + t.Fatal(err) + } + + for _, tc := range offLoopPreviewModes { + t.Run(tc.name, func(t *testing.T) { + app := newTestApp([]session.Session{sess}) + app.config.ClaudeDir = claudeDir + app.sessSplit.Show = true + app.sessPreviewMode = tc.mode + app.sessSplit.CacheKey = "" + + // Drain the load, then the re-render it triggers. + for range 4 { + cmd := app.updateSessionPreviewWith(true) + if cmd == nil { + break + } + if msg, ok := cmd().(previewLoadMsg); ok { + app.handlePreviewLoad(msg) + } + app.sessSplit.CacheKey = "" + } + got := app.sessSplit.Preview.View() + if strings.Contains(got, "loading") { + t.Fatalf("%s: pane still on its loading placeholder after the load landed:\n%s", tc.name, got) + } + if strings.TrimSpace(stripANSI(got)) == "" { + t.Fatalf("%s: pane is empty after the load landed", tc.name) + } + }) + } +} + +// TestInspectorScratchpadLoadsItsOwnFiles pins the other caller of +// buildScratchpadContent. The sessions pane hands it files fetched off the UI +// loop; the conversation inspector is reached by selecting a node, not by row +// navigation, so it loads them itself. Making the builder read the pane's +// cached slice instead of taking a parameter silently emptied this view. +func TestInspectorScratchpadLoadsItsOwnFiles(t *testing.T) { + claudeDir := t.TempDir() + sess := writeHeavySession(t, claudeDir, "proj-a", "sess-a") + files := writeScratchpadFile(t, sess, "notes.md", "probe-marker\n") + + app := newTestApp([]session.Session{sess}) + app.config.ClaudeDir = claudeDir + // The pane's cache is deliberately empty: the inspector must not depend on it. + app.sessScratchpadFiles = nil + + got := stripANSI(app.buildScratchpadContent(sess, files)) + if strings.Contains(got, "No scratchpad files") { + t.Fatalf("inspector rendered the empty state for a session that has files:\n%s", got) + } +} + +// TestOutputsDigestSurvivesRowChange guards the digest cache. The App tracks one +// session's outputs, so a row change clears it and the collection handler used +// to drop a result that landed after the cursor moved — making a walk up and +// down the list rescan each transcript, at 8ms median and 117ms p95 apiece. +func TestOutputsDigestSurvivesRowChange(t *testing.T) { + claudeDir := t.TempDir() + a := writeHeavySession(t, claudeDir, "proj-a", "sess-a") + b := writeHeavySession(t, claudeDir, "proj-b", "sess-b") + + app := newTestApp([]session.Session{a, b}) + app.config.ClaudeDir = claudeDir + app.sessSplit.Show = true + app.sessPreviewMode = sessPreviewOutputs + app.sessGroupMode = groupFlat + app.rebuildSessionList() + + collected := map[string]int{} + visit := func(idx int) { + app.sessionList.Select(idx) + app.sessSplit.CacheKey = "" + cmd := app.updateSessionPreviewWith(true) + if cmd == nil { + return + } + msg, ok := cmd().(outputsCollectedMsg) + if !ok { + return + } + collected[msg.id]++ + m, _ := app.Update(msg) + app = m.(*App) + } + // a → b → a → b: each transcript must be scanned exactly once. + visit(0) + visit(1) + visit(0) + visit(1) + for id, n := range collected { + if n != 1 { + t.Fatalf("collected %s %d times over two visits; the digest is not surviving a row change", id, n) + } + } + if len(collected) != 2 { + t.Fatalf("collected %d sessions, want 2", len(collected)) + } +}