From 2fa3141e797753838b1c4e3d352fcf0d3da26677 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Thu, 23 Apr 2026 14:51:04 +0900 Subject: [PATCH 1/2] fix: rebuild filtered session list during refresh --- internal/tui/app.go | 51 +++------- internal/tui/refresh_test.go | 175 +++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+), 39 deletions(-) create mode 100644 internal/tui/refresh_test.go diff --git a/internal/tui/app.go b/internal/tui/app.go index 39bb74d..ea9b9ec 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -265,10 +265,10 @@ type App struct { convPageVP viewport.Model convPageChangeMap map[string]extract.ChangeItem // Browser search filter - convPageSearching bool - convPageSearchTI textinput.Model + convPageSearching bool + convPageSearchTI textinput.Model convPageSearchTerm string - convPageAllItems []convPageItem // unfiltered items (set when filter is active) + convPageAllItems []convPageItem // unfiltered items (set when filter is active) // Conversation artifact browser actions menu convPageActionsMenu bool @@ -1664,7 +1664,6 @@ func (a *App) handleSessionKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a, a.schedulePreviewUpdate() } - // Default list update oldIdx := a.sessionList.Index() m, cmd := a.updateSessionList(msg) @@ -3485,7 +3484,7 @@ func (a *App) refreshRespondingState() { changed = true } } - if changed && !a.isFiltering() && !a.hasFilterApplied() { + if changed && !a.isFiltering() { a.rebuildSessionList() } } @@ -3519,26 +3518,11 @@ func (a *App) doRefresh() tea.Cmd { // Preserve live state detection tmux.MarkLiveSessions(fresh) - // Remember cursor position - selectedID := "" - if sess, ok := a.selectedSession(); ok { - selectedID = sess.ID - } - a.sessions = a.injectRemoteSessions(fresh) a.globalStatsCache = nil // invalidate cached stats - if !a.isFiltering() && !a.hasFilterApplied() { - items := buildGroupedItems(a.sessions, a.sessGroupMode) - newIdx := 0 - for i, item := range items { - if si, ok := item.(sessionItem); ok && si.sess.ID == selectedID { - newIdx = i - break - } - } - a.sessionList.SetItems(items) - a.sessionList.Select(newIdx) + if !a.isFiltering() { + a.rebuildSessionList() } } else { // Fallback: lightweight stat-only refresh @@ -3570,24 +3554,13 @@ func (a *App) doRefresh() tea.Cmd { needsRefresh = true } } - if (needsSort || needsRefresh) && !a.isFiltering() && !a.hasFilterApplied() { - selectedID := "" - if sess, ok := a.selectedSession(); ok { - selectedID = sess.ID - } - sort.Slice(a.sessions, func(i, j int) bool { - return a.sessions[i].ModTime.After(a.sessions[j].ModTime) - }) - items := buildGroupedItems(a.sessions, a.sessGroupMode) - newIdx := 0 - for i, item := range items { - if si, ok := item.(sessionItem); ok && si.sess.ID == selectedID { - newIdx = i - break - } + if (needsSort || needsRefresh) && !a.isFiltering() { + if needsSort { + sort.Slice(a.sessions, func(i, j int) bool { + return a.sessions[i].ModTime.After(a.sessions[j].ModTime) + }) } - a.sessionList.SetItems(items) - a.sessionList.Select(newIdx) + a.rebuildSessionList() } } diff --git a/internal/tui/refresh_test.go b/internal/tui/refresh_test.go new file mode 100644 index 0000000..7df7a5a --- /dev/null +++ b/internal/tui/refresh_test.go @@ -0,0 +1,175 @@ +package tui + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/sendbird/ccx/internal/session" +) + +func newConfiguredTestApp(sessions []session.Session, cfg Config) *App { + app := NewApp(sessions, cfg) + m, _ := app.Update(tea.WindowSizeMsg{Width: 160, Height: 50}) + a := m.(*App) + a.state = viewSessions + a.sessPreviewMode = sessPreviewConversation + return a +} + +func writeTestSessionFile(t *testing.T, claudeDir, projectPath, sessionID string) string { + t.Helper() + + if err := os.MkdirAll(projectPath, 0o755); err != nil { + t.Fatalf("mkdir project path: %v", err) + } + + projectDir := filepath.Join(claudeDir, "projects", session.EncodeProjectPath(projectPath)) + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("mkdir project dir: %v", err) + } + + filePath := filepath.Join(projectDir, sessionID+".jsonl") + content := fmt.Sprintf("{\"isMeta\":true,\"cwd\":%q,\"gitBranch\":\"main\"}\n{\"role\":\"user\",\"content\":\"hello\"}\n", projectPath) + if err := os.WriteFile(filePath, []byte(content), 0o644); err != nil { + t.Fatalf("write session file: %v", err) + } + return filePath +} + +func writeLiveDetectionStubs(t *testing.T, binDir string, livePaths ...string) { + t.Helper() + + pgrepScript := "#!/bin/sh\nexit 1\n" + lsofScript := "#!/bin/sh\nexit 1\n" + if len(livePaths) > 0 { + pgrepScript = "#!/bin/sh\necho 123\n" + lsofScript = "#!/bin/sh\ncat <<'EOF'\n" + for _, path := range livePaths { + lsofScript += "n" + path + "\n" + } + lsofScript += "EOF\n" + } + + for name, content := range map[string]string{ + "pgrep": pgrepScript, + "lsof": lsofScript, + } { + path := filepath.Join(binDir, name) + if err := os.WriteFile(path, []byte(content), 0o755); err != nil { + t.Fatalf("write %s stub: %v", name, err) + } + } +} + +func TestDoRefreshRebuildsFilteredSessionItemsWhenLiveStateChanges(t *testing.T) { + home := t.TempDir() + claudeDir := filepath.Join(home, ".claude") + binDir := filepath.Join(home, "bin") + projectPath := filepath.Join(home, "proj-b") + + if err := os.MkdirAll(claudeDir, 0o755); err != nil { + t.Fatalf("mkdir claude dir: %v", err) + } + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin dir: %v", err) + } + + writeLiveDetectionStubs(t, binDir) + t.Setenv("PATH", binDir+":"+os.Getenv("PATH")) + t.Setenv("TMUX", "") + + writeTestSessionFile(t, claudeDir, projectPath, "sess-b") + sessions, err := session.ScanSessions(claudeDir) + if err != nil { + t.Fatalf("scan sessions: %v", err) + } + if len(sessions) != 1 { + t.Fatalf("expected 1 session, got %d", len(sessions)) + } + + app := newConfiguredTestApp(sessions, Config{ClaudeDir: claudeDir, TmuxEnabled: true}) + applyListFilter(&app.sessionList, sessions[0].ProjectName) + + selected, ok := app.selectedSession() + if !ok { + t.Fatal("expected selected session before refresh") + } + if selected.IsLive { + t.Fatal("expected initial selected session to be non-live") + } + + writeLiveDetectionStubs(t, binDir, projectPath) + app.doRefresh() + + selected, ok = app.selectedSession() + if !ok { + t.Fatal("expected selected session after refresh") + } + if !selected.IsLive { + t.Fatal("expected filtered selected session to reflect refreshed live state") + } +} + +func TestRefreshRespondingStateRebuildsFilteredSessionItems(t *testing.T) { + home := t.TempDir() + claudeDir := filepath.Join(home, ".claude") + binDir := filepath.Join(home, "bin") + projectPath := filepath.Join(home, "proj-b") + + if err := os.MkdirAll(claudeDir, 0o755); err != nil { + t.Fatalf("mkdir claude dir: %v", err) + } + if err := os.MkdirAll(binDir, 0o755); err != nil { + t.Fatalf("mkdir bin dir: %v", err) + } + + writeLiveDetectionStubs(t, binDir) + t.Setenv("PATH", binDir+":"+os.Getenv("PATH")) + t.Setenv("TMUX", "") + + filePath := writeTestSessionFile(t, claudeDir, projectPath, "sess-b") + stale := time.Now().Add(-time.Minute) + if err := os.Chtimes(filePath, stale, stale); err != nil { + t.Fatalf("set stale modtime: %v", err) + } + + app := newConfiguredTestApp([]session.Session{{ + ID: "sess-b", + ShortID: "sess-b", + FilePath: filePath, + ProjectPath: projectPath, + ProjectName: "proj-b", + ModTime: stale, + MsgCount: 1, + IsLive: true, + IsResponding: false, + }}, Config{ClaudeDir: claudeDir, TmuxEnabled: true}) + applyListFilter(&app.sessionList, "proj-b") + + selected, ok := app.selectedSession() + if !ok { + t.Fatal("expected selected session before responding refresh") + } + if selected.IsResponding { + t.Fatal("expected initial selected session to be idle") + } + + now := time.Now() + if err := os.Chtimes(filePath, now, now); err != nil { + t.Fatalf("touch session file: %v", err) + } + + app.refreshRespondingState() + + selected, ok = app.selectedSession() + if !ok { + t.Fatal("expected selected session after responding refresh") + } + if !selected.IsResponding { + t.Fatal("expected filtered selected session to reflect refreshed responding state") + } +} From 07f4eb91f2b5830db1ca96d60ab754bce7c21360 Mon Sep 17 00:00:00 2001 From: Gavin Jeong Date: Thu, 23 Apr 2026 15:03:51 +0900 Subject: [PATCH 2/2] fix: hide tool-only turns in compact preview --- internal/tui/conversation.go | 25 ++++++++++++++++++++++++- internal/tui/conversation_ux_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/internal/tui/conversation.go b/internal/tui/conversation.go index c3a524c..002d0f4 100644 --- a/internal/tui/conversation.go +++ b/internal/tui/conversation.go @@ -898,7 +898,7 @@ func buildCompactEntry(entry session.Entry, sourceEntries []session.Entry) sessi blocks := make([]session.ContentBlock, 0, len(sourceEntries)*2) first := true for _, raw := range sourceEntries { - text := previewMessageText(raw) + text := compactPreviewMessageText(raw) if strings.TrimSpace(text) == "" { continue } @@ -1580,6 +1580,29 @@ func summarizeToolResult(b session.ContentBlock) session.ContentBlock { return b } +func compactPreviewMessageText(e session.Entry) string { + role := strings.ToUpper(e.Role) + if role == "" { + role = "ENTRY" + } + header := role + if !e.Timestamp.IsZero() { + header += " " + e.Timestamp.Format("15:04:05") + } + + text := entryFullText(e) + if text == "" { + return "" + } + + const maxPreviewLines = 6 + lines := strings.Split(text, "\n") + if len(lines) > maxPreviewLines { + text = strings.Join(lines[:maxPreviewLines], "\n") + "\n..." + } + return header + "\n" + text +} + func previewMessageText(e session.Entry) string { role := strings.ToUpper(e.Role) if role == "" { diff --git a/internal/tui/conversation_ux_test.go b/internal/tui/conversation_ux_test.go index 70fdd4f..9ecd128 100644 --- a/internal/tui/conversation_ux_test.go +++ b/internal/tui/conversation_ux_test.go @@ -878,6 +878,34 @@ func TestCompactPreviewArrowKeysMoveBlockSelection(t *testing.T) { } } +func TestBuildCompactEntrySkipsToolOnlyTurns(t *testing.T) { + base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) + sourceEntries := []session.Entry{ + makeTextEntry("user", base, "Investigate the failure"), + { + Role: "assistant", + Timestamp: base.Add(time.Second), + Content: []session.ContentBlock{ + {Type: "tool_use", ToolName: "Read", ToolInput: `{"file_path":"main.go"}`}, + {Type: "tool_result", Text: "package main"}, + }, + }, + makeTextEntry("assistant", base.Add(2*time.Second), "Found the issue in main.go"), + } + + entry := buildCompactEntry(session.Entry{Role: "assistant"}, sourceEntries) + if got := len(entry.Content); got != 2 { + t.Fatalf("compact entry block count = %d, want 2", got) + } + full := entryFullText(entry) + if strings.Contains(full, "READ") || strings.Contains(full, "package main") { + t.Fatalf("compact preview should skip tool-only turns, got %q", full) + } + if !strings.Contains(full, "Investigate the failure") || !strings.Contains(full, "Found the issue in main.go") { + t.Fatalf("compact preview should keep text turns, got %q", full) + } +} + func TestModeSwitchPreservesNearestSelection(t *testing.T) { base := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC) entries := []session.Entry{