diff --git a/CLAUDE.md b/CLAUDE.md index 65d9038..f805908 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,8 +7,9 @@ Session viewer for Claude Code. Browse, inspect, search, and export conversation ```bash ccx web # Start web UI at localhost:8080 ccx projects # List all projects -ccx sessions # List sessions (interactive picker) -ccx view # View session (interactive picker) +ccx sessions # List sessions for this workspace +ccx sessions --all # List sessions across all projects +ccx view # View workspace session (interactive picker) ccx export --format=html # Export to HTML ``` diff --git a/README.md b/README.md index 78969a7..01316d7 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,9 @@ cd ccx && make build ```bash ccx web # Start web UI (recommended) ccx projects # List all projects -ccx sessions # List recent sessions -ccx sessions --provider=cx # Codex sessions only +ccx sessions # List recent sessions for this workspace +ccx sessions --all # List recent sessions across all projects +ccx sessions --provider=cx # Codex sessions in this workspace ccx sessions --after=2026-03-01 # Date filtered ccx view [session] # View in terminal ccx export -f html --brief # Export conversation-only HTML diff --git a/internal/catalog/session_query.go b/internal/catalog/session_query.go new file mode 100644 index 0000000..417604e --- /dev/null +++ b/internal/catalog/session_query.go @@ -0,0 +1,216 @@ +package catalog + +import ( + "fmt" + "sort" + "strings" + + "github.com/thevibeworks/ccx/internal/config" + "github.com/thevibeworks/ccx/internal/parser" +) + +type SessionScope string + +const ( + ScopeAll SessionScope = "all" + ScopeWorkspace SessionScope = "workspace" + ScopeProject SessionScope = "project" +) + +type SessionSort string + +const ( + SortTime SessionSort = "time" + SortMessages SessionSort = "messages" +) + +type SessionQuery struct { + Scope SessionScope + WorkspacePath string + ProjectName string + Filter config.SessionFilter + Sort SessionSort + Limit int +} + +func (q SessionQuery) WithoutLimit() SessionQuery { + q.Limit = 0 + return q +} + +func (q SessionQuery) WithoutProviderFilter() SessionQuery { + q.Filter.Provider = "" + return q +} + +func ApplySessionQuery(projects []*parser.Project, q SessionQuery) []*parser.Session { + var sessions []*parser.Session + for _, project := range projects { + if project == nil || !projectMatchesScope(project, q) { + continue + } + for _, session := range project.Sessions { + if session == nil { + continue + } + if q.Scope == ScopeWorkspace && !sessionMatchesWorkspace(project, session, q.WorkspacePath) { + continue + } + if session.ProjectName == "" { + session.ProjectName = project.Name + } + if !q.Filter.IsEmpty() && !q.Filter.Match(session) { + continue + } + sessions = append(sessions, session) + } + } + SortSessions(sessions, q.Sort) + if q.Limit > 0 && len(sessions) > q.Limit { + sessions = sessions[:q.Limit] + } + return sessions +} + +func SortSessions(sessions []*parser.Session, sortBy SessionSort) { + switch sortBy { + case SortMessages: + sort.SliceStable(sessions, func(i, j int) bool { + return sessions[i].Stats.MessageCount > sessions[j].Stats.MessageCount + }) + default: + sort.SliceStable(sessions, func(i, j int) bool { + return sessions[i].EndTime.After(sessions[j].EndTime) + }) + } +} + +func ValidateSessionSort(sortBy SessionSort) error { + switch sortBy { + case "", SortTime, SortMessages: + return nil + default: + return fmt.Errorf("invalid sort %q (want time or messages)", sortBy) + } +} + +func projectMatchesScope(project *parser.Project, q SessionQuery) bool { + switch q.Scope { + case ScopeProject: + return ProjectMatchesName(project, q.ProjectName) + case ScopeWorkspace: + return ProjectMatchesWorkspace(project, q.WorkspacePath) + default: + return true + } +} + +func sessionMatchesWorkspace(project *parser.Project, session *parser.Session, workspacePath string) bool { + canonical := parser.CanonicalizeWorkspacePath(workspacePath) + if canonical == "" || session == nil { + return false + } + if strings.TrimSpace(session.CWD) != "" { + return parser.CanonicalizeWorkspacePath(session.CWD) == canonical + } + return ProjectMatchesWorkspace(project, workspacePath) +} + +func ProjectMatchesName(project *parser.Project, name string) bool { + if project == nil { + return false + } + query := strings.ToLower(strings.TrimSpace(name)) + if query == "" { + return false + } + nameLower := strings.ToLower(project.Name) + encodedLower := strings.ToLower(project.EncodedName) + pathLower := strings.ToLower(project.Path) + if nameLower == query || encodedLower == query || pathLower == query { + return true + } + canonicalQuery := parser.CanonicalizeWorkspacePath(name) + if canonicalQuery != "" { + if parser.CanonicalizeWorkspacePath(project.Path) == canonicalQuery { + return true + } + for _, session := range project.Sessions { + if session != nil && parser.CanonicalizeWorkspacePath(session.CWD) == canonicalQuery { + return true + } + } + } + return strings.Contains(nameLower, query) || strings.Contains(pathLower, query) +} + +func ProjectMatchesWorkspace(project *parser.Project, workspacePath string) bool { + if project == nil { + return false + } + canonical := parser.CanonicalizeWorkspacePath(workspacePath) + if canonical == "" { + return false + } + if parser.CanonicalizeWorkspacePath(project.Path) == canonical { + return true + } + if project.EncodedName != "" && parser.CanonicalizeWorkspacePath(parser.DecodePath(project.EncodedName)) == canonical { + return true + } + for _, session := range project.Sessions { + if session == nil { + continue + } + if parser.CanonicalizeWorkspacePath(session.CWD) == canonical { + return true + } + } + return false +} + +type AmbiguousSessionError struct { + Query string + Matches []*parser.Session +} + +func (e *AmbiguousSessionError) Error() string { + if e == nil { + return "" + } + return fmt.Sprintf("ambiguous session %q matched %d sessions", e.Query, len(e.Matches)) +} + +func FindSessionByID(sessions []*parser.Session, query string) (*parser.Session, error) { + query = strings.TrimSpace(query) + if query == "" { + return nil, nil + } + + var exact []*parser.Session + for _, session := range sessions { + if session != nil && session.ID == query { + exact = append(exact, session) + } + } + if len(exact) == 1 { + return exact[0], nil + } + if len(exact) > 1 { + return nil, &AmbiguousSessionError{Query: query, Matches: exact} + } + + var prefixed []*parser.Session + for _, session := range sessions { + if session != nil && strings.HasPrefix(session.ID, query) { + prefixed = append(prefixed, session) + } + } + if len(prefixed) == 1 { + return prefixed[0], nil + } + if len(prefixed) > 1 { + return nil, &AmbiguousSessionError{Query: query, Matches: prefixed} + } + return nil, nil +} diff --git a/internal/catalog/session_query_test.go b/internal/catalog/session_query_test.go new file mode 100644 index 0000000..9e7d492 --- /dev/null +++ b/internal/catalog/session_query_test.go @@ -0,0 +1,140 @@ +package catalog + +import ( + "errors" + "testing" + "time" + + "github.com/thevibeworks/ccx/internal/config" + "github.com/thevibeworks/ccx/internal/parser" +) + +func TestApplySessionQueryScopesToWorkspace(t *testing.T) { + now := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC) + projects := []*parser.Project{ + { + Name: "repo", + Path: "/tmp/repo", + Sessions: []*parser.Session{ + {ID: "current-cx", Provider: "codex", CWD: "/tmp/repo", EndTime: now}, + {ID: "current-cc", Provider: "claude-code", CWD: "/tmp/repo", EndTime: now.Add(-time.Minute)}, + }, + }, + { + Name: "other", + Path: "/tmp/other", + Sessions: []*parser.Session{ + {ID: "other", Provider: "codex", CWD: "/tmp/other", EndTime: now.Add(time.Minute)}, + }, + }, + } + + got := ApplySessionQuery(projects, SessionQuery{ + Scope: ScopeWorkspace, + WorkspacePath: "/tmp/repo", + Sort: SortTime, + }) + + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].ID != "current-cx" || got[1].ID != "current-cc" { + t.Fatalf("got IDs %q, %q; want current-cx, current-cc", got[0].ID, got[1].ID) + } +} + +func TestApplySessionQueryAllPreservesGlobalCandidateSet(t *testing.T) { + now := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC) + projects := []*parser.Project{ + {Name: "repo", Sessions: []*parser.Session{{ID: "repo", EndTime: now}}}, + {Name: "other", Sessions: []*parser.Session{{ID: "other", EndTime: now.Add(time.Minute)}}}, + } + + got := ApplySessionQuery(projects, SessionQuery{Scope: ScopeAll, Sort: SortTime}) + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].ID != "other" || got[1].ID != "repo" { + t.Fatalf("global ordering = [%s %s], want [other repo]", got[0].ID, got[1].ID) + } +} + +func TestApplySessionQueryProjectScopeIsExplicit(t *testing.T) { + projects := []*parser.Project{ + {Name: "repo", EncodedName: "tmp-repo", Path: "/tmp/repo", Sessions: []*parser.Session{{ID: "repo"}}}, + {Name: "other", EncodedName: "tmp-other", Path: "/tmp/other", Sessions: []*parser.Session{{ID: "other"}}}, + } + + got := ApplySessionQuery(projects, SessionQuery{ + Scope: ScopeProject, + ProjectName: "tmp-other", + }) + if len(got) != 1 || got[0].ID != "other" { + t.Fatalf("got %+v, want only other project session", got) + } +} + +func TestApplySessionQueryFiltersSortsAndLimits(t *testing.T) { + base := time.Date(2026, 5, 21, 12, 0, 0, 0, time.UTC) + projects := []*parser.Project{{ + Name: "repo", + Sessions: []*parser.Session{ + {ID: "cc-big", Provider: "claude-code", Summary: "auth refactor", Model: "sonnet", StartTime: base, EndTime: base.Add(time.Minute), Stats: parser.SessionStats{MessageCount: 20}}, + {ID: "cx-small", Provider: "codex", Summary: "auth refactor", Model: "gpt-5", StartTime: base, EndTime: base.Add(2 * time.Minute), Stats: parser.SessionStats{MessageCount: 4}}, + {ID: "cx-big", Provider: "codex", Summary: "billing", Model: "gpt-5", StartTime: base, EndTime: base.Add(3 * time.Minute), Stats: parser.SessionStats{MessageCount: 40}}, + }, + }} + + got := ApplySessionQuery(projects, SessionQuery{ + Scope: ScopeAll, + Filter: config.SessionFilter{ + Provider: "codex", + Model: "gpt", + }, + Sort: SortMessages, + Limit: 1, + }) + if len(got) != 1 || got[0].ID != "cx-big" { + t.Fatalf("got %+v, want cx-big", got) + } +} + +func TestFindSessionByIDReportsAmbiguousPrefix(t *testing.T) { + sessions := []*parser.Session{ + {ID: "abcdef-one"}, + {ID: "abcdef-two"}, + } + + got, err := FindSessionByID(sessions, "abcdef") + if got != nil { + t.Fatalf("session = %+v, want nil on ambiguity", got) + } + var ambiguous *AmbiguousSessionError + if !errors.As(err, &ambiguous) { + t.Fatalf("err = %v, want AmbiguousSessionError", err) + } + if len(ambiguous.Matches) != 2 { + t.Fatalf("matches = %d, want 2", len(ambiguous.Matches)) + } +} + +func TestFindSessionByIDPrefersExactBeforePrefixAmbiguity(t *testing.T) { + sessions := []*parser.Session{ + {ID: "abc"}, + {ID: "abcdef"}, + } + + got, err := FindSessionByID(sessions, "abc") + if err != nil { + t.Fatalf("FindSessionByID error: %v", err) + } + if got == nil || got.ID != "abc" { + t.Fatalf("got %+v, want exact abc", got) + } +} + +func TestValidateSessionSortRejectsUnknownSort(t *testing.T) { + if err := ValidateSessionSort("bogus"); err == nil { + t.Fatal("ValidateSessionSort(bogus) returned nil, want error") + } +} diff --git a/internal/cmd/display.go b/internal/cmd/display.go new file mode 100644 index 0000000..8bf261b --- /dev/null +++ b/internal/cmd/display.go @@ -0,0 +1,110 @@ +package cmd + +import ( + "strings" + "unicode" + "unicode/utf8" +) + +func sessionSummaryPreview(summary string, max int) string { + preview := cleanDisplayText(summary) + preview = strings.Trim(preview, "`'\" ") + if preview == "" { + preview = "(no summary)" + } + return truncateDisplay(preview, max) +} + +func cleanDisplayText(s string) string { + s = stripANSI(s) + var out strings.Builder + out.Grow(len(s)) + + lastSpace := false + for _, r := range s { + if r == '\uFFFD' { + continue + } + if r == '\t' || r == '\n' || r == '\r' || unicode.IsSpace(r) { + if !lastSpace && out.Len() > 0 { + out.WriteByte(' ') + lastSpace = true + } + continue + } + if unicode.IsControl(r) { + continue + } + out.WriteRune(r) + lastSpace = false + } + + return strings.TrimSpace(out.String()) +} + +func stripANSI(s string) string { + var out strings.Builder + out.Grow(len(s)) + for i := 0; i < len(s); i++ { + if s[i] != 0x1b { + out.WriteByte(s[i]) + continue + } + i++ + if i >= len(s) || s[i] != '[' { + continue + } + i++ + for i < len(s) { + b := s[i] + if b >= 0x40 && b <= 0x7e { + break + } + i++ + } + } + return out.String() +} + +func truncateDisplay(s string, max int) string { + if max <= 0 { + return "" + } + if displayLen(s) <= max { + return s + } + if max <= 3 { + return strings.Repeat(".", max) + } + + limit := max - 3 + var out strings.Builder + width := 0 + for _, r := range s { + w := runeDisplayWidth(r) + if width+w > limit { + break + } + out.WriteRune(r) + width += w + } + return strings.TrimRight(out.String(), " ") + "..." +} + +func displayLen(s string) int { + n := 0 + for _, r := range s { + n += runeDisplayWidth(r) + } + return n +} + +func runeDisplayWidth(r rune) int { + if r == 0 || unicode.IsControl(r) { + return 0 + } + if utf8.RuneLen(r) > 1 && !unicode.In(r, unicode.Latin, unicode.Common) { + return 2 + } + return 1 +} diff --git a/internal/cmd/display_test.go b/internal/cmd/display_test.go new file mode 100644 index 0000000..f69e754 --- /dev/null +++ b/internal/cmd/display_test.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestSessionSummaryPreviewCollapsesMarkdownAndNewlines(t *testing.T) { + input := "initiatives & request: ```\n\n› we've built and released our ccx project\n\n```" + got := sessionSummaryPreview(input, 80) + + if strings.ContainsAny(got, "\n\r\t") { + t.Fatalf("preview contains table-breaking whitespace: %q", got) + } + if got != "initiatives & request: ``` › we've built and released our ccx project" { + t.Fatalf("preview = %q", got) + } +} + +func TestSessionSummaryPreviewNormalizesIndentedReviewPrompt(t *testing.T) { + input := " Second review pass for the scoped-session refac...\n with details" + got := sessionSummaryPreview(input, 64) + + if got != "Second review pass for the scoped-session refac... with details" { + t.Fatalf("preview = %q", got) + } +} + +func TestSessionSummaryPreviewDropsControlBytes(t *testing.T) { + input := "fix\x00 auth\x1b[31m bug\nnow" + got := sessionSummaryPreview(input, 64) + + if strings.Contains(got, "\x00") || strings.Contains(got, "\x1b") || strings.Contains(got, "\n") { + t.Fatalf("preview retained control bytes: %q", got) + } + if got != "fix auth bug now" { + t.Fatalf("preview = %q", got) + } +} + +func TestSessionSummaryPreviewTruncatesAfterNormalization(t *testing.T) { + input := "hello\nworld from ccx sessions list" + got := sessionSummaryPreview(input, 18) + + if got != "hello world fro..." { + t.Fatalf("preview = %q", got) + } +} + +func TestSessionSummaryPreviewFallback(t *testing.T) { + got := sessionSummaryPreview("\n\t ", 20) + if got != "(no summary)" { + t.Fatalf("preview = %q", got) + } +} diff --git a/internal/cmd/export.go b/internal/cmd/export.go index a0fc28f..23fe8b7 100644 --- a/internal/cmd/export.go +++ b/internal/cmd/export.go @@ -38,12 +38,14 @@ var ( exportShape string exportEnvelope string exportTemplate string + exportAll bool ) func init() { exportCmd.Flags().StringVarP(&exportFormat, "format", "f", "", "output format: html, md, org, exec (default from config)") exportCmd.Flags().StringVarP(&exportOutput, "output", "o", "", "output file path (default: session.)") exportCmd.Flags().StringVarP(&exportProject, "project", "p", "", "project name") + exportCmd.Flags().BoolVar(&exportAll, "all", false, "search across all projects") exportCmd.Flags().StringVar(&exportTheme, "theme", "", "theme: dark, light (default from config)") exportCmd.Flags().BoolVar(&exportIncludeThinking, "include-thinking", false, "include thinking blocks") exportCmd.Flags().BoolVar(&exportIncludeAgents, "include-agents", false, "include agent sidechains") @@ -60,14 +62,18 @@ func runExport(cmd *cobra.Command, args []string) error { var err error if len(args) == 0 { - session, err = selectSession(backend) + session, err = selectSession(backend, exportAll) } else { sessionArg := args[0] projectName, sessionID := parseSessionArg(sessionArg) if exportProject != "" { projectName = exportProject } - session, err = backend.FindSession(projectName, sessionID) + query, qErr := sessionLookupQuery(projectName, exportAll) + if qErr != nil { + return qErr + } + session, err = resolveSessionInQuery(backend, query, sessionID) } if err != nil { diff --git a/internal/cmd/scope.go b/internal/cmd/scope.go new file mode 100644 index 0000000..7892dbc --- /dev/null +++ b/internal/cmd/scope.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "strconv" + "strings" + + "github.com/thevibeworks/ccx/internal/catalog" + "github.com/thevibeworks/ccx/internal/parser" + "github.com/thevibeworks/ccx/internal/provider" +) + +func currentWorkspaceQuery() (catalog.SessionQuery, error) { + cwd, err := os.Getwd() + if err != nil { + return catalog.SessionQuery{}, fmt.Errorf("failed to get current directory: %w", err) + } + return catalog.SessionQuery{ + Scope: catalog.ScopeWorkspace, + WorkspacePath: cwd, + }, nil +} + +func allSessionsQuery() catalog.SessionQuery { + return catalog.SessionQuery{Scope: catalog.ScopeAll} +} + +func resolveSessionInQuery(backend provider.Backend, query catalog.SessionQuery, sessionID string) (*parser.Session, error) { + sessions, err := backend.ListSessions(query.WithoutLimit()) + if err != nil { + return nil, err + } + if strings.HasPrefix(sessionID, "@") { + return resolveSessionIndex(sessions, sessionID) + } + session, err := catalog.FindSessionByID(sessions, sessionID) + if err == nil { + return session, nil + } + var ambiguous *catalog.AmbiguousSessionError + if errors.As(err, &ambiguous) { + return nil, describeAmbiguousSession(ambiguous) + } + return nil, err +} + +func resolveSessionIndex(sessions []*parser.Session, sessionID string) (*parser.Session, error) { + n, err := strconv.Atoi(strings.TrimPrefix(sessionID, "@")) + if err != nil || n < 1 { + return nil, fmt.Errorf("invalid session index %q", sessionID) + } + catalog.SortSessions(sessions, catalog.SortTime) + if n > len(sessions) { + return nil, fmt.Errorf("session index %s out of range; only %d sessions available", sessionID, len(sessions)) + } + return sessions[n-1], nil +} + +func describeAmbiguousSession(err *catalog.AmbiguousSessionError) error { + if err == nil { + return nil + } + var details []string + for _, session := range err.Matches { + if session == nil { + continue + } + if session.ProjectName != "" { + details = append(details, fmt.Sprintf("%s:%s", session.ProjectName, session.ID)) + continue + } + details = append(details, session.ID) + } + if len(details) == 0 { + return err + } + return fmt.Errorf("%w: %s", err, strings.Join(details, ", ")) +} diff --git a/internal/cmd/scope_test.go b/internal/cmd/scope_test.go new file mode 100644 index 0000000..c1a9bc6 --- /dev/null +++ b/internal/cmd/scope_test.go @@ -0,0 +1,108 @@ +package cmd + +import ( + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/thevibeworks/ccx/internal/catalog" + "github.com/thevibeworks/ccx/internal/parser" +) + +type scopeBackend struct { + sessions []*parser.Session +} + +func (b *scopeBackend) ID() string { return "scope" } +func (b *scopeBackend) Homes() []string { return nil } +func (b *scopeBackend) DiscoverProjects() ([]*parser.Project, error) { + return nil, nil +} +func (b *scopeBackend) ListSessions(query catalog.SessionQuery) ([]*parser.Session, error) { + project := &parser.Project{Name: "repo", Path: query.WorkspacePath, Sessions: b.sessions} + if query.Scope == catalog.ScopeAll { + project.Path = "" + } + return catalog.ApplySessionQuery([]*parser.Project{project}, query), nil +} +func (b *scopeBackend) FindProject(string) (*parser.Project, error) { return nil, nil } +func (b *scopeBackend) FindSession(string, string) (*parser.Session, error) { + return nil, nil +} +func (b *scopeBackend) ParseSession(string) (*parser.Session, error) { return nil, nil } + +func TestResolveSessionInQueryDoesNotEscapeWorkspaceScope(t *testing.T) { + dir := t.TempDir() + oldwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldwd) }) + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + + query, err := currentWorkspaceQuery() + if err != nil { + t.Fatalf("currentWorkspaceQuery() error: %v", err) + } + + backend := &scopeBackend{sessions: []*parser.Session{ + {ID: "abcdef-current", CWD: dir, EndTime: time.Now()}, + {ID: "abcdef-other", CWD: "/tmp/other", EndTime: time.Now().Add(time.Minute)}, + }} + + got, err := resolveSessionInQuery(backend, query, "abcdef") + if err != nil { + t.Fatalf("resolveSessionInQuery() error: %v", err) + } + if got == nil || got.ID != "abcdef-current" { + t.Fatalf("got %+v, want current workspace match", got) + } +} + +func TestResolveSessionInQueryReportsScopedAmbiguity(t *testing.T) { + dir := t.TempDir() + backend := &scopeBackend{sessions: []*parser.Session{ + {ID: "abcdef123456-one", CWD: dir}, + {ID: "abcdef123456-two", CWD: dir}, + {ID: "abcdef123456-other", CWD: "/tmp/other"}, + }} + + _, err := resolveSessionInQuery(backend, catalog.SessionQuery{ + Scope: catalog.ScopeWorkspace, + WorkspacePath: dir, + }, "abcdef123456") + + var ambiguous *catalog.AmbiguousSessionError + if !errors.As(err, &ambiguous) { + t.Fatalf("err = %v, want AmbiguousSessionError", err) + } + if len(ambiguous.Matches) != 2 { + t.Fatalf("matches = %d, want 2 scoped matches", len(ambiguous.Matches)) + } + if !strings.Contains(err.Error(), "abcdef123456-one") || !strings.Contains(err.Error(), "abcdef123456-two") { + t.Fatalf("ambiguous error should list scoped matches, got %q", err.Error()) + } +} + +func TestResolveSessionInQuerySupportsScopedIndex(t *testing.T) { + now := time.Now() + backend := &scopeBackend{sessions: []*parser.Session{ + {ID: "old", CWD: "/tmp/repo", EndTime: now.Add(-time.Hour)}, + {ID: "new", CWD: "/tmp/repo", EndTime: now}, + }} + + got, err := resolveSessionInQuery(backend, catalog.SessionQuery{ + Scope: catalog.ScopeWorkspace, + WorkspacePath: "/tmp/repo", + }, "@1") + if err != nil { + t.Fatalf("resolveSessionInQuery(@1) error: %v", err) + } + if got == nil || got.ID != "new" { + t.Fatalf("got %+v, want newest session", got) + } +} diff --git a/internal/cmd/search.go b/internal/cmd/search.go index 2603e72..b0f6a9d 100644 --- a/internal/cmd/search.go +++ b/internal/cmd/search.go @@ -133,7 +133,7 @@ func runSearch(cmd *cobra.Command, args []string) error { Type: "session", Project: projDisplay, Session: truncateID(s.ID, 8), - Summary: truncate(s.Summary, 60), + Summary: sessionSummaryPreview(s.Summary, 64), Time: formatAge(s.StartTime), Priority: 0, }) @@ -146,7 +146,7 @@ func runSearch(cmd *cobra.Command, args []string) error { Type: "session", Project: projDisplay, Session: truncateID(s.ID, 8), - Summary: truncate(s.Summary, 60), + Summary: sessionSummaryPreview(s.Summary, 64), Time: formatAge(s.StartTime), Priority: 2, }) @@ -167,10 +167,10 @@ func runSearch(cmd *cobra.Command, args []string) error { } if strings.Contains(strings.ToLower(name), query) { results = append(results, searchResult{ - Type: "memory", - Project: filepath.Base(home), - Summary: name, - Time: "-", + Type: "memory", + Project: filepath.Base(home), + Summary: name, + Time: "-", Priority: 1, }) } @@ -216,7 +216,7 @@ func printSearchResults(results []searchResult) error { time = "-" } fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", - r.Type, truncate(r.Project, 20), session, r.Summary, time) + r.Type, truncateDisplay(cleanDisplayText(r.Project), 24), session, cleanDisplayText(r.Summary), time) } return w.Flush() @@ -255,13 +255,6 @@ func searchMemoryDir(home, subdir, query string, filter config.SessionFilter, re } } -func truncate(s string, max int) string { - if len(s) <= max { - return s - } - return s[:max-3] + "..." -} - func truncateID(id string, max int) string { if len(id) <= max { return id diff --git a/internal/cmd/sessions.go b/internal/cmd/sessions.go index 61f6cb0..7421429 100644 --- a/internal/cmd/sessions.go +++ b/internal/cmd/sessions.go @@ -4,12 +4,12 @@ import ( "encoding/json" "fmt" "os" - "sort" "text/tabwriter" "time" "github.com/spf13/cobra" + "github.com/thevibeworks/ccx/internal/catalog" "github.com/thevibeworks/ccx/internal/config" "github.com/thevibeworks/ccx/internal/parser" "github.com/thevibeworks/ccx/internal/provider" @@ -22,7 +22,8 @@ var sessionsCmd = &cobra.Command{ Long: `List sessions from the selected provider. If PROJECT is specified, show sessions for that project only. -Otherwise, show recent sessions across all projects.`, +Otherwise, show recent sessions for the current workspace. +Use --all to show recent sessions across all projects.`, Args: cobra.MaximumNArgs(1), RunE: runSessions, } @@ -36,6 +37,7 @@ var ( sessionsAfter string sessionsBefore string sessionsModel string + sessionsAll bool ) func init() { @@ -47,6 +49,7 @@ func init() { sessionsCmd.Flags().StringVar(&sessionsAfter, "after", "", "sessions after date (YYYY-MM-DD)") sessionsCmd.Flags().StringVar(&sessionsBefore, "before", "", "sessions before date (YYYY-MM-DD)") sessionsCmd.Flags().StringVar(&sessionsModel, "model", "", "filter by model name substring") + sessionsCmd.Flags().BoolVar(&sessionsAll, "all", false, "list sessions across all projects") } func runSessions(cmd *cobra.Command, args []string) error { @@ -68,67 +71,44 @@ func runSessions(cmd *cobra.Command, args []string) error { Model: sessionsModel, } - var sessions []*parser.Session + query := catalog.SessionQuery{ + Filter: filter, + Sort: catalog.SessionSort(sessionsSort), + Limit: sessionsLimit, + } + if err := catalog.ValidateSessionSort(query.Sort); err != nil { + return err + } var projectName string - if len(args) > 0 { projectName = args[0] - project, err := backend.FindProject(projectName) - if err != nil { - return fmt.Errorf("failed to find project: %w", err) - } - if project == nil { - return fmt.Errorf("project not found: %s", projectName) - } - sessions = project.Sessions + query.Scope = catalog.ScopeProject + query.ProjectName = projectName + } else if sessionsAll { + query.Scope = catalog.ScopeAll } else { - projects, err := backend.DiscoverProjects() + current, err := currentWorkspaceQuery() if err != nil { - return fmt.Errorf("failed to discover projects: %w", err) - } - for _, p := range projects { - for _, s := range p.Sessions { - s.ProjectName = p.Name - sessions = append(sessions, s) - } + return err } + query.Scope = current.Scope + query.WorkspacePath = current.WorkspacePath } - if !filter.IsEmpty() { - var filtered []*parser.Session - for _, s := range sessions { - if filter.Match(s) { - filtered = append(filtered, s) - } - } - sessions = filtered + sessions, err := backend.ListSessions(query) + if err != nil { + return fmt.Errorf("failed to list sessions: %w", err) } - if len(sessions) == 0 { fmt.Println("No sessions found.") return nil } - switch sessionsSort { - case "messages": - sort.Slice(sessions, func(i, j int) bool { - return sessions[i].Stats.MessageCount > sessions[j].Stats.MessageCount - }) - default: // "time" - sort.Slice(sessions, func(i, j int) bool { - return sessions[i].EndTime.After(sessions[j].EndTime) - }) - } - - if sessionsLimit > 0 && len(sessions) > sessionsLimit { - sessions = sessions[:sessionsLimit] - } - if sessionsJSON { return printSessionsJSON(sessions) } - return printSessionsTable(sessions, projectName == "") + return printSessionsTable(sessions, projectName == "" && sessionsAll) } func providerTag(p string) string { @@ -157,19 +137,13 @@ func printSessionsTable(sessions []*parser.Session, showProject bool) error { id = id[:8] } - summary := s.Summary - if len(summary) > 50 { - summary = summary[:47] + "..." - } + summary := sessionSummaryPreview(s.Summary, 64) age := formatAge(s.StartTime) tag := providerTag(s.Provider) if showProject { - proj := s.ProjectName - if len(proj) > 20 { - proj = proj[:17] + "..." - } + proj := truncateDisplay(cleanDisplayText(s.ProjectName), 24) fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", tag, proj, id, age, summary) } else { fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", tag, id, age, summary) diff --git a/internal/cmd/sessions_test.go b/internal/cmd/sessions_test.go new file mode 100644 index 0000000..c2621dc --- /dev/null +++ b/internal/cmd/sessions_test.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "bytes" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/thevibeworks/ccx/internal/parser" +) + +func TestPrintSessionsTableKeepsEachSessionOnOneLine(t *testing.T) { + sessions := []*parser.Session{ + { + ID: "019e48cb-long-id", + Provider: "codex", + Summary: "initiatives & request: ```\n\n› we've built and released our ccx project\n```", + StartTime: time.Date(2026, 5, 21, 10, 0, 0, 0, time.UTC), + }, + { + ID: "019e48dc-long-id", + Provider: "codex", + Summary: " Second review pass for the scoped-session refac...\n with details", + StartTime: time.Date(2026, 5, 21, 10, 1, 0, 0, time.UTC), + }, + } + + out := captureStdout(t, func() { + if err := printSessionsTable(sessions, false); err != nil { + t.Fatalf("printSessionsTable() error: %v", err) + } + }) + + lines := strings.Split(strings.TrimSpace(out), "\n") + if len(lines) != 3 { + t.Fatalf("line count = %d, want header + 2 sessions; output:\n%s", len(lines), out) + } + if strings.Contains(out, "\n\n") { + t.Fatalf("table contains blank line from raw summary: %q", out) + } + if !strings.Contains(lines[1], "initiatives & request: ``` › we've built") { + t.Fatalf("first row summary not normalized: %q", lines[1]) + } + if !strings.Contains(lines[2], "Second review pass for the scoped-session") { + t.Fatalf("second row summary not normalized: %q", lines[2]) + } +} + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + old := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + t.Cleanup(func() { os.Stdout = old }) + + fn() + if err := w.Close(); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + if _, err := io.Copy(&buf, r); err != nil { + t.Fatal(err) + } + return buf.String() +} diff --git a/internal/cmd/view.go b/internal/cmd/view.go index 1922104..adc6867 100644 --- a/internal/cmd/view.go +++ b/internal/cmd/view.go @@ -9,6 +9,7 @@ import ( "github.com/spf13/cobra" + "github.com/thevibeworks/ccx/internal/catalog" "github.com/thevibeworks/ccx/internal/config" "github.com/thevibeworks/ccx/internal/parser" "github.com/thevibeworks/ccx/internal/provider" @@ -36,11 +37,13 @@ var ( viewShowThinking bool viewShowAgents bool viewFlat bool - viewBrief bool + viewBrief bool + viewAll bool ) func init() { viewCmd.Flags().StringVarP(&viewProject, "project", "p", "", "project name") + viewCmd.Flags().BoolVar(&viewAll, "all", false, "search across all projects") viewCmd.Flags().BoolVar(&viewShowThinking, "show-thinking", false, "show thinking blocks expanded") viewCmd.Flags().BoolVar(&viewShowAgents, "show-agents", false, "show agent sidechains") viewCmd.Flags().BoolVar(&viewFlat, "flat", false, "disable tree rendering") @@ -54,14 +57,18 @@ func runView(cmd *cobra.Command, args []string) error { var err error if len(args) == 0 { - session, err = selectSession(backend) + session, err = selectSession(backend, viewAll) } else { sessionArg := args[0] projectName, sessionID := parseSessionArg(sessionArg) if viewProject != "" { projectName = viewProject } - session, err = backend.FindSession(projectName, sessionID) + query, qErr := sessionLookupQuery(projectName, viewAll) + if qErr != nil { + return qErr + } + session, err = resolveSessionInQuery(backend, query, sessionID) } if err != nil { @@ -90,6 +97,19 @@ func runView(cmd *cobra.Command, args []string) error { return render.Terminal(fullSession, opts) } +func sessionLookupQuery(projectName string, all bool) (catalog.SessionQuery, error) { + if projectName != "" { + return catalog.SessionQuery{ + Scope: catalog.ScopeProject, + ProjectName: projectName, + }, nil + } + if all { + return allSessionsQuery(), nil + } + return currentWorkspaceQuery() +} + func parseSessionArg(arg string) (project, session string) { if strings.Contains(arg, ":") { parts := strings.SplitN(arg, ":", 2) @@ -98,18 +118,25 @@ func parseSessionArg(arg string) (project, session string) { return "", arg } -func selectSession(backend provider.Backend) (*parser.Session, error) { - projects, err := backend.DiscoverProjects() - if err != nil { - return nil, err +func selectSession(backend provider.Backend, all bool) (*parser.Session, error) { + query := catalog.SessionQuery{ + Sort: catalog.SortTime, + Limit: 10, } - - var allSessions []*parser.Session - for _, p := range projects { - for _, s := range p.Sessions { - s.ProjectName = p.Name - allSessions = append(allSessions, s) + if all { + query.Scope = catalog.ScopeAll + } else { + current, err := currentWorkspaceQuery() + if err != nil { + return nil, err } + query.Scope = current.Scope + query.WorkspacePath = current.WorkspacePath + } + + allSessions, err := backend.ListSessions(query) + if err != nil { + return nil, err } if len(allSessions) == 0 { @@ -123,12 +150,10 @@ func selectSession(backend provider.Backend) (*parser.Session, error) { } for i, s := range allSessions[:limit] { - summary := s.Summary - if len(summary) > 50 { - summary = summary[:47] + "..." - } + summary := sessionSummaryPreview(s.Summary, 64) tag := providerTag(s.Provider) - fmt.Printf(" %d. %s [%s] %s\n", i+1, tag, s.ProjectName, summary) + project := truncateDisplay(cleanDisplayText(s.ProjectName), 24) + fmt.Printf(" %d. %s [%s] %s\n", i+1, tag, project, summary) } fmt.Printf("\nSelect session (1-%d): ", limit) diff --git a/internal/parser/project.go b/internal/parser/project.go index 2ddbe94..d2c954f 100644 --- a/internal/parser/project.go +++ b/internal/parser/project.go @@ -25,7 +25,7 @@ func DiscoverProjects(projectsDir string) ([]*Project, error) { encodedName := entry.Name() projectPath := filepath.Join(projectsDir, encodedName) - sessions, err := discoverSessions(projectPath) + sessions, err := DiscoverProjectSessions(projectPath) if err != nil { continue } @@ -56,7 +56,7 @@ func DiscoverProjects(projectsDir string) ([]*Project, error) { return projects, nil } -func discoverSessions(projectPath string) ([]*Session, error) { +func DiscoverProjectSessions(projectPath string) ([]*Session, error) { entries, err := os.ReadDir(projectPath) if err != nil { return nil, err diff --git a/internal/parser/session_extra_test.go b/internal/parser/session_extra_test.go index 78039a7..253e01b 100644 --- a/internal/parser/session_extra_test.go +++ b/internal/parser/session_extra_test.go @@ -1,6 +1,7 @@ package parser import ( + "encoding/json" "os" "path/filepath" "testing" @@ -415,3 +416,47 @@ func containsHelper(s, substr string) bool { } return false } + +func TestParseToolUseResult_Object(t *testing.T) { + raw := json.RawMessage(`{"agentId":"abc123","agentType":"Explore","status":"completed","totalTokens":22351,"totalToolUseCount":12,"totalDurationMs":144629}`) + result := parseToolUseResult(raw) + if result == nil { + t.Fatal("expected non-nil result for valid object") + } + if result.AgentID != "abc123" { + t.Errorf("AgentID = %q, want abc123", result.AgentID) + } + if result.AgentType != "Explore" { + t.Errorf("AgentType = %q, want Explore", result.AgentType) + } + if result.TotalTokens != 22351 { + t.Errorf("TotalTokens = %d, want 22351", result.TotalTokens) + } +} + +func TestParseToolUseResult_String(t *testing.T) { + raw := json.RawMessage(`"Error: File does not exist"`) + result := parseToolUseResult(raw) + if result != nil { + t.Error("string toolUseResult should return nil") + } +} + +func TestParseToolUseResult_NoAgentID(t *testing.T) { + raw := json.RawMessage(`{"status":"completed","stdout":"hello"}`) + result := parseToolUseResult(raw) + if result != nil { + t.Error("toolUseResult without agentId should return nil") + } +} + +func TestParseToolUseResult_Empty(t *testing.T) { + result := parseToolUseResult(nil) + if result != nil { + t.Error("nil raw should return nil") + } + result = parseToolUseResult(json.RawMessage{}) + if result != nil { + t.Error("empty raw should return nil") + } +} diff --git a/internal/parser/types.go b/internal/parser/types.go index deb1ead..f22010c 100644 --- a/internal/parser/types.go +++ b/internal/parser/types.go @@ -189,10 +189,10 @@ type SubAgentResultData struct { TotalToolUseCount int `json:"totalToolUseCount"` TotalDurationMs int `json:"totalDurationMs"` Usage *usageData `json:"usage"` - ToolStats *toolStats `json:"toolStats"` + ToolStats *ToolStats `json:"toolStats"` } -type toolStats struct { +type ToolStats struct { ReadCount int `json:"readCount"` SearchCount int `json:"searchCount"` BashCount int `json:"bashCount"` diff --git a/internal/provider/claude/backend.go b/internal/provider/claude/backend.go index ba75c3b..9a8f06d 100644 --- a/internal/provider/claude/backend.go +++ b/internal/provider/claude/backend.go @@ -2,7 +2,9 @@ package claude import ( "path/filepath" + "regexp" + "github.com/thevibeworks/ccx/internal/catalog" "github.com/thevibeworks/ccx/internal/parser" ) @@ -42,6 +44,54 @@ func (b *Backend) DiscoverProjects() ([]*parser.Project, error) { return projects, nil } +func (b *Backend) ListSessions(query catalog.SessionQuery) ([]*parser.Session, error) { + if query.Scope == catalog.ScopeWorkspace { + return b.listWorkspaceSessions(query) + } + + if query.Scope == catalog.ScopeProject && query.ProjectName != "" { + project, err := b.FindProject(query.ProjectName) + if err != nil || project == nil { + return nil, err + } + return catalog.ApplySessionQuery([]*parser.Project{project}, query), nil + } + + projects, err := b.DiscoverProjects() + if err != nil { + return nil, err + } + return catalog.ApplySessionQuery(projects, query), nil +} + +func (b *Backend) listWorkspaceSessions(query catalog.SessionQuery) ([]*parser.Session, error) { + encoded := claudeEncodeWorkspacePath(query.WorkspacePath) + if encoded != "" { + projectPath := filepath.Join(b.projectsDir, encoded) + if sessions, err := parser.DiscoverProjectSessions(projectPath); err == nil && len(sessions) > 0 { + project := &parser.Project{ + Name: parser.GetProjectDisplayName(encoded), + EncodedName: encoded, + Path: query.WorkspacePath, + Provider: ProviderID, + Sessions: sessions, + LastModified: sessions[0].EndTime, + } + for _, session := range sessions { + session.Provider = ProviderID + session.ProjectName = project.Name + } + return catalog.ApplySessionQuery([]*parser.Project{project}, query), nil + } + } + + projects, err := b.DiscoverProjects() + if err != nil { + return nil, err + } + return catalog.ApplySessionQuery(projects, query), nil +} + func (b *Backend) FindProject(name string) (*parser.Project, error) { p, err := parser.FindProject(b.projectsDir, name) if err != nil || p == nil { @@ -63,6 +113,15 @@ func (b *Backend) FindSession(projectName, sessionID string) (*parser.Session, e return s, nil } +func claudeEncodeWorkspacePath(path string) string { + if path == "" { + return "" + } + return claudeNonAlphanumeric.ReplaceAllString(filepath.Clean(path), "-") +} + +var claudeNonAlphanumeric = regexp.MustCompile(`[^a-zA-Z0-9]`) + func (b *Backend) ParseSession(filePath string) (*parser.Session, error) { s, err := parser.ParseSession(filePath) if err != nil || s == nil { diff --git a/internal/provider/claude/backend_test.go b/internal/provider/claude/backend_test.go new file mode 100644 index 0000000..ec45383 --- /dev/null +++ b/internal/provider/claude/backend_test.go @@ -0,0 +1,81 @@ +package claude + +import ( + "os" + "path/filepath" + "testing" + + "github.com/thevibeworks/ccx/internal/catalog" +) + +func TestListSessionsScopesClaudeSessionsByWorkspace(t *testing.T) { + home := t.TempDir() + projectsDir := filepath.Join(home, "projects") + + currentDir := filepath.Join(projectsDir, "-tmp-work-current") + otherDir := filepath.Join(projectsDir, "-tmp-work-other") + writeClaudeSession(t, filepath.Join(currentDir, "current.jsonl"), "/tmp/work/current", "current session") + writeClaudeSession(t, filepath.Join(otherDir, "other.jsonl"), "/tmp/work/other", "other session") + + backend := NewWithProjectsDir(home, projectsDir) + got, err := backend.ListSessions(catalog.SessionQuery{ + Scope: catalog.ScopeWorkspace, + WorkspacePath: "/tmp/work/current", + }) + if err != nil { + t.Fatalf("ListSessions() error = %v", err) + } + if len(got) != 1 || got[0].ID != "current" { + t.Fatalf("got %+v, want only current session", got) + } + if got[0].Provider != ProviderID { + t.Fatalf("Provider = %q, want %q", got[0].Provider, ProviderID) + } +} + +func TestListSessionsClaudeAllPreservesGlobalBehavior(t *testing.T) { + home := t.TempDir() + projectsDir := filepath.Join(home, "projects") + writeClaudeSession(t, filepath.Join(projectsDir, "-tmp-work-current", "current.jsonl"), "/tmp/work/current", "current session") + writeClaudeSession(t, filepath.Join(projectsDir, "-tmp-work-other", "other.jsonl"), "/tmp/work/other", "other session") + + backend := NewWithProjectsDir(home, projectsDir) + got, err := backend.ListSessions(catalog.SessionQuery{Scope: catalog.ScopeAll}) + if err != nil { + t.Fatalf("ListSessions() error = %v", err) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } +} + +func TestListSessionsClaudeWorkspaceFastPathUsesClaudeSanitizer(t *testing.T) { + home := t.TempDir() + projectsDir := filepath.Join(home, "projects") + writeClaudeSession(t, filepath.Join(projectsDir, "-tmp-work-a-b", "current.jsonl"), "/tmp/work/a+b", "current session") + + backend := NewWithProjectsDir(home, projectsDir) + got, err := backend.ListSessions(catalog.SessionQuery{ + Scope: catalog.ScopeWorkspace, + WorkspacePath: "/tmp/work/a+b", + }) + if err != nil { + t.Fatalf("ListSessions() error = %v", err) + } + if len(got) != 1 || got[0].ID != "current" { + t.Fatalf("got %+v, want session from sanitized -tmp-work-a-b directory", got) + } +} + +func writeClaudeSession(t *testing.T, path, cwd, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + t.Fatal(err) + } + data := `{"type":"user","timestamp":"2026-05-21T10:00:00Z","uuid":"u1","cwd":"` + cwd + `","message":{"role":"user","content":"` + content + `"}} +{"type":"assistant","timestamp":"2026-05-21T10:00:01Z","uuid":"a1","parentUuid":"u1","cwd":"` + cwd + `","message":{"role":"assistant","content":"ok"}} +` + if err := os.WriteFile(path, []byte(data), 0644); err != nil { + t.Fatal(err) + } +} diff --git a/internal/provider/codex/backend.go b/internal/provider/codex/backend.go index fb094b0..83f6096 100644 --- a/internal/provider/codex/backend.go +++ b/internal/provider/codex/backend.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/thevibeworks/ccx/internal/catalog" "github.com/thevibeworks/ccx/internal/parser" ) @@ -283,6 +284,44 @@ func (b *Backend) DiscoverProjects() ([]*parser.Project, error) { return projects, nil } +func (b *Backend) ListSessions(query catalog.SessionQuery) ([]*parser.Session, error) { + threadNames, err := b.readThreadNames() + if err != nil { + return nil, err + } + + sessions, err := b.discoverSessions(threadNames) + if err != nil { + return nil, err + } + + var projects []*parser.Project + projectsByKey := make(map[string]*parser.Project) + for _, session := range sessions { + encodedName, displayName, projectPath := projectInfoForCWD(session.CWD) + session.ProjectName = encodedName + session.Provider = ProviderID + + project := projectsByKey[encodedName] + if project == nil { + project = &parser.Project{ + Name: displayName, + EncodedName: encodedName, + Path: projectPath, + Provider: ProviderID, + } + projectsByKey[encodedName] = project + projects = append(projects, project) + } + project.Sessions = append(project.Sessions, session) + if project.LastModified.IsZero() || session.EndTime.After(project.LastModified) { + project.LastModified = session.EndTime + } + } + + return catalog.ApplySessionQuery(projects, query), nil +} + func (b *Backend) FindProject(name string) (*parser.Project, error) { projects, err := b.DiscoverProjects() if err != nil { diff --git a/internal/provider/codex/backend_test.go b/internal/provider/codex/backend_test.go index 538cde5..96daef0 100644 --- a/internal/provider/codex/backend_test.go +++ b/internal/provider/codex/backend_test.go @@ -4,6 +4,9 @@ import ( "os" "path/filepath" "testing" + + "github.com/thevibeworks/ccx/internal/catalog" + "github.com/thevibeworks/ccx/internal/config" ) func TestDiscoverProjectsGroupsCodexSessionsByCWD(t *testing.T) { @@ -50,6 +53,69 @@ func TestDiscoverProjectsGroupsCodexSessionsByCWD(t *testing.T) { } } +func TestListSessionsScopesCodexSessionsByWorkspace(t *testing.T) { + home := t.TempDir() + sessionsDir := filepath.Join(home, "sessions") + archivedDir := filepath.Join(home, "archived_sessions") + + writeRollout(t, filepath.Join(sessionsDir, "2026", "05", "21", "rollout-current.jsonl"), `{"timestamp":"2026-05-21T10:00:00Z","type":"session_meta","payload":{"id":"current","timestamp":"2026-05-21T10:00:00Z","cwd":"/tmp/work/current","originator":"codex_cli_rs","cli_version":"0.116.0","source":"cli","model_provider":"openai"}} +{"timestamp":"2026-05-21T10:00:01Z","type":"event_msg","payload":{"type":"user_message","message":"current session","images":[],"local_images":[],"text_elements":[]}} +{"timestamp":"2026-05-21T10:00:02Z","type":"turn_context","payload":{"cwd":"/tmp/work/current","model":"gpt-5"}} +`) + writeRollout(t, filepath.Join(sessionsDir, "2026", "05", "21", "rollout-other.jsonl"), `{"timestamp":"2026-05-21T11:00:00Z","type":"session_meta","payload":{"id":"other","timestamp":"2026-05-21T11:00:00Z","cwd":"/tmp/work/other","originator":"codex_cli_rs","cli_version":"0.116.0","source":"cli","model_provider":"openai"}} +{"timestamp":"2026-05-21T11:00:01Z","type":"event_msg","payload":{"type":"user_message","message":"other session","images":[],"local_images":[],"text_elements":[]}} +{"timestamp":"2026-05-21T11:00:02Z","type":"turn_context","payload":{"cwd":"/tmp/work/other","model":"gpt-5"}} +`) + + backend := NewWithDirs(home, sessionsDir, archivedDir) + got, err := backend.ListSessions(catalog.SessionQuery{ + Scope: catalog.ScopeWorkspace, + WorkspacePath: "/tmp/work/current", + }) + if err != nil { + t.Fatalf("ListSessions() error = %v", err) + } + if len(got) != 1 || got[0].ID != "current" { + t.Fatalf("got %+v, want only current session", got) + } +} + +func TestListSessionsAppliesCodexFiltersSortAndLimit(t *testing.T) { + home := t.TempDir() + sessionsDir := filepath.Join(home, "sessions") + archivedDir := filepath.Join(home, "archived_sessions") + + writeRollout(t, filepath.Join(sessionsDir, "2026", "05", "21", "rollout-small.jsonl"), `{"timestamp":"2026-05-21T10:00:00Z","type":"session_meta","payload":{"id":"small","timestamp":"2026-05-21T10:00:00Z","cwd":"/tmp/work/current","originator":"codex_cli_rs","cli_version":"0.116.0","source":"cli","model_provider":"openai"}} +{"timestamp":"2026-05-21T10:00:01Z","type":"event_msg","payload":{"type":"user_message","message":"auth session","images":[],"local_images":[],"text_elements":[]}} +{"timestamp":"2026-05-21T10:00:02Z","type":"turn_context","payload":{"cwd":"/tmp/work/current","model":"gpt-5"}} +`) + writeRollout(t, filepath.Join(sessionsDir, "2026", "05", "21", "rollout-big.jsonl"), `{"timestamp":"2026-05-21T11:00:00Z","type":"session_meta","payload":{"id":"big","timestamp":"2026-05-21T11:00:00Z","cwd":"/tmp/work/current","originator":"codex_cli_rs","cli_version":"0.116.0","source":"cli","model_provider":"openai"}} +{"timestamp":"2026-05-21T11:00:01Z","type":"event_msg","payload":{"type":"user_message","message":"auth session with more messages","images":[],"local_images":[],"text_elements":[]}} +{"timestamp":"2026-05-21T11:00:02Z","type":"event_msg","payload":{"type":"agent_message","message":"one"}} +{"timestamp":"2026-05-21T11:00:03Z","type":"event_msg","payload":{"type":"agent_message","message":"two"}} +{"timestamp":"2026-05-21T11:00:04Z","type":"turn_context","payload":{"cwd":"/tmp/work/current","model":"gpt-5"}} +`) + writeRollout(t, filepath.Join(sessionsDir, "2026", "05", "21", "rollout-other.jsonl"), `{"timestamp":"2026-05-21T12:00:00Z","type":"session_meta","payload":{"id":"other","timestamp":"2026-05-21T12:00:00Z","cwd":"/tmp/work/current","originator":"codex_cli_rs","cli_version":"0.116.0","source":"cli","model_provider":"openai"}} +{"timestamp":"2026-05-21T12:00:01Z","type":"event_msg","payload":{"type":"user_message","message":"billing session","images":[],"local_images":[],"text_elements":[]}} +{"timestamp":"2026-05-21T12:00:02Z","type":"turn_context","payload":{"cwd":"/tmp/work/current","model":"gpt-5"}} +`) + + backend := NewWithDirs(home, sessionsDir, archivedDir) + got, err := backend.ListSessions(catalog.SessionQuery{ + Scope: catalog.ScopeWorkspace, + WorkspacePath: "/tmp/work/current", + Filter: config.SessionFilter{Query: "auth", Model: "gpt-5"}, + Sort: catalog.SortMessages, + Limit: 1, + }) + if err != nil { + t.Fatalf("ListSessions() error = %v", err) + } + if len(got) != 1 || got[0].ID != "big" { + t.Fatalf("got %+v, want big auth session", got) + } +} + func TestDiscoverProjectsWithoutSessionIndexUsesResponseItemToolCounts(t *testing.T) { home := t.TempDir() sessionsDir := filepath.Join(home, "sessions") diff --git a/internal/provider/disk_cache_test.go b/internal/provider/disk_cache_test.go index c37c393..53f7299 100644 --- a/internal/provider/disk_cache_test.go +++ b/internal/provider/disk_cache_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/thevibeworks/ccx/internal/catalog" "github.com/thevibeworks/ccx/internal/parser" ) @@ -22,7 +23,10 @@ type realParseBackend struct { func (b *realParseBackend) ID() string { return "real" } func (b *realParseBackend) Homes() []string { return b.homes } func (b *realParseBackend) DiscoverProjects() ([]*parser.Project, error) { return nil, nil } -func (b *realParseBackend) FindProject(string) (*parser.Project, error) { return nil, nil } +func (b *realParseBackend) ListSessions(catalog.SessionQuery) ([]*parser.Session, error) { + return nil, nil +} +func (b *realParseBackend) FindProject(string) (*parser.Project, error) { return nil, nil } func (b *realParseBackend) FindSession(string, string) (*parser.Session, error) { return nil, nil } diff --git a/internal/provider/multi.go b/internal/provider/multi.go index 682a8a7..3b15c03 100644 --- a/internal/provider/multi.go +++ b/internal/provider/multi.go @@ -6,6 +6,7 @@ import ( "sort" "strings" + "github.com/thevibeworks/ccx/internal/catalog" "github.com/thevibeworks/ccx/internal/config" "github.com/thevibeworks/ccx/internal/parser" ) @@ -145,6 +146,46 @@ func (m *Multi) DiscoverWorkspaces() ([]*parser.Workspace, error) { return parser.GroupProjectsByWorkspace(projects), nil } +func (m *Multi) ListSessions(query catalog.SessionQuery) ([]*parser.Session, error) { + if query.Scope == catalog.ScopeProject { + project, err := m.FindProject(query.ProjectName) + if err != nil || project == nil { + return nil, err + } + projectQuery := query + projectQuery.Scope = catalog.ScopeAll + projectQuery.ProjectName = "" + return catalog.ApplySessionQuery([]*parser.Project{project}, projectQuery), nil + } + + var sessions []*parser.Session + for _, b := range m.backends { + if query.Filter.Provider != "" && query.Filter.Provider != b.ID() { + continue + } + backendQuery := query.WithoutProviderFilter().WithoutLimit() + backendSessions, err := b.ListSessions(backendQuery) + if err != nil { + return nil, err + } + sessions = append(sessions, backendSessions...) + } + if !query.Filter.IsEmpty() { + var filtered []*parser.Session + for _, session := range sessions { + if query.Filter.Match(session) { + filtered = append(filtered, session) + } + } + sessions = filtered + } + catalog.SortSessions(sessions, query.Sort) + if query.Limit > 0 && len(sessions) > query.Limit { + sessions = sessions[:query.Limit] + } + return sessions, nil +} + func (m *Multi) FindProject(name string) (*parser.Project, error) { projects, err := m.DiscoverProjects() if err != nil { diff --git a/internal/provider/multi_test.go b/internal/provider/multi_test.go index 174a654..499e6e9 100644 --- a/internal/provider/multi_test.go +++ b/internal/provider/multi_test.go @@ -5,6 +5,8 @@ import ( "testing" "time" + "github.com/thevibeworks/ccx/internal/catalog" + "github.com/thevibeworks/ccx/internal/config" "github.com/thevibeworks/ccx/internal/parser" ) @@ -22,6 +24,10 @@ func (m *mockBackend) DiscoverProjects() ([]*parser.Project, error) { return m.projects, nil } +func (m *mockBackend) ListSessions(query catalog.SessionQuery) ([]*parser.Session, error) { + return catalog.ApplySessionQuery(m.projects, query), nil +} + func (m *mockBackend) FindProject(name string) (*parser.Project, error) { for _, p := range m.projects { if p.Name == name || p.EncodedName == name { @@ -252,6 +258,170 @@ func TestMultiFindSessionFirstBackendWins(t *testing.T) { } } +func TestMultiListSessionsScopesCurrentWorkspaceAcrossProviders(t *testing.T) { + now := time.Now() + repo := "/home/user/src/repo" + other := "/home/user/src/other" + claudeSession := &parser.Session{ + ID: "cc-repo", + Provider: "claude-code", + CWD: repo, + EndTime: now.Add(-time.Minute), + } + codexSession := &parser.Session{ + ID: "cx-repo", + Provider: "codex", + CWD: repo, + EndTime: now, + } + otherSession := &parser.Session{ + ID: "cx-other", + Provider: "codex", + CWD: other, + EndTime: now.Add(time.Minute), + } + + m := NewMulti( + &mockBackend{ + id: "claude-code", + projects: []*parser.Project{{ + Name: "repo", + EncodedName: "-home-user-src-repo", + Path: repo, + Provider: "claude-code", + Sessions: []*parser.Session{claudeSession}, + LastModified: claudeSession.EndTime, + }}, + }, + &mockBackend{ + id: "codex", + projects: []*parser.Project{ + { + Name: "repo", + EncodedName: "home-user-src-repo", + Path: repo, + Provider: "codex", + Sessions: []*parser.Session{codexSession}, + LastModified: codexSession.EndTime, + }, + { + Name: "other", + EncodedName: "home-user-src-other", + Path: other, + Provider: "codex", + Sessions: []*parser.Session{otherSession}, + LastModified: otherSession.EndTime, + }, + }, + }, + ) + + got, err := m.ListSessions(catalog.SessionQuery{ + Scope: catalog.ScopeWorkspace, + WorkspacePath: repo, + Sort: catalog.SortTime, + }) + if err != nil { + t.Fatalf("ListSessions() error: %v", err) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].ID != "cx-repo" || got[1].ID != "cc-repo" { + t.Fatalf("got IDs [%s %s], want [cx-repo cc-repo]", got[0].ID, got[1].ID) + } +} + +func TestMultiListSessionsProviderFilterSelectsBackendBeforeDiscovery(t *testing.T) { + now := time.Now() + repo := "/home/user/src/repo" + m := NewMulti( + &mockBackend{ + id: "claude-code", + projects: []*parser.Project{{ + Name: "repo", + Path: repo, + Sessions: []*parser.Session{{ID: "cc-repo", Provider: "claude-code", CWD: repo, EndTime: now}}, + }}, + }, + &mockBackend{ + id: "codex", + projects: []*parser.Project{{ + Name: "repo", + Path: repo, + Sessions: []*parser.Session{{ID: "cx-repo", Provider: "codex", CWD: repo, EndTime: now.Add(time.Minute)}}, + }}, + }, + ) + + got, err := m.ListSessions(catalog.SessionQuery{ + Scope: catalog.ScopeWorkspace, + WorkspacePath: repo, + Filter: config.SessionFilter{Provider: "codex"}, + }) + if err != nil { + t.Fatalf("ListSessions() error: %v", err) + } + if len(got) != 1 || got[0].ID != "cx-repo" { + t.Fatalf("got %+v, want only codex session", got) + } +} + +func TestMultiListSessionsProjectScopeMatchesAlternateEncoding(t *testing.T) { + now := time.Now() + claudeSession := &parser.Session{ + ID: "aaaaaaaa-cc", + ProjectName: "-Users-eric-foo-bar", + CWD: "/Users/eric/foo_bar", + Provider: "claude-code", + EndTime: now, + } + codexSession := &parser.Session{ + ID: "019d8f43-cx", + ProjectName: "Users-eric-foo_bar", + CWD: "/Users/eric/foo_bar", + Provider: "codex", + EndTime: now.Add(-time.Minute), + } + + m := NewMulti( + &mockBackend{ + id: "claude-code", + projects: []*parser.Project{{ + Name: "foo_bar", + EncodedName: "-Users-eric-foo-bar", + Path: "/Users/eric/foo_bar", + Sessions: []*parser.Session{claudeSession}, + LastModified: claudeSession.EndTime, + }}, + }, + &mockBackend{ + id: "codex", + projects: []*parser.Project{{ + Name: "foo_bar", + EncodedName: "Users-eric-foo_bar", + Path: "/Users/eric/foo_bar", + Sessions: []*parser.Session{codexSession}, + LastModified: codexSession.EndTime, + }}, + }, + ) + + got, err := m.ListSessions(catalog.SessionQuery{ + Scope: catalog.ScopeProject, + ProjectName: "Users-eric-foo_bar", + }) + if err != nil { + t.Fatalf("ListSessions() error: %v", err) + } + if len(got) != 2 { + t.Fatalf("len = %d, want 2", len(got)) + } + if got[0].ID != "aaaaaaaa-cc" || got[1].ID != "019d8f43-cx" { + t.Fatalf("got IDs [%s %s], want [aaaaaaaa-cc 019d8f43-cx]", got[0].ID, got[1].ID) + } +} + func TestMultiFindSessionReturnsNilOnMiss(t *testing.T) { b := &mockBackend{sessions: map[string]*parser.Session{}} m := NewMulti(b) diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 9db791a..e7b1a16 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -3,6 +3,7 @@ package provider import ( "os" + "github.com/thevibeworks/ccx/internal/catalog" "github.com/thevibeworks/ccx/internal/config" "github.com/thevibeworks/ccx/internal/parser" "github.com/thevibeworks/ccx/internal/provider/claude" @@ -13,6 +14,7 @@ type Backend interface { ID() string Homes() []string DiscoverProjects() ([]*parser.Project, error) + ListSessions(query catalog.SessionQuery) ([]*parser.Session, error) FindProject(name string) (*parser.Project, error) FindSession(projectName, sessionID string) (*parser.Session, error) ParseSession(filePath string) (*parser.Session, error) diff --git a/internal/web/sidechain_filter_test.go b/internal/web/sidechain_filter_test.go index a562bf1..6670356 100644 --- a/internal/web/sidechain_filter_test.go +++ b/internal/web/sidechain_filter_test.go @@ -29,7 +29,21 @@ func buildSessionWithSidechains() *parser.Session { }}, }, } - mainUser.Children = []*parser.Message{mainAssistant} + mainToolResult := &parser.Message{ + UUID: "tr1", + Kind: parser.KindToolResult, + Timestamp: t0.Add(5 * time.Second), + Content: []parser.ContentBlock{{Type: "tool_result", ToolID: "t1", ToolResult: "done"}}, + SubAgentResult: &parser.SubAgentResultData{ + AgentID: "agent-abc", + AgentType: "Explore", + TotalTokens: 22351, + TotalToolUseCount: 12, + TotalDurationMs: 144629, + ToolStats: &parser.ToolStats{BashCount: 8, EditFileCount: 4, LinesAdded: 484}, + }, + } + mainUser.Children = []*parser.Message{mainAssistant, mainToolResult} scUser := &parser.Message{ UUID: "sc-u1", @@ -206,6 +220,107 @@ func TestRenderMessages_SidechainStatsPreserved(t *testing.T) { } } +func TestRenderMessages_SidechainShowsAgentTypeAndStats(t *testing.T) { + session := buildSessionWithSidechains() + var b strings.Builder + renderMessages(&b, session.RootMessages, 0, false, false, true) + html := b.String() + + if !strings.Contains(html, "Explore") { + t.Error("sidechain header should show agent type name 'Explore'") + } + if !strings.Contains(html, "find auth callers") { + t.Error("sidechain header should show description") + } + if !strings.Contains(html, "22.4k tokens") || !strings.Contains(html, "12 tools") { + t.Errorf("sidechain header should show stats from toolUseResult, got html containing sidechain-meta") + } + if !strings.Contains(html, "+484 lines") { + t.Error("sidechain header should show lines added from toolStats") + } +} + +func TestGroupSidechainsByAgent(t *testing.T) { + msgs := []*parser.Message{ + {UUID: "m1"}, + {UUID: "sc1", IsSidechain: true, AgentID: "a1"}, + {UUID: "sc2", IsSidechain: true, AgentID: "a1"}, + {UUID: "sc3", IsSidechain: true, AgentID: "a2"}, + {UUID: "m2"}, + {UUID: "sc4", IsSidechain: true, AgentID: "a2"}, + {UUID: "sc5", IsSidechain: true, AgentID: ""}, // no agentId — skip + } + groups := groupSidechainsByAgent(msgs) + if len(groups) != 2 { + t.Fatalf("want 2 groups, got %d", len(groups)) + } + if groups[0].AgentID != "a1" || len(groups[0].Messages) != 2 { + t.Errorf("group 0: agentID=%q msgs=%d, want a1/2", groups[0].AgentID, len(groups[0].Messages)) + } + if groups[1].AgentID != "a2" || len(groups[1].Messages) != 2 { + t.Errorf("group 1: agentID=%q msgs=%d, want a2/2", groups[1].AgentID, len(groups[1].Messages)) + } +} + +func TestMatchSidechainsToToolUse(t *testing.T) { + mainMsgs := []*parser.Message{ + {UUID: "u1", Kind: parser.KindUserPrompt}, + {UUID: "a1", Kind: parser.KindAssistant, Content: []parser.ContentBlock{ + {Type: "tool_use", ToolName: "Agent", ToolID: "t1", ToolInput: map[string]any{ + "subagent_type": "Explore", + "description": "search codebase", + }}, + {Type: "tool_use", ToolName: "Agent", ToolID: "t2", ToolInput: map[string]any{ + "subagent_type": "linus-rants", + "description": "review naming", + }}, + }}, + } + groups := []sidechainGroup{ + {AgentID: "agent-1", Messages: []*parser.Message{{UUID: "sc1"}}}, + {AgentID: "agent-2", Messages: []*parser.Message{{UUID: "sc2"}}}, + } + scMap := matchSidechainsToToolUse(mainMsgs, groups) + if len(scMap) != 2 { + t.Fatalf("want 2 matched groups, got %d", len(scMap)) + } + g1 := scMap["t1"] + if g1.AgentType != "Explore" { + t.Errorf("t1 AgentType = %q, want Explore", g1.AgentType) + } + if g1.Description != "search codebase" { + t.Errorf("t1 Description = %q, want 'search codebase'", g1.Description) + } + g2 := scMap["t2"] + if g2.AgentType != "linus-rants" { + t.Errorf("t2 AgentType = %q, want linus-rants", g2.AgentType) + } +} + +func TestMatchSidechainsToToolUse_NoGroups(t *testing.T) { + scMap := matchSidechainsToToolUse(nil, nil) + if scMap != nil { + t.Error("nil groups should return nil map") + } +} + +func TestMatchSidechainsToToolUse_MoreDispatchesThanGroups(t *testing.T) { + mainMsgs := []*parser.Message{ + {UUID: "a1", Kind: parser.KindAssistant, Content: []parser.ContentBlock{ + {Type: "tool_use", ToolName: "Agent", ToolID: "t1"}, + {Type: "tool_use", ToolName: "Agent", ToolID: "t2"}, + {Type: "tool_use", ToolName: "Agent", ToolID: "t3"}, + }}, + } + groups := []sidechainGroup{ + {AgentID: "a1", Messages: []*parser.Message{{UUID: "sc1"}}}, + } + scMap := matchSidechainsToToolUse(mainMsgs, groups) + if len(scMap) != 1 { + t.Errorf("should match only 1 group (not panic), got %d", len(scMap)) + } +} + func TestRenderMessages_MainOnlySession(t *testing.T) { t0 := time.Date(2026, 4, 15, 10, 0, 0, 0, time.UTC) user := &parser.Message{ diff --git a/internal/web/static/style.css b/internal/web/static/style.css index 0d4a689..f3b8682 100644 --- a/internal/web/static/style.css +++ b/internal/web/static/style.css @@ -1207,11 +1207,18 @@ body[data-ccx-provider="codex"] .cli-spinner { color: var(--accent-cx); } .nav-compact .nav-icon { color: #fa0; } .nav-sidechain { - border-left: 3px solid #86c; + background: color-mix(in srgb, var(--event-subagent) 8%, transparent); margin: 4px 0; - opacity: 0.85; + padding-left: 10px; + font-size: 0.85em; +} +.nav-sidechain .nav-icon { color: var(--event-subagent); } +.nav-sidechain .nav-count { + font-size: 0.8em; + color: var(--text-muted); + margin-left: auto; + font-variant-numeric: tabular-nums; } -.nav-sidechain .nav-icon { color: #86c; } .nav-user { padding-left: 8px; font-weight: 500; } .nav-user .nav-icon { color: var(--user-border); } @@ -1356,17 +1363,25 @@ body[data-ccx-provider="codex"] .cli-spinner { color: var(--accent-cx); } * - 120ms grace period on mouseleave forgives slight drift. */ .timeline-rail { position: fixed; - right: 14px; /* gutter for browser scrollbar + visual breathing room */ + right: 14px; top: 56px; bottom: 24px; - width: 24px; /* invisible hit target; visual notch strip is centered */ - padding: 14px 8px 14px 14px; /* extra left-side hit zone for forgiving entry */ + width: 24px; + padding: 14px 8px 14px 14px; box-sizing: content-box; z-index: 150; background: transparent; border: none; transition: width 0.18s ease, padding 0.18s ease; cursor: crosshair; + display: flex; + align-items: center; +} +.timeline-rail .timeline-spine { + position: relative; + width: 100%; + max-height: 100%; + height: calc(var(--tick-count, 100) * 12px); } .timeline-rail:hover { width: 52px; @@ -1374,11 +1389,6 @@ body[data-ccx-provider="codex"] .cli-spinner { color: var(--accent-cx); } } .timeline-rail.has-no-ticks { pointer-events: none; } -.timeline-rail .timeline-spine { - position: relative; - width: 100%; - height: 100%; -} /* Faint center guideline — the "ruler's edge" that ticks extend from */ .timeline-rail .timeline-spine::before { content: ''; @@ -1506,8 +1516,8 @@ body[data-ccx-provider="codex"] .cli-spinner { color: var(--accent-cx); } position: absolute; right: calc(100% + 3px); top: 50%; - width: 3px; - height: 3px; + width: 5px; + height: 5px; border-radius: 50%; opacity: 0; pointer-events: none; @@ -2069,36 +2079,39 @@ body.watching .tail-spinner { display: flex; align-items: center; gap: 8px; } .turn-agent { margin-left: 16px; padding-left: 12px; - border-left: 2px solid #86c; + padding-left: 12px; + border-left: 2px solid var(--event-subagent); opacity: 0.9; } .turn-agent .turn-header { background: transparent; } -.turn-agent .turn-icon { color: #86c; } -.turn-agent .turn-role { display: inline; color: #86c; } +.turn-agent .turn-icon { color: var(--event-subagent); } +.turn-agent .turn-role { display: inline; color: var(--event-subagent); } -/* Sub-agent transcript sections */ -.sidechain-sections { margin-top: 32px; } +/* Sub-agent transcript sections — inline after dispatching tool_use */ .sidechain-group { - margin: 12px 0; - border: 1px solid var(--border, #e5e7eb); - border-left: 3px solid #86c; - border-radius: 6px; + margin: 8px 0 12px; + background: color-mix(in srgb, var(--event-subagent) 5%, var(--bg)); + border: 1px solid color-mix(in srgb, var(--event-subagent) 20%, var(--border)); + border-radius: var(--radius); } .sidechain-header { - padding: 10px 14px; + padding: 8px 12px; cursor: pointer; - font-weight: 500; - color: var(--text-secondary, #6b7280); + font-size: 0.875rem; + color: var(--text-muted); + display: flex; + align-items: center; + gap: 4px; } -.sidechain-header:hover { background: var(--bg-hover, #f9fafb); } -.sidechain-icon { color: #86c; margin-right: 6px; } +.sidechain-header:hover { background: color-mix(in srgb, var(--event-subagent) 10%, var(--bg)); } +.sidechain-icon { color: var(--event-subagent); flex-shrink: 0; } .sidechain-meta { - font-size: 0.85em; - font-weight: 400; - color: var(--text-tertiary, #9ca3af); - margin-left: 8px; + font-size: 0.8em; + color: var(--text-muted); + margin-left: auto; + font-variant-numeric: tabular-nums; } -.sidechain-body { padding: 0 14px 14px; } +.sidechain-body { padding: 4px 12px 12px; } /* Compacted context - minimal separator */ .turn-compacted { diff --git a/internal/web/templates.go b/internal/web/templates.go index fea7268..4547161 100644 --- a/internal/web/templates.go +++ b/internal/web/templates.go @@ -835,27 +835,6 @@ func renderInlineSidechain(b *strings.Builder, g sidechainGroup, showThinking, s b.WriteString(``) } -func sidechainSnippet(msgs []*parser.Message) string { - for _, m := range msgs { - if m.Kind != parser.KindUserPrompt { - continue - } - for _, block := range m.Content { - if block.Type == "text" && block.Text != "" { - text := strings.TrimSpace(block.Text) - if idx := strings.Index(text, "\n"); idx > 0 { - text = text[:idx] - } - if len(text) > 80 { - text = text[:77] + "..." - } - return text - } - } - } - return "" -} - // renderThread renders a conversation thread anchored by a USER message func renderThread(b *strings.Builder, thread []*parser.Message, showThinking, showTools bool, toolResults map[string]parser.ContentBlock, scMap map[string]sidechainGroup) { if len(thread) == 0 { @@ -1978,10 +1957,6 @@ func renderConversationNav(b *strings.Builder, messages []*parser.Message) { allMsgs := filterMainConversation(flat) scGroups := groupSidechainsByAgent(flat) scMap := matchSidechainsToToolUse(allMsgs, scGroups) - var sidechains []sidechainGroup - for _, g := range scMap { - sidechains = append(sidechains, g) - } type navGroup struct { user *parser.Message @@ -2075,24 +2050,40 @@ func renderConversationNav(b *strings.Builder, messages []*parser.Message) { if total <= maxChildren { for _, child := range g.children { renderNavChild(b, child) + renderNavSidechainEntries(b, child, scMap) } } else { head := maxChildren - 1 for _, child := range g.children[:head] { renderNavChild(b, child) + renderNavSidechainEntries(b, child, scMap) } hidden := total - head - 1 if hidden > 0 { b.WriteString(fmt.Sprintf(`+%d more`, hidden)) } renderNavChild(b, g.children[total-1]) + renderNavSidechainEntries(b, g.children[total-1], scMap) } b.WriteString(``) // .nav-children b.WriteString(``) // .nav-group } } - for _, g := range sidechains { +} + +func renderNavSidechainEntries(b *strings.Builder, msg *parser.Message, scMap map[string]sidechainGroup) { + if msg == nil || len(scMap) == 0 { + return + } + for _, block := range msg.Content { + if block.Type != "tool_use" || !subagentToolSet[block.ToolName] { + continue + } + g, ok := scMap[block.ToolID] + if !ok { + continue + } label := g.AgentType if label == "" { label = "Agent" diff --git a/internal/web/timeline.go b/internal/web/timeline.go index 0fd7a71..420191f 100644 --- a/internal/web/timeline.go +++ b/internal/web/timeline.go @@ -412,7 +412,7 @@ func renderTimelineRail(b *strings.Builder, session *parser.Session) { } b.WriteString(`