Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>` 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
Expand Down
31 changes: 23 additions & 8 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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 {
Expand Down
96 changes: 96 additions & 0 deletions internal/tui/refresh_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Loading