diff --git a/README.md b/README.md index 1db5506..5b57d8e 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ Browse all Claude Code sessions across projects, sorted by recency. - **Fork** — agent-fork grouping - **Daily** (`d`) — day → project → session tree (newest day first), previewing what each level produced - **Project filter** — `/proj:` scopes the list to one project -- **Preview pane** (`Tab` to cycle): conversation, stats, memory, tasks/plan, workflows, outputs, references, live +- **Preview pane** (`Tab` to cycle): outputs (the default — PRs, Jira issues, plans, scratchpad files), conversation, stats, memory, tasks/plan, workflows, references, live - **Fleet notifications** — when a live session transitions into an attention state (→ `WAIT`/`DONE`/`STUCK`), a `(!)N` indicator appears in the status bar; press `n` to jump to the most recently notified session - **Multi-select** (`Space`) — bulk delete, copy paths, send input - **Actions menu** (`x`) — delete, move, resume, copy path, worktree, kill, input, jump, URLs, files diff --git a/internal/tui/app.go b/internal/tui/app.go index a5a2423..607170f 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -974,11 +974,15 @@ func NewApp(sessions []session.Session, cfg Config) *App { // persisted preference or -group daily): the browser's default, not the // zero value, which would drop the user into flat. preDailyGroupMode: groupProjectCentric, - // Each view remembers its own preview. The daily view exists to show - // results, so it opens on the outputs digest rather than a wall of - // conversation text; the project browser keeps the conversation preview. + // Each view remembers its own preview, and both open on the outputs + // digest: the first question about a row is what it produced — PRs, + // Jira issues, plans, scratchpad files — not the wall of conversation + // text that answers how. Scratchpad in particular was only reachable + // from a date row before this, because that row ignores the preview + // mode while project and session rows follow it. Tab (or the page menu) + // switches to the conversation when the how is what you want. dailyPreviewMode: sessPreviewOutputs, - browserPreviewMode: sessPreviewConversation, + browserPreviewMode: sessPreviewOutputs, } // Restore persisted view state (CLI flags override in the apply block below) @@ -1055,10 +1059,15 @@ func NewApp(sessions []session.Session, cfg Config) *App { } } } - // Starting in the daily view with no explicit -preview: open on its own - // default (outputs) rather than the browser's conversation preview. - if a.sessGroupMode == groupDaily && a.config.PreviewMode == "" { - a.sessPreviewMode = a.dailyPreviewMode + // With no explicit -preview, open on the starting view's own default rather + // than sessPreviewMode's zero value, which is the conversation preview and + // has nothing to do with what either view wants to show first. + if a.config.PreviewMode == "" { + if a.sessGroupMode == groupDaily { + a.sessPreviewMode = a.dailyPreviewMode + } else { + a.sessPreviewMode = a.browserPreviewMode + } } if a.config.ViewMode != "" { modeMap := map[string]viewState{ @@ -1086,6 +1095,12 @@ func (a *App) Init() tea.Cmd { claudeDir := a.config.ClaudeDir cmds = append(cmds, func() tea.Msg { sessions, err := session.ScanSessions(claudeDir) + // Warm the window-scan memo here as well. autoSelectSession, which + // runs the moment this scan lands, calls CurrentWindowClaudes to + // decide where to put the cursor — and the first call walks every + // tmux pane for ~223ms. Memoized it is 2µs, so doing it on this + // goroutine turns a visible startup stall into nothing. + tmux.CurrentWindowClaudes() // Mark live state here too: MarkLiveSessions shells out to tmux and // costs ~112ms, which is a visible stall if left on the UI loop. if err == nil && len(sessions) > 0 { diff --git a/internal/tui/refresh_test.go b/internal/tui/refresh_test.go index 92b4769..d211df2 100644 --- a/internal/tui/refresh_test.go +++ b/internal/tui/refresh_test.go @@ -6,9 +6,11 @@ import ( "os" "path/filepath" "testing" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/sendbird/ccx/internal/session" + "github.com/sendbird/ccx/internal/tmux" ) func newConfiguredTestApp(sessions []session.Session, cfg Config) *App { @@ -406,3 +408,97 @@ func TestDoRefreshDedupsConcurrentScans(t *testing.T) { t.Error("a second scan was dispatched while one was already in flight") } } + +// TestScannedMsgDoesNotWalkTmuxOnTheUILoop guards the startup stall: ccx felt +// sluggish for the first moment after launch. +// +// The initial scan's result handler calls autoSelectSession to place the +// cursor, which asks tmux which panes in this window are running Claude. That +// walk costs ~223ms the first time and 2µs once memoized — so the whole cost +// landed on the UI loop at exactly the moment the list first appeared. Init's +// scan command now warms the memo on its own goroutine. +// +// Asserting on the memo rather than a wall-clock number keeps this honest on a +// loaded machine: what matters is that the expensive call has already happened +// off the loop by the time the handler runs. +func TestScannedMsgDoesNotWalkTmuxOnTheUILoop(t *testing.T) { + if !tmux.InTmux() { + t.Skip("not inside tmux; the window scan is a no-op") + } + // Cold: the walk has not run yet. + tmux.InvalidateWindowClaudes() + + home := t.TempDir() + claudeDir := filepath.Join(home, ".claude") + if err := os.MkdirAll(claudeDir, 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("CLAUDE_CONFIG_DIR", filepath.Join(home, "config")) + writeTestSessionFile(t, claudeDir, filepath.Join(home, "proj-a"), "sess-a") + sessions, err := session.ScanSessions(claudeDir) + if err != nil { + t.Fatal(err) + } + + app := newConfiguredTestApp(nil, Config{ClaudeDir: claudeDir, TmuxEnabled: true}) + + // Run what Init dispatches, expanding the batch the way the runtime does: + // each command is its own goroutine there, so every one has to actually be + // called for this to stand in for startup. + app.sessionsLoading = true + if msg := app.Init()(); msg != nil { + if batch, ok := msg.(tea.BatchMsg); ok { + for _, c := range batch { + if c != nil { + _ = c() + } + } + } + } + + warmed := time.Now() + tmux.CurrentWindowClaudes() + if d := time.Since(warmed); d > 20*time.Millisecond { + t.Errorf("window scan still cold after the startup command ran (%s) — "+ + "autoSelectSession will pay for it on the UI loop", d.Round(time.Millisecond)) + } + + // And the handler itself stays cheap. + start := time.Now() + m, _ := app.Update(sessionsScannedMsg{sessions: sessions}) + app = m.(*App) + if d := time.Since(start); d > 100*time.Millisecond { + t.Errorf("sessionsScannedMsg blocked the loop for %s", d.Round(time.Millisecond)) + } +} + +// TestBrowserOpensOnTheOutputsDigest: the first question about a row is what it +// produced, so every view now opens on the outputs digest. It also makes +// scratchpad reachable from project and session rows — a date row ignores the +// preview mode and always shows Produced, which is why scratchpad appeared +// there and nowhere else. +func TestBrowserOpensOnTheOutputsDigest(t *testing.T) { + app := NewApp([]session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: time.Now()}, + }, Config{}) + if got := app.sessPreviewMode; got != sessPreviewOutputs { + t.Errorf("startup preview mode = %v, want the outputs digest (%v)", got, sessPreviewOutputs) + } + if got := app.browserPreviewMode; got != sessPreviewOutputs { + t.Errorf("browser preview mode = %v, want the outputs digest", got) + } + if got := app.dailyPreviewMode; got != sessPreviewOutputs { + t.Errorf("daily preview mode = %v, want the outputs digest", got) + } +} + +// TestExplicitPreviewFlagStillWins: the new default must not override what the +// user asked for on the command line. +func TestExplicitPreviewFlagStillWins(t *testing.T) { + app := NewApp([]session.Session{ + {ID: "a1", ShortID: "a1", ProjectPath: "/tmp/repo-a", ProjectName: "repo-a", ModTime: time.Now()}, + }, Config{PreviewMode: "conv"}) + if got := app.sessPreviewMode; got != sessPreviewConversation { + t.Errorf("-preview conv gave %v, want the conversation preview", got) + } +}