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
51 changes: 12 additions & 39 deletions internal/tui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -3485,7 +3484,7 @@ func (a *App) refreshRespondingState() {
changed = true
}
}
if changed && !a.isFiltering() && !a.hasFilterApplied() {
if changed && !a.isFiltering() {
a.rebuildSessionList()
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
}

Expand Down
25 changes: 24 additions & 1 deletion internal/tui/conversation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 == "" {
Expand Down
28 changes: 28 additions & 0 deletions internal/tui/conversation_ux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
175 changes: 175 additions & 0 deletions internal/tui/refresh_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading