From 0784876c9c167ca2dd35bf4c01e239d3ae885d8f Mon Sep 17 00:00:00 2001 From: Prateek Rungta Date: Tue, 30 Jun 2026 03:12:31 -0400 Subject: [PATCH 1/7] feat(filter): add branch filter foundation --- internal/db/analytics.go | 16 +- internal/db/branch_filter_test.go | 177 +++++++++++++++++++++++ internal/db/db.go | 2 + internal/db/query_dialect.go | 70 +++++++++ internal/db/query_dialect_test.go | 80 ++++++++++ internal/db/search_content.go | 4 +- internal/db/sessions.go | 56 ++++++- internal/db/store.go | 1 + internal/db/usage.go | 22 ++- internal/duckdb/analytics_usage.go | 15 +- internal/duckdb/store.go | 21 +++ internal/duckdb/store_test.go | 71 +++++++++ internal/postgres/analytics.go | 5 + internal/postgres/schema.go | 2 + internal/postgres/sessions.go | 39 +++++ internal/postgres/usage.go | 10 +- internal/server/activity_report_test.go | 43 ++++++ internal/server/huma_routes_activity.go | 22 +-- internal/server/huma_routes_analytics.go | 2 + internal/server/huma_routes_metadata.go | 16 ++ internal/server/huma_routes_search.go | 2 + internal/server/huma_routes_sessions.go | 3 + internal/server/huma_routes_usage.go | 2 + internal/service/direct.go | 2 + internal/service/http.go | 3 + internal/service/service.go | 3 + internal/service/usage.go | 2 + 27 files changed, 665 insertions(+), 26 deletions(-) create mode 100644 internal/db/branch_filter_test.go diff --git a/internal/db/analytics.go b/internal/db/analytics.go index f2e6a16e2..67471f515 100644 --- a/internal/db/analytics.go +++ b/internal/db/analytics.go @@ -87,10 +87,12 @@ func queryChunkedSize( // AnalyticsFilter is the shared filter for all analytics queries. type AnalyticsFilter struct { - From string // ISO date YYYY-MM-DD, inclusive - To string // ISO date YYYY-MM-DD, inclusive - Machine string // optional machine filter - Project string // optional project filter + From string // ISO date YYYY-MM-DD, inclusive + To string // ISO date YYYY-MM-DD, inclusive + Machine string // optional machine filter + Project string // optional project filter + // GitBranch is a branchListSep-joined list of opaque (project, branch) tokens (EncodeBranchFilterToken). + GitBranch string Agent string // optional agent filter Model string // optional model filter Timezone string // IANA timezone for day bucketing @@ -354,6 +356,12 @@ func (f AnalyticsFilter) buildWhereWithDate( args = append(args, f.Project) } + if f.GitBranch != "" { + var clause string + clause, args = BranchPairClauseArgs("project", "git_branch", f.GitBranch, args) + preds = append(preds, clause) + } + if f.Agent != "" { agents := csvFilterValues(f.Agent) if len(agents) == 1 { diff --git a/internal/db/branch_filter_test.go b/internal/db/branch_filter_test.go new file mode 100644 index 000000000..f7ad00f1f --- /dev/null +++ b/internal/db/branch_filter_test.go @@ -0,0 +1,177 @@ +package db + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetDailyUsageGitBranchFilter(t *testing.T) { + d := testDB(t) + ctx := context.Background() + + seed := []struct { + id, project, branch string + input, output int + }{ + {"a", "proj-a", "main", 100, 10}, + {"b", "proj-a", "feature-x", 200, 20}, + {"c", "proj-b", "main", 300, 30}, + {"d", "proj-a", "", 400, 40}, + {"e", "proj-a", "unknown", 500, 50}, + } + for _, s := range seed { + input, output := s.input, s.output + insertSession(t, d, s.id, s.project, func(sess *Session) { + sess.GitBranch = s.branch + sess.StartedAt = new("2026-05-14T10:00:00Z") + sess.UserMessageCount = 2 + }) + require.NoError(t, d.ReplaceSessionUsageEvents(s.id, []UsageEvent{{ + SessionID: s.id, + Source: "session", + Model: "gpt-5.4", + InputTokens: input, + OutputTokens: output, + DedupKey: s.id + "-key", + }}), "replace usage event for %s", s.id) + } + + daily, err := d.GetDailyUsage(ctx, UsageFilter{ + From: "2026-05-14", + To: "2026-05-14", + GitBranch: EncodeBranchFilterToken("proj-a", "main"), + }) + require.NoError(t, err, "GetDailyUsage") + require.Len(t, daily.Daily, 1, "one day") + assert.Equal(t, 100, daily.Daily[0].InputTokens, + "usage filter uses scoped (project, branch), not branch name alone") +} + +func TestSplitBranchFilterTokens(t *testing.T) { + tests := []struct { + name string + in string + want []BranchInfo + }{ + {"empty", "", []BranchInfo{}}, + { + name: "round trip single", + in: EncodeBranchFilterToken("alpha", "main"), + want: []BranchInfo{{Project: "alpha", Branch: "main"}}, + }, + { + name: "multiple", + in: encodeBranchFilterTokensForTest( + BranchInfo{Project: "alpha", Branch: "feat/x"}, + BranchInfo{Project: "beta", Branch: "main"}, + ), + want: []BranchInfo{ + {Project: "alpha", Branch: "feat/x"}, + {Project: "beta", Branch: "main"}, + }, + }, + { + name: "comma in branch name round-trips", + in: EncodeBranchFilterToken("proj", "wip,test"), + want: []BranchInfo{{Project: "proj", Branch: "wip,test"}}, + }, + { + name: "drops blank and separator-less tokens", + in: branchListSep + EncodeBranchFilterToken("alpha", "main") + branchListSep + "noseparator", + want: []BranchInfo{{Project: "alpha", Branch: "main"}}, + }, + { + name: "empty branch component survives", + in: EncodeBranchFilterToken("alpha", ""), + want: []BranchInfo{{Project: "alpha", Branch: ""}}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, SplitBranchFilterTokens(tt.in)) + }) + } +} + +func TestGetBranches(t *testing.T) { + d := testDB(t) + + insertSession(t, d, "s1", "alpha", func(s *Session) { + s.GitBranch = "main" + s.UserMessageCount = 5 + }) + insertSession(t, d, "s2", "alpha", func(s *Session) { + s.GitBranch = "feat/x" + s.UserMessageCount = 5 + }) + insertSession(t, d, "s3", "beta", func(s *Session) { + s.GitBranch = "main" + s.UserMessageCount = 5 + }) + insertSession(t, d, "s4", "alpha", func(s *Session) { + s.GitBranch = "" + s.UserMessageCount = 5 + }) + insertSession(t, d, "s5", "gamma", func(s *Session) { + s.GitBranch = "solo" + s.UserMessageCount = 1 + }) + + all, err := d.GetBranches(context.Background(), false, false) + require.NoError(t, err, "GetBranches includeAll") + assert.Equal(t, []BranchInfo{ + {Project: "alpha", Branch: "feat/x"}, + {Project: "alpha", Branch: "main"}, + {Project: "beta", Branch: "main"}, + {Project: "gamma", Branch: "solo"}, + }, all, "distinct (project, branch) pairs, ordered, empty excluded") + + filtered, err := d.GetBranches(context.Background(), true, false) + require.NoError(t, err, "GetBranches excludeOneShot") + assert.NotContains(t, filtered, BranchInfo{Project: "gamma", Branch: "solo"}, + "one-shot branch excluded when excludeOneShot is set") +} + +func TestSessionFilterGitBranchComposite(t *testing.T) { + d := testDB(t) + + insertSession(t, d, "alpha-main", "alpha", func(s *Session) { + s.GitBranch = "main" + }) + insertSession(t, d, "alpha-feat", "alpha", func(s *Session) { + s.GitBranch = "feat/x" + }) + insertSession(t, d, "beta-main", "beta", func(s *Session) { + s.GitBranch = "main" + }) + insertSession(t, d, "alpha-empty", "alpha", func(s *Session) { + s.GitBranch = "" + }) + insertSession(t, d, "alpha-unknown", "alpha", func(s *Session) { + s.GitBranch = "unknown" + }) + + // Filtering by (alpha, main) must not match (beta, main): the grain is + // (project, branch), so same-named branches across projects stay distinct. + requireSessions(t, d, SessionFilter{ + GitBranch: EncodeBranchFilterToken("alpha", "main"), + }, []string{"alpha-main"}) + + requireSessions(t, d, SessionFilter{ + GitBranch: encodeBranchFilterTokensForTest( + BranchInfo{Project: "alpha", Branch: "feat/x"}, + BranchInfo{Project: "beta", Branch: "main"}, + ), + }, []string{"alpha-feat", "beta-main"}) + + requireSessions(t, d, SessionFilter{ + GitBranch: EncodeBranchFilterToken("alpha", ""), + }, []string{"alpha-empty"}) + + requireSessions(t, d, SessionFilter{ + GitBranch: EncodeBranchFilterToken("alpha", "unknown"), + }, []string{"alpha-unknown"}) +} diff --git a/internal/db/db.go b/internal/db/db.go index f4fd1cf38..fc959cbc6 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -1419,6 +1419,8 @@ func (db *DB) createPartialIndexesLocked(w *writerHandle) error { indexes := []string{ `CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd) WHERE cwd != ''`, + `CREATE INDEX IF NOT EXISTS idx_sessions_project_git_branch + ON sessions(project, git_branch) WHERE git_branch != ''`, `CREATE INDEX IF NOT EXISTS idx_messages_compact_boundary ON messages(session_id, ordinal) WHERE is_compact_boundary = 1`, `CREATE INDEX IF NOT EXISTS idx_messages_sidechain diff --git a/internal/db/query_dialect.go b/internal/db/query_dialect.go index 4a69545f6..9be657d59 100644 --- a/internal/db/query_dialect.go +++ b/internal/db/query_dialect.go @@ -483,6 +483,11 @@ func sessionFilterPredicates( preds = append(preds, inPredicate(q("machine"), splitCSV(f.Machine), b)) } + if f.GitBranch != "" { + preds = append(preds, BranchPairPredicate( + q("project"), q("git_branch"), f.GitBranch, + func(s string) string { return b.Add(s) })) + } if f.Agent != "" { preds = append(preds, inPredicate(q("agent"), splitCSV(f.Agent), b)) @@ -606,6 +611,71 @@ func splitCSV(s string) []string { return out } +// The separators are unit/record separators so comma-delimited filters can +// carry project or branch names containing commas. +const ( + branchFilterSep = "\x1f" + branchListSep = "\x1e" +) + +// EncodeBranchFilterToken builds the opaque (project, branch) filter token. +// Keying by (project, branch) keeps same-named branches across repos distinct; +// the frontend passes the token back verbatim. +func EncodeBranchFilterToken(project, branch string) string { + return project + branchFilterSep + branch +} + +// SplitBranchFilterTokens decodes a branchListSep-joined list of +// EncodeBranchFilterToken values into (project, branch) pairs, dropping blank or +// separator-less tokens. Shared across backends so they decode identically. +func SplitBranchFilterTokens(s string) []BranchInfo { + parts := strings.Split(s, branchListSep) + out := make([]BranchInfo, 0, len(parts)) + for _, p := range parts { + project, branch, ok := strings.Cut(p, branchFilterSep) + if !ok { + continue + } + out = append(out, BranchInfo{Project: project, Branch: branch}) + } + return out +} + +// BranchPairPredicate uses OR-of-ANDs instead of row-value IN for backend +// portability. An empty decoded pair set returns false so invalid filters do +// not broaden to all rows. +func BranchPairPredicate( + projectCol, branchCol, tokens string, placeholder func(string) string, +) string { + pairs := SplitBranchFilterTokens(tokens) + if len(pairs) == 0 { + return "1 = 0" + } + parts := make([]string, len(pairs)) + for i, p := range pairs { + parts[i] = "(" + projectCol + " = " + placeholder(p.Project) + + " AND " + branchCol + " = " + placeholder(p.Branch) + ")" + } + if len(parts) == 1 { + return parts[0] + } + return "(" + strings.Join(parts, " OR ") + ")" +} + +// BranchPairClauseArgs is the raw-args ("?" placeholder) form of +// BranchPairPredicate. +func BranchPairClauseArgs( + projectCol, branchCol, tokens string, args []any, +) (string, []any) { + clause := BranchPairPredicate( + projectCol, branchCol, tokens, + func(v string) string { + args = append(args, v) + return "?" + }) + return clause, args +} + func nonEmpty(values []string) []string { out := make([]string, 0, len(values)) for _, v := range values { diff --git a/internal/db/query_dialect_test.go b/internal/db/query_dialect_test.go index 60356914c..346782e80 100644 --- a/internal/db/query_dialect_test.go +++ b/internal/db/query_dialect_test.go @@ -9,6 +9,15 @@ import ( "github.com/stretchr/testify/require" ) +func encodeBranchFilterTokensForTest(branches ...BranchInfo) string { + tokens := make([]string, 0, len(branches)) + for _, branch := range branches { + tokens = append(tokens, + EncodeBranchFilterToken(branch.Project, branch.Branch)) + } + return strings.Join(tokens, branchListSep) +} + func TestBuildSessionFilterSQLRendersEquivalentDialectFilters(t *testing.T) { minToolFailures := 2 filter := SessionFilter{ @@ -189,6 +198,77 @@ func TestBuildSessionFilterSQLHandlesEmptyCSVFilters(t *testing.T) { assert.Empty(t, args) } +func TestBuildSessionFilterSQLRendersBranchPairs(t *testing.T) { + filter := SessionFilter{ + Machine: "laptop", + GitBranch: encodeBranchFilterTokensForTest( + BranchInfo{Project: "alpha", Branch: ""}, + BranchInfo{Project: "alpha", Branch: "unknown"}, + ), + Agent: "claude", + } + + tests := []struct { + name string + dialect QueryDialect + wantParts []string + }{ + { + name: "sqlite", + dialect: SQLiteQueryDialect(), + wantParts: []string{ + "machine = ?", + "((project = ? AND git_branch = ?) OR (project = ? AND git_branch = ?))", + "agent = ?", + }, + }, + { + name: "postgres", + dialect: PostgresQueryDialect(), + wantParts: []string{ + "machine = $1", + "((project = $2 AND git_branch = $3) OR (project = $4 AND git_branch = $5))", + "agent = $6", + }, + }, + { + name: "duckdb", + dialect: DuckDBQueryDialect(), + wantParts: []string{ + "machine = ?", + "((project = ? AND git_branch = ?) OR (project = ? AND git_branch = ?))", + "agent = ?", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, args := BuildSessionFilterSQL(filter, tt.dialect) + normalized := normalizeSQL(got) + + for _, part := range tt.wantParts { + assert.Contains(t, normalized, normalizeSQL(part)) + } + assert.Equal(t, []any{"laptop", "alpha", "", "alpha", "unknown", "claude"}, args) + }) + } +} + +func TestBranchPairClauseArgsKeepsEmptyBranchDistinct(t *testing.T) { + tokens := encodeBranchFilterTokensForTest( + BranchInfo{Project: "alpha", Branch: ""}, + BranchInfo{Project: "alpha", Branch: "unknown"}, + ) + + got, args := BranchPairClauseArgs("project", "git_branch", tokens, nil) + + assert.Equal(t, + "((project = ? AND git_branch = ?) OR (project = ? AND git_branch = ?))", + normalizeSQL(got)) + assert.Equal(t, []any{"alpha", "", "alpha", "unknown"}, args) +} + func TestSessionCursorFragmentsAreParameterized(t *testing.T) { cursor := SessionCursor{ EndedAt: "2026-06-08T12:00:00Z", diff --git a/internal/db/search_content.go b/internal/db/search_content.go index f3324a6da..dc9265f52 100644 --- a/internal/db/search_content.go +++ b/internal/db/search_content.go @@ -34,6 +34,8 @@ type ContentSearchFilter struct { Project, ExcludeProject, Machine, Agent string Date, DateFrom, DateTo, ActiveSince string IncludeChildren, IncludeAutomated, IncludeOneShot bool + // GitBranch is a branchListSep-joined list of opaque (project, branch) tokens (EncodeBranchFilterToken). + GitBranch string // RevealSecrets returns raw snippets. It defaults false so snippets are // secret-redacted unless a caller (the localhost-gated reveal path) @@ -88,7 +90,7 @@ func sessionScopeSubquery(f ContentSearchFilter) (string, []any) { // (scanned over every session at sync), not from search defaults. sf := SessionFilter{ Project: f.Project, ExcludeProject: f.ExcludeProject, - Machine: f.Machine, Agent: f.Agent, + Machine: f.Machine, GitBranch: f.GitBranch, Agent: f.Agent, Date: f.Date, DateFrom: f.DateFrom, DateTo: f.DateTo, ActiveSince: f.ActiveSince, ExcludeOneShot: !f.IncludeOneShot, diff --git a/internal/db/sessions.go b/internal/db/sessions.go index c988ee329..1f5a4ac1c 100644 --- a/internal/db/sessions.go +++ b/internal/db/sessions.go @@ -446,9 +446,11 @@ func (db *DB) DecodeCursor(s string) (SessionCursor, error) { // SessionFilter specifies how to query sessions. type SessionFilter struct { - Project string - ExcludeProject string // exclude sessions with this project name - Machine string + Project string + ExcludeProject string // exclude sessions with this project name + Machine string + // GitBranch is a branchListSep-joined list of opaque (project, branch) tokens (EncodeBranchFilterToken). + GitBranch string Agent string Date string // exact date YYYY-MM-DD DateFrom string // range start (inclusive) @@ -2417,6 +2419,54 @@ func (db *DB) GetMachines( return machines, rows.Err() } +// BranchInfo is a (project, branch) pair, keyed by project so same-named +// branches across repos stay distinct. +type BranchInfo struct { + Project string `json:"project"` + Branch string `json:"branch"` +} + +// GetBranches returns distinct (project, git_branch) pairs for sessions with a +// recorded branch. Scoping matches GetProjects/GetAgents (root sessions with +// messages) so the dropdown reflects real work rather than subagents. +func (db *DB) GetBranches( + ctx context.Context, + excludeOneShot, excludeAutomated bool, +) ([]BranchInfo, error) { + q := `SELECT DISTINCT project, git_branch + FROM sessions + WHERE message_count > 0 + AND relationship_type NOT IN ('subagent', 'fork') + AND deleted_at IS NULL + AND git_branch != ''` + if excludeOneShot { + if !excludeAutomated { + q += " AND (user_message_count > 1 OR is_automated = 1)" + } else { + q += " AND user_message_count > 1" + } + } + if excludeAutomated { + q += " AND is_automated = 0" + } + q += " ORDER BY project, git_branch" + rows, err := db.getReader().QueryContext(ctx, q) + if err != nil { + return nil, fmt.Errorf("querying branches: %w", err) + } + defer rows.Close() + + branches := []BranchInfo{} + for rows.Next() { + var bi BranchInfo + if err := rows.Scan(&bi.Project, &bi.Branch); err != nil { + return nil, fmt.Errorf("scanning branch: %w", err) + } + branches = append(branches, bi) + } + return branches, rows.Err() +} + // scanSessionRows iterates rows and scans each using // scanSessionRow. func scanSessionRows(rows *sql.Rows) ([]Session, error) { diff --git a/internal/db/store.go b/internal/db/store.go index 9e90746a2..c1c431ebf 100644 --- a/internal/db/store.go +++ b/internal/db/store.go @@ -56,6 +56,7 @@ type Store interface { GetProjects(ctx context.Context, excludeOneShot, excludeAutomated bool) ([]ProjectInfo, error) GetAgents(ctx context.Context, excludeOneShot, excludeAutomated bool) ([]AgentInfo, error) GetMachines(ctx context.Context, excludeOneShot, excludeAutomated bool) ([]string, error) + GetBranches(ctx context.Context, excludeOneShot, excludeAutomated bool) ([]BranchInfo, error) // Analytics. GetAnalyticsSummary(ctx context.Context, f AnalyticsFilter) (AnalyticsSummary, error) diff --git a/internal/db/usage.go b/internal/db/usage.go index 394359159..655950d24 100644 --- a/internal/db/usage.go +++ b/internal/db/usage.go @@ -58,11 +58,13 @@ func (r *modelRateResolver) lookup(model string) (modelRates, bool) { // UsageFilter controls the date range, agent, and timezone // for daily usage aggregation queries. type UsageFilter struct { - From string // YYYY-MM-DD, inclusive - To string // YYYY-MM-DD, inclusive - Agent string // "" for all; supports comma-separated - Project string // "" for all; supports comma-separated - Machine string // "" for all; supports comma-separated + From string // YYYY-MM-DD, inclusive + To string // YYYY-MM-DD, inclusive + Agent string // "" for all; supports comma-separated + Project string // "" for all; supports comma-separated + Machine string // "" for all; supports comma-separated + // GitBranch is a branchListSep-joined list of opaque (project, branch) tokens (EncodeBranchFilterToken). + GitBranch string Model string // "" for all; supports comma-separated ExcludeProject string // comma-separated projects to exclude ExcludeAgent string // comma-separated agents to exclude @@ -160,6 +162,11 @@ func (f UsageFilter) appendUsageSessionFilterClauses( where, args = appendCSV(where, args, "s.agent", f.Agent, true) where, args = appendCSV(where, args, "s.project", f.Project, true) where, args = appendCSV(where, args, "s.machine", f.Machine, true) + if f.GitBranch != "" { + var clause string + clause, args = BranchPairClauseArgs("s.project", "s.git_branch", f.GitBranch, args) + where += "\n\tAND " + clause + } where, args = appendCSV(where, args, "s.project", f.ExcludeProject, false) where, args = appendCSV(where, args, "s.agent", f.ExcludeAgent, false) @@ -811,8 +818,11 @@ func cursorUsageRowsSQLForBounds( f UsageFilter, b usageBounds, ) (string, []any, bool) { termPred, _ := buildUsageTerminationPredSQLite(f.Termination) + // Cursor usage rows carry no project or git branch and bypass the session + // filter, so any filter they cannot satisfy (project, machine, branch) + // must exclude them entirely rather than let them leak into totals. if f.Project != "" || f.ExcludeProject != "" || - f.Machine != "" || f.MinUserMessages > 0 || + f.Machine != "" || f.GitBranch != "" || f.MinUserMessages > 0 || f.ExcludeOneShot || termPred != "" || f.ActiveSince != "" { return "", nil, false diff --git a/internal/duckdb/analytics_usage.go b/internal/duckdb/analytics_usage.go index fcd743b08..2f7f5db05 100644 --- a/internal/duckdb/analytics_usage.go +++ b/internal/duckdb/analytics_usage.go @@ -210,6 +210,11 @@ func duckBuildAnalyticsWhere( preds = append(preds, q("project")+" = ?") args = append(args, f.Project) } + if f.GitBranch != "" { + var clause string + clause, args = db.BranchPairClauseArgs(q("project"), q("git_branch"), f.GitBranch, args) + preds = append(preds, clause) + } if f.Agent != "" { preds, args = appendDuckAnalyticsCSVFilter(preds, args, q("agent"), f.Agent) } @@ -3031,6 +3036,11 @@ func appendDuckUsageSessionFilterClauses( where, args = appendDuckUsageCSVFilter(where, args, "s.agent", f.Agent, true) where, args = appendDuckUsageCSVFilter(where, args, "s.project", f.Project, true) where, args = appendDuckUsageCSVFilter(where, args, "s.machine", f.Machine, true) + if f.GitBranch != "" { + var clause string + clause, args = db.BranchPairClauseArgs("s.project", "s.git_branch", f.GitBranch, args) + where += "\n\t\t\tAND " + clause + } where, args = appendDuckUsageCSVFilter(where, args, "s.project", f.ExcludeProject, false) where, args = appendDuckUsageCSVFilter(where, args, "s.agent", f.ExcludeAgent, false) if sessionID != "" { @@ -3178,8 +3188,11 @@ func duckCursorUsageRowsSQLForBounds( f db.UsageFilter, b duckUsageBounds, ) (string, []any, bool) { hasTermFilter := f.Termination != "" && f.Termination != "all" + // Cursor usage rows carry no project or git branch and bypass the session + // filter, so any filter they cannot satisfy (project, machine, branch) + // must exclude them entirely rather than let them leak into totals. if f.Project != "" || f.ExcludeProject != "" || - f.Machine != "" || f.MinUserMessages > 0 || + f.Machine != "" || f.GitBranch != "" || f.MinUserMessages > 0 || f.ExcludeOneShot || hasTermFilter || f.ActiveSince != "" { return "", nil, false diff --git a/internal/duckdb/store.go b/internal/duckdb/store.go index 83e7a2662..25d05688e 100644 --- a/internal/duckdb/store.go +++ b/internal/duckdb/store.go @@ -514,6 +514,27 @@ func (s *Store) GetMachines(ctx context.Context, excludeOneShot, excludeAutomate return out, rows.Err() } +func (s *Store) GetBranches(ctx context.Context, excludeOneShot, excludeAutomated bool) ([]db.BranchInfo, error) { + rows, err := s.duck.QueryContext(ctx, + `SELECT DISTINCT project, git_branch FROM sessions WHERE `+ + rootSessionWhere(excludeOneShot, excludeAutomated)+ + ` AND git_branch != '' ORDER BY project, git_branch`, + ) + if err != nil { + return nil, fmt.Errorf("querying duckdb branches: %w", err) + } + defer rows.Close() + out := []db.BranchInfo{} + for rows.Next() { + var bi db.BranchInfo + if err := rows.Scan(&bi.Project, &bi.Branch); err != nil { + return nil, fmt.Errorf("scanning duckdb branch: %w", err) + } + out = append(out, bi) + } + return out, rows.Err() +} + func rootSessionWhere(excludeOneShot, excludeAutomated bool) string { filter := `message_count > 0 AND relationship_type NOT IN ('subagent', 'fork') diff --git a/internal/duckdb/store_test.go b/internal/duckdb/store_test.go index 960782093..eed3ed6bd 100644 --- a/internal/duckdb/store_test.go +++ b/internal/duckdb/store_test.go @@ -2309,3 +2309,74 @@ func newSyncedStore(t *testing.T) (*Store, syncFixture) { require.NoError(t, err) return NewStoreFromDB(syncer.DB()), fixture } + +func TestDuckDBBranchDimension(t *testing.T) { + ctx := context.Background() + local := newLocalDB(t) + require.NoError(t, local.UpsertModelPricing([]db.ModelPricing{{ + ModelPattern: "claude-test", InputPerMTok: 3, OutputPerMTok: 15, + }})) + + seed := []struct { + id, project, branch string + input, output int + }{ + {"d-a", "alpha", "main", 100, 10}, + {"d-b", "alpha", "feature-x", 200, 20}, + {"d-c", "beta", "main", 300, 30}, + {"d-d", "alpha", "", 400, 40}, + {"d-e", "alpha", "unknown", 500, 50}, + } + var writes []db.SessionBatchWrite + for _, s := range seed { + sess := syncSession(s.id, s.project, s.id+" first", "2026-02-01T12:00:00.000Z", 1) + sess.GitBranch = s.branch + writes = append(writes, db.SessionBatchWrite{ + Session: sess, + // A token-free user message so only the usage event below feeds the + // usage totals (syncMessage would inject a stray input token). + Messages: []db.Message{{ + SessionID: s.id, + Ordinal: 0, + Role: "user", + Content: s.id + " first", + Timestamp: "2026-02-01T12:00:00.000Z", + ContentLength: len(s.id + " first"), + }}, + UsageEvents: []db.UsageEvent{{ + Source: "session", Model: "claude-test", + InputTokens: s.input, OutputTokens: s.output, + OccurredAt: "2026-02-01T12:01:00.000Z", DedupKey: s.id + "-usage", + }}, + DataVersion: 1, + ReplaceMessages: true, + }) + } + _, err := local.WriteSessionBatchAtomic(writes) + require.NoError(t, err) + + syncer := newInMemoryTestSync(t, local, SyncOptions{}) + _, err = syncer.Push(ctx, true, nil) + require.NoError(t, err) + store := NewStoreFromDB(syncer.DB()) + + branches, err := store.GetBranches(ctx, false, false) + require.NoError(t, err) + assert.Equal(t, []db.BranchInfo{ + {Project: "alpha", Branch: "feature-x"}, + {Project: "alpha", Branch: "main"}, + {Project: "alpha", Branch: "unknown"}, + {Project: "beta", Branch: "main"}, + }, branches) + + filtered, err := store.GetDailyUsage(ctx, db.UsageFilter{ + From: "2026-01-01", To: "2026-12-31", + GitBranch: db.EncodeBranchFilterToken("alpha", "main"), + }) + require.NoError(t, err) + total := 0 + for _, day := range filtered.Daily { + total += day.InputTokens + } + assert.Equal(t, 100, total, "branch filter restricts usage to alpha/main") +} diff --git a/internal/postgres/analytics.go b/internal/postgres/analytics.go index 9b6821061..2509adb59 100644 --- a/internal/postgres/analytics.go +++ b/internal/postgres/analytics.go @@ -148,6 +148,11 @@ func buildAnalyticsWhereWithDate( preds = append(preds, "project = "+pb.add(f.Project)) } + if f.GitBranch != "" { + preds = append(preds, db.BranchPairPredicate( + "project", "git_branch", f.GitBranch, + func(s string) string { return pb.add(s) })) + } if f.Agent != "" { preds = appendPGAnalyticsCSVFilter( preds, "agent", f.Agent, pb) diff --git a/internal/postgres/schema.go b/internal/postgres/schema.go index 4cc5407ce..524cf82ef 100644 --- a/internal/postgres/schema.go +++ b/internal/postgres/schema.go @@ -828,6 +828,8 @@ func createPartialIndexesPG(ctx context.Context, db *sql.DB) error { indexes := []string{ `CREATE INDEX IF NOT EXISTS idx_sessions_cwd ON sessions(cwd) WHERE cwd != ''`, + `CREATE INDEX IF NOT EXISTS idx_sessions_project_git_branch + ON sessions(project, git_branch) WHERE git_branch != ''`, `CREATE INDEX IF NOT EXISTS idx_messages_compact_boundary ON messages(session_id, ordinal) WHERE is_compact_boundary = TRUE`, `CREATE INDEX IF NOT EXISTS idx_messages_sidechain diff --git a/internal/postgres/sessions.go b/internal/postgres/sessions.go index b677e2de4..9254a07d3 100644 --- a/internal/postgres/sessions.go +++ b/internal/postgres/sessions.go @@ -1066,3 +1066,42 @@ func (s *Store) GetMachines( } return machines, rows.Err() } + +// GetBranches mirrors db.DB.GetBranches: distinct (project, branch) pairs scoped +// to root sessions with messages, matching GetProjects/GetAgents. +func (s *Store) GetBranches( + ctx context.Context, + excludeOneShot, excludeAutomated bool, +) ([]db.BranchInfo, error) { + q := `SELECT DISTINCT project, git_branch FROM sessions + WHERE message_count > 0 + AND relationship_type NOT IN ('subagent', 'fork') + AND deleted_at IS NULL + AND git_branch != ''` + if excludeOneShot { + if !excludeAutomated { + q += " AND (user_message_count > 1 OR is_automated = TRUE)" + } else { + q += " AND user_message_count > 1" + } + } + if excludeAutomated { + q += " AND is_automated = FALSE" + } + q += " ORDER BY project, git_branch" + rows, err := s.pg.QueryContext(ctx, q) + if err != nil { + return nil, fmt.Errorf("querying branches: %w", err) + } + defer rows.Close() + + branches := []db.BranchInfo{} + for rows.Next() { + var bi db.BranchInfo + if err := rows.Scan(&bi.Project, &bi.Branch); err != nil { + return nil, fmt.Errorf("scanning branch: %w", err) + } + branches = append(branches, bi) + } + return branches, rows.Err() +} diff --git a/internal/postgres/usage.go b/internal/postgres/usage.go index 2436b873e..e109ba63f 100644 --- a/internal/postgres/usage.go +++ b/internal/postgres/usage.go @@ -120,6 +120,11 @@ func appendPGUsageSessionFilterClauses( where = appendCSV(where, "s.agent", f.Agent, true) where = appendCSV(where, "s.project", f.Project, true) where = appendCSV(where, "s.machine", f.Machine, true) + if f.GitBranch != "" { + where += "\n\tAND " + db.BranchPairPredicate( + "s.project", "s.git_branch", f.GitBranch, + func(s string) string { return pb.add(s) }) + } where = appendCSV(where, "s.project", f.ExcludeProject, false) where = appendCSV(where, "s.agent", f.ExcludeAgent, false) @@ -692,8 +697,11 @@ func pgCursorUsageRowsSQLForBounds( pb *paramBuilder, f db.UsageFilter, b pgUsageBounds, ) (string, bool) { hasTermFilter := f.Termination != "" && f.Termination != "all" + // Cursor usage rows carry no project or git branch and bypass the session + // filter, so any filter they cannot satisfy (project, machine, branch) + // must exclude them entirely rather than let them leak into totals. if f.Project != "" || f.ExcludeProject != "" || - f.Machine != "" || f.MinUserMessages > 0 || + f.Machine != "" || f.GitBranch != "" || f.MinUserMessages > 0 || f.ExcludeOneShot || hasTermFilter || f.ActiveSince != "" { return "", false } diff --git a/internal/server/activity_report_test.go b/internal/server/activity_report_test.go index 8e4c72af3..220a04897 100644 --- a/internal/server/activity_report_test.go +++ b/internal/server/activity_report_test.go @@ -328,3 +328,46 @@ func TestActivityReportEndpoint_BadAutomation(t *testing.T) { })) assertStatus(t, w, http.StatusBadRequest) } + +// TestActivityReportEndpoint_GitBranchFilter guards that /activity/report honors +// the git_branch filter (it previously ignored the param). +func TestActivityReportEndpoint_GitBranchFilter(t *testing.T) { + te := setup(t) + seed := []struct { + id, branch, started, ended string + times []string + }{ + {"b1", "main", activityDate + "T10:00:00Z", activityDate + "T10:08:00Z", + []string{activityDate + "T10:00:00Z", activityDate + "T10:02:00Z", + activityDate + "T10:05:00Z", activityDate + "T10:07:00Z"}}, + {"b2", "feature-x", activityDate + "T10:01:00Z", activityDate + "T10:09:00Z", + []string{activityDate + "T10:01:00Z", activityDate + "T10:03:00Z", + activityDate + "T10:06:00Z", activityDate + "T10:08:00Z"}}, + } + for _, e := range seed { + started, ended, branch := e.started, e.ended, e.branch + te.seedSession(t, e.id, "alpha", len(e.times), func(s *db.Session) { + s.GitBranch = branch + s.StartedAt = &started + s.EndedAt = &ended + }) + times := e.times + te.seedMessages(t, e.id, len(times), func(i int, m *db.Message) { + m.Timestamp = times[i] + }) + } + + all := te.get(t, buildPathURL("/api/v1/activity/report", map[string]string{ + "preset": "day", "date": activityDate, "timezone": "UTC", + })) + assertStatus(t, all, http.StatusOK) + assert.Equal(t, 2, decode[activity.Report](t, all).Totals.Sessions) + + filtered := te.get(t, buildPathURL("/api/v1/activity/report", map[string]string{ + "preset": "day", "date": activityDate, "timezone": "UTC", + "git_branch": db.EncodeBranchFilterToken("alpha", "main"), + })) + assertStatus(t, filtered, http.StatusOK) + assert.Equal(t, 1, decode[activity.Report](t, filtered).Totals.Sessions, + "git_branch filter restricts the activity report to alpha/main") +} diff --git a/internal/server/huma_routes_activity.go b/internal/server/huma_routes_activity.go index 4725af96b..c688985ad 100644 --- a/internal/server/huma_routes_activity.go +++ b/internal/server/huma_routes_activity.go @@ -16,15 +16,16 @@ func (s *Server) registerActivityRoutes() { } type activityReportInput struct { - Preset string `query:"preset" enum:"day,week,month,custom" doc:"Range preset"` - Date string `query:"date" format:"date" doc:"Calendar day (YYYY-MM-DD) for presets"` - From string `query:"from" doc:"Range start (RFC3339) for custom ranges"` - To string `query:"to" doc:"Range end (RFC3339) for custom ranges"` - Timezone string `query:"timezone" doc:"IANA timezone name"` - Bucket string `query:"bucket" enum:"5m,15m,1h,1d,1w" doc:"Timeline bucket size override"` - Project string `query:"project" doc:"Filter by project"` - Agent string `query:"agent" doc:"Filter by agent"` - Machine string `query:"machine" doc:"Filter by machine"` + Preset string `query:"preset" enum:"day,week,month,custom" doc:"Range preset"` + Date string `query:"date" format:"date" doc:"Calendar day (YYYY-MM-DD) for presets"` + From string `query:"from" doc:"Range start (RFC3339) for custom ranges"` + To string `query:"to" doc:"Range end (RFC3339) for custom ranges"` + Timezone string `query:"timezone" doc:"IANA timezone name"` + Bucket string `query:"bucket" enum:"5m,15m,1h,1d,1w" doc:"Timeline bucket size override"` + Project string `query:"project" doc:"Filter by project"` + GitBranch string `query:"git_branch" doc:"Filter by git branch; opaque (project, branch) tokens from the /branches endpoint"` + Agent string `query:"agent" doc:"Filter by agent"` + Machine string `query:"machine" doc:"Filter by machine"` // Automation classes the report: "all" (default) keeps both, "interactive" // drops automated sessions, "automated" drops interactive ones. Empty is // treated as "all"; any other value is rejected. @@ -63,7 +64,8 @@ func (s *Server) humaActivityReport( // analytics which excludes them by default. The automation class is the // caller's choice (default "all" keeps both automated and interactive). f := db.AnalyticsFilter{ - Timezone: tz, Project: in.Project, Agent: in.Agent, Machine: in.Machine, + Timezone: tz, Project: in.Project, GitBranch: in.GitBranch, + Agent: in.Agent, Machine: in.Machine, ExcludeOneShot: false, ExcludeAutomated: excludeAutomated, ExcludeInteractive: excludeInteractive, diff --git a/internal/server/huma_routes_analytics.go b/internal/server/huma_routes_analytics.go index 60f3d6493..bc3bb1e26 100644 --- a/internal/server/huma_routes_analytics.go +++ b/internal/server/huma_routes_analytics.go @@ -39,6 +39,7 @@ type AnalyticsFilterInput struct { Timezone string `query:"timezone" doc:"IANA timezone name"` Machine string `query:"machine" doc:"Filter by machine"` Project string `query:"project" doc:"Filter by project"` + GitBranch string `query:"git_branch" doc:"Filter by git branch; opaque (project, branch) tokens from the /branches endpoint"` Agent string `query:"agent" doc:"Filter by agent"` Model string `query:"model" doc:"Comma-separated model filter"` DayOfWeek optionalIntParam `query:"dow" minimum:"0" maximum:"6" doc:"Day of week, Monday=0 through Sunday=6"` @@ -95,6 +96,7 @@ func analyticsFilterFromInput(in AnalyticsFilterInput) (db.AnalyticsFilter, erro To: to, Machine: in.Machine, Project: in.Project, + GitBranch: in.GitBranch, Agent: in.Agent, Model: in.Model, Timezone: tz, diff --git a/internal/server/huma_routes_metadata.go b/internal/server/huma_routes_metadata.go index 31163ff8a..d406285b4 100644 --- a/internal/server/huma_routes_metadata.go +++ b/internal/server/huma_routes_metadata.go @@ -12,6 +12,7 @@ func (s *Server) registerMetadataRoutes() { get(s, group, "/projects", "List projects", s.humaListProjects) get(s, group, "/machines", "List machines", s.humaListMachines) + get(s, group, "/branches", "List branches", s.humaListBranches) get(s, group, "/agents", "List agents", s.humaListAgents) get(s, group, "/stats", "Get stats", s.humaGetStats) get(s, group, "/version", "Get server version", s.humaGetVersion) @@ -30,6 +31,10 @@ type machinesResponse struct { Machines []string `json:"machines"` } +type branchesResponse struct { + Branches []db.BranchInfo `json:"branches"` +} + type agentsResponse struct { Agents []db.AgentInfo `json:"agents"` } @@ -67,6 +72,17 @@ func (s *Server) humaListMachines( return &jsonOutput[machinesResponse]{Body: machinesResponse{Machines: machines}}, nil } +func (s *Server) humaListBranches( + ctx context.Context, + in *statsInput, +) (*jsonOutput[branchesResponse], error) { + branches, err := s.db.GetBranches(ctx, !in.IncludeOneShot, !in.IncludeAutomated) + if err != nil { + return nil, serverError(err) + } + return &jsonOutput[branchesResponse]{Body: branchesResponse{Branches: branches}}, nil +} + func (s *Server) humaListAgents( ctx context.Context, in *statsInput, diff --git a/internal/server/huma_routes_search.go b/internal/server/huma_routes_search.go index 0d92ea6d2..7b87d9bf7 100644 --- a/internal/server/huma_routes_search.go +++ b/internal/server/huma_routes_search.go @@ -38,6 +38,7 @@ type contentSearchInput struct { Project string `query:"project" doc:"Filter by project"` ExcludeProject string `query:"exclude_project" doc:"Exclude a project"` Machine string `query:"machine" doc:"Filter by machine"` + GitBranch string `query:"git_branch" doc:"Filter by git branch; opaque (project, branch) tokens from the /branches endpoint"` Agent string `query:"agent" doc:"Filter by agent"` Date string `query:"date" format:"date" doc:"Filter to a single YYYY-MM-DD date"` DateFrom string `query:"date_from" format:"date" doc:"Filter start date"` @@ -108,6 +109,7 @@ func (s *Server) humaSearchContent( Project: in.Project, ExcludeProject: in.ExcludeProject, Machine: in.Machine, + GitBranch: in.GitBranch, Agent: in.Agent, Date: in.Date, DateFrom: in.DateFrom, diff --git a/internal/server/huma_routes_sessions.go b/internal/server/huma_routes_sessions.go index b1cfcae92..1a2a8e49d 100644 --- a/internal/server/huma_routes_sessions.go +++ b/internal/server/huma_routes_sessions.go @@ -60,6 +60,7 @@ type sessionFilterInput struct { Project string `query:"project" doc:"Filter by project"` ExcludeProject string `query:"exclude_project" doc:"Exclude a project"` Machine string `query:"machine" doc:"Filter by machine"` + GitBranch string `query:"git_branch" doc:"Filter by git branch; opaque (project, branch) tokens from the /branches endpoint"` Agent string `query:"agent" doc:"Filter by agent"` Date string `query:"date" format:"date" doc:"Filter to a single YYYY-MM-DD date"` DateFrom string `query:"date_from" format:"date" doc:"Filter start date"` @@ -107,6 +108,7 @@ func (in *sessionFilterInput) listFilter() (service.ListFilter, error) { Project: in.Project, ExcludeProject: in.ExcludeProject, Machine: in.Machine, + GitBranch: in.GitBranch, Agent: in.Agent, Date: in.Date, DateFrom: in.DateFrom, @@ -152,6 +154,7 @@ func (in *sessionFilterInput) dbFilter(includeChildren bool) (db.SessionFilter, Project: in.Project, ExcludeProject: in.ExcludeProject, Machine: in.Machine, + GitBranch: in.GitBranch, Agent: in.Agent, Date: in.Date, DateFrom: in.DateFrom, diff --git a/internal/server/huma_routes_usage.go b/internal/server/huma_routes_usage.go index cee1d0d84..e759ad575 100644 --- a/internal/server/huma_routes_usage.go +++ b/internal/server/huma_routes_usage.go @@ -25,6 +25,7 @@ type UsageFilterInput struct { Agent string `query:"agent" doc:"Filter by agent"` Project string `query:"project" doc:"Filter by project"` Machine string `query:"machine" doc:"Filter by machine"` + GitBranch string `query:"git_branch" doc:"Filter by git branch; opaque (project, branch) tokens from the /branches endpoint"` ExcludeProject string `query:"exclude_project" doc:"Exclude a project"` ExcludeAgent string `query:"exclude_agent" doc:"Exclude an agent"` ExcludeModel string `query:"exclude_model" doc:"Exclude a model"` @@ -59,6 +60,7 @@ func usageRequestFromInput(in UsageFilterInput) service.UsageRequest { Agent: in.Agent, Project: in.Project, Machine: in.Machine, + GitBranch: in.GitBranch, ExcludeProject: in.ExcludeProject, ExcludeAgent: in.ExcludeAgent, ExcludeModel: in.ExcludeModel, diff --git a/internal/service/direct.go b/internal/service/direct.go index 2cc253603..eca075e13 100644 --- a/internal/service/direct.go +++ b/internal/service/direct.go @@ -159,6 +159,7 @@ func listFilterToDB(f ListFilter) db.SessionFilter { Project: f.Project, ExcludeProject: f.ExcludeProject, Machine: f.Machine, + GitBranch: f.GitBranch, Agent: f.Agent, Date: f.Date, DateFrom: f.DateFrom, @@ -586,6 +587,7 @@ func (b *directBackend) SearchContent( Project: req.Project, ExcludeProject: req.ExcludeProject, Machine: req.Machine, + GitBranch: req.GitBranch, Agent: req.Agent, Date: req.Date, DateFrom: req.DateFrom, diff --git a/internal/service/http.go b/internal/service/http.go index ff802fb4f..43a423d0e 100644 --- a/internal/service/http.go +++ b/internal/service/http.go @@ -96,6 +96,7 @@ func filterToQuery(f ListFilter) url.Values { setIfNotEmpty("project", f.Project) setIfNotEmpty("exclude_project", f.ExcludeProject) setIfNotEmpty("machine", f.Machine) + setIfNotEmpty("git_branch", f.GitBranch) setIfNotEmpty("agent", f.Agent) setIfNotEmpty("date", f.Date) setIfNotEmpty("date_from", f.DateFrom) @@ -344,6 +345,7 @@ func (b *httpBackend) SearchContent( "project": req.Project, "exclude_project": req.ExcludeProject, "machine": req.Machine, + "git_branch": req.GitBranch, "agent": req.Agent, "date": req.Date, "date_from": req.DateFrom, @@ -387,6 +389,7 @@ func (b *httpBackend) UsageSummary( "agent": req.Agent, "project": req.Project, "machine": req.Machine, + "git_branch": req.GitBranch, "exclude_project": req.ExcludeProject, "exclude_agent": req.ExcludeAgent, "exclude_model": req.ExcludeModel, diff --git a/internal/service/service.go b/internal/service/service.go index c0a1ad228..795b94b6a 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -111,6 +111,8 @@ type ContentSearchRequest struct { Project, ExcludeProject, Machine, Agent string Date, DateFrom, DateTo, ActiveSince string IncludeChildren, IncludeAutomated, IncludeOneShot bool + // GitBranch is a branchListSep-joined list of opaque (project, branch) tokens (EncodeBranchFilterToken). + GitBranch string Limit int `json:"limit,omitempty"` Cursor int `json:"cursor,omitempty"` @@ -182,6 +184,7 @@ type ListFilter struct { Project string `json:"project,omitempty"` ExcludeProject string `json:"exclude_project,omitempty"` Machine string `json:"machine,omitempty"` + GitBranch string `json:"git_branch,omitempty"` Agent string `json:"agent,omitempty"` Date string `json:"date,omitempty"` DateFrom string `json:"date_from,omitempty"` diff --git a/internal/service/usage.go b/internal/service/usage.go index 1ad12c76e..0a819f118 100644 --- a/internal/service/usage.go +++ b/internal/service/usage.go @@ -20,6 +20,7 @@ type UsageRequest struct { Agent string `json:"agent,omitempty"` Project string `json:"project,omitempty"` Machine string `json:"machine,omitempty"` + GitBranch string `json:"git_branch,omitempty"` ExcludeProject string `json:"exclude_project,omitempty"` ExcludeAgent string `json:"exclude_agent,omitempty"` ExcludeModel string `json:"exclude_model,omitempty"` @@ -87,6 +88,7 @@ func BuildUsageFilter(req UsageRequest) (db.UsageFilter, error) { Agent: req.Agent, Project: req.Project, Machine: req.Machine, + GitBranch: req.GitBranch, ExcludeProject: req.ExcludeProject, ExcludeAgent: req.ExcludeAgent, ExcludeModel: req.ExcludeModel, From a7cdad6e163838f0c1d3d635664085396597bce7 Mon Sep 17 00:00:00 2001 From: Prateek Rungta Date: Tue, 30 Jun 2026 03:37:49 -0400 Subject: [PATCH 2/7] feat(filter): share branch filter client support --- frontend/messages/en.json | 1 + frontend/messages/zh-CN.json | 1 + frontend/src/lib/api/generated/index.ts | 2 + .../api/generated/models/BranchesResponse.ts | 8 +++ .../lib/api/generated/models/DbBranchInfo.ts | 9 +++ .../api/generated/services/ActivityService.ts | 6 ++ .../generated/services/AnalyticsService.ts | 72 +++++++++++++++++++ .../api/generated/services/MetadataService.ts | 41 +++++++++++ .../api/generated/services/SearchService.ts | 6 ++ .../api/generated/services/SessionsService.ts | 12 ++++ .../api/generated/services/TrendsService.ts | 6 ++ .../api/generated/services/UsageService.ts | 18 +++++ frontend/src/lib/api/types/core.ts | 10 +++ frontend/src/lib/branchFilters.test.ts | 21 ++++++ frontend/src/lib/branchFilters.ts | 34 +++++++++ 15 files changed, 247 insertions(+) create mode 100644 frontend/src/lib/api/generated/models/BranchesResponse.ts create mode 100644 frontend/src/lib/api/generated/models/DbBranchInfo.ts create mode 100644 frontend/src/lib/branchFilters.test.ts create mode 100644 frontend/src/lib/branchFilters.ts diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 8bc5bc2ed..1af05cef1 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -460,6 +460,7 @@ "shared_no_sessions_in_range": "No sessions in range", "shared_none": "None", "shared_other": "Other", + "shared_no_branch": "(no branch)", "shared_unknown": "unknown", "analytics_refresh": "Refresh analytics", "analytics_export_csv": "Export CSV", diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index dff7e35b1..9bad54a70 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -449,6 +449,7 @@ "shared_no_sessions_in_range": "此范围内无会话", "shared_none": "无", "shared_other": "其他", + "shared_no_branch": "(无分支)", "shared_unknown": "未知", "analytics_refresh": "刷新分析", "analytics_export_csv": "导出 CSV", diff --git a/frontend/src/lib/api/generated/index.ts b/frontend/src/lib/api/generated/index.ts index d3bc43c74..fe5d02d95 100644 --- a/frontend/src/lib/api/generated/index.ts +++ b/frontend/src/lib/api/generated/index.ts @@ -18,6 +18,7 @@ export type { AgentsResponse } from './models/AgentsResponse'; export type { AgentTotal } from './models/AgentTotal'; export type { ApiErrorResponse } from './models/ApiErrorResponse'; export type { ApplyWorktreeMappingsResponse } from './models/ApplyWorktreeMappingsResponse'; +export type { BranchesResponse } from './models/BranchesResponse'; export type { BulkStarInputBody } from './models/BulkStarInputBody'; export type { CacheStats } from './models/CacheStats'; export type { Comparison } from './models/Comparison'; @@ -31,6 +32,7 @@ export type { DbAgentBreakdown } from './models/DbAgentBreakdown'; export type { DbAgentInfo } from './models/DbAgentInfo'; export type { DbAgentSummary } from './models/DbAgentSummary'; export type { DbAnalyticsSummary } from './models/DbAnalyticsSummary'; +export type { DbBranchInfo } from './models/DbBranchInfo'; export type { DbCallTiming } from './models/DbCallTiming'; export type { DbCategoryTotal } from './models/DbCategoryTotal'; export type { DbContentMatch } from './models/DbContentMatch'; diff --git a/frontend/src/lib/api/generated/models/BranchesResponse.ts b/frontend/src/lib/api/generated/models/BranchesResponse.ts new file mode 100644 index 000000000..b3eda7a99 --- /dev/null +++ b/frontend/src/lib/api/generated/models/BranchesResponse.ts @@ -0,0 +1,8 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type BranchesResponse = { + branches: any[] | null; +}; + diff --git a/frontend/src/lib/api/generated/models/DbBranchInfo.ts b/frontend/src/lib/api/generated/models/DbBranchInfo.ts new file mode 100644 index 000000000..69abd4e2f --- /dev/null +++ b/frontend/src/lib/api/generated/models/DbBranchInfo.ts @@ -0,0 +1,9 @@ +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +export type DbBranchInfo = { + branch: string; + project: string; +}; + diff --git a/frontend/src/lib/api/generated/services/ActivityService.ts b/frontend/src/lib/api/generated/services/ActivityService.ts index 9ffcbc038..bf4c2d2b9 100644 --- a/frontend/src/lib/api/generated/services/ActivityService.ts +++ b/frontend/src/lib/api/generated/services/ActivityService.ts @@ -20,6 +20,7 @@ export class ActivityService { timezone, bucket, project, + gitBranch, agent, machine, automation = 'all', @@ -52,6 +53,10 @@ export class ActivityService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -76,6 +81,7 @@ export class ActivityService { 'timezone': timezone, 'bucket': bucket, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'machine': machine, 'automation': automation, diff --git a/frontend/src/lib/api/generated/services/AnalyticsService.ts b/frontend/src/lib/api/generated/services/AnalyticsService.ts index 90076aeec..b0416c8d8 100644 --- a/frontend/src/lib/api/generated/services/AnalyticsService.ts +++ b/frontend/src/lib/api/generated/services/AnalyticsService.ts @@ -29,6 +29,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -61,6 +62,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -115,6 +120,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -153,6 +159,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -185,6 +192,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -239,6 +250,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -277,6 +289,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -308,6 +321,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -358,6 +375,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -395,6 +413,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -426,6 +445,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -476,6 +499,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -513,6 +537,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -544,6 +569,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -594,6 +623,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -632,6 +662,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -668,6 +699,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -722,6 +757,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -761,6 +797,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -792,6 +829,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -842,6 +883,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -879,6 +921,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -910,6 +953,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -960,6 +1007,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -997,6 +1045,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -1028,6 +1077,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -1078,6 +1131,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -1115,6 +1169,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -1146,6 +1201,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -1196,6 +1255,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -1233,6 +1293,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -1265,6 +1326,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -1319,6 +1384,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, @@ -1357,6 +1423,7 @@ export class AnalyticsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -1388,6 +1455,10 @@ export class AnalyticsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -1438,6 +1509,7 @@ export class AnalyticsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, diff --git a/frontend/src/lib/api/generated/services/MetadataService.ts b/frontend/src/lib/api/generated/services/MetadataService.ts index 6f215735d..71c9e4976 100644 --- a/frontend/src/lib/api/generated/services/MetadataService.ts +++ b/frontend/src/lib/api/generated/services/MetadataService.ts @@ -3,6 +3,7 @@ /* tslint:disable */ /* eslint-disable */ import type { AgentsResponse } from '../models/AgentsResponse'; +import type { BranchesResponse } from '../models/BranchesResponse'; import type { DbStats } from '../models/DbStats'; import type { MachinesResponse } from '../models/MachinesResponse'; import type { ProjectsResponse } from '../models/ProjectsResponse'; @@ -52,6 +53,46 @@ export class MetadataService { }, }); } + /** + * List branches + * @returns BranchesResponse OK + * @throws ApiError + */ + public static getApiV1Branches({ + includeOneShot, + includeAutomated, + }: { + /** + * Include one-shot sessions + */ + includeOneShot?: boolean, + /** + * Include automated sessions + */ + includeAutomated?: boolean, + }): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v1/branches', + query: { + 'include_one_shot': includeOneShot, + 'include_automated': includeAutomated, + }, + errors: { + 400: `Bad Request`, + 401: `Unauthorized`, + 403: `Forbidden`, + 404: `Not Found`, + 409: `Conflict`, + 422: `Unprocessable Entity`, + 500: `Internal Server Error`, + 501: `Not Implemented`, + 502: `Bad Gateway`, + 503: `Service Unavailable`, + 504: `Gateway Timeout`, + }, + }); + } /** * List machines * @returns MachinesResponse OK diff --git a/frontend/src/lib/api/generated/services/SearchService.ts b/frontend/src/lib/api/generated/services/SearchService.ts index 7d27e4de4..90b04932c 100644 --- a/frontend/src/lib/api/generated/services/SearchService.ts +++ b/frontend/src/lib/api/generated/services/SearchService.ts @@ -80,6 +80,7 @@ export class SearchService { project, excludeProject, machine, + gitBranch, agent, date, dateFrom, @@ -123,6 +124,10 @@ export class SearchService { * Filter by machine */ machine?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -176,6 +181,7 @@ export class SearchService { 'project': project, 'exclude_project': excludeProject, 'machine': machine, + 'git_branch': gitBranch, 'agent': agent, 'date': date, 'date_from': dateFrom, diff --git a/frontend/src/lib/api/generated/services/SessionsService.ts b/frontend/src/lib/api/generated/services/SessionsService.ts index e865eda86..e0031d095 100644 --- a/frontend/src/lib/api/generated/services/SessionsService.ts +++ b/frontend/src/lib/api/generated/services/SessionsService.ts @@ -59,6 +59,7 @@ export class SessionsService { project, excludeProject, machine, + gitBranch, agent, date, dateFrom, @@ -93,6 +94,10 @@ export class SessionsService { * Filter by machine */ machine?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -185,6 +190,7 @@ export class SessionsService { 'project': project, 'exclude_project': excludeProject, 'machine': machine, + 'git_branch': gitBranch, 'agent': agent, 'date': date, 'date_from': dateFrom, @@ -231,6 +237,7 @@ export class SessionsService { project, excludeProject, machine, + gitBranch, agent, date, dateFrom, @@ -265,6 +272,10 @@ export class SessionsService { * Filter by machine */ machine?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -357,6 +368,7 @@ export class SessionsService { 'project': project, 'exclude_project': excludeProject, 'machine': machine, + 'git_branch': gitBranch, 'agent': agent, 'date': date, 'date_from': dateFrom, diff --git a/frontend/src/lib/api/generated/services/TrendsService.ts b/frontend/src/lib/api/generated/services/TrendsService.ts index 47b0cea01..9fcbd062e 100644 --- a/frontend/src/lib/api/generated/services/TrendsService.ts +++ b/frontend/src/lib/api/generated/services/TrendsService.ts @@ -18,6 +18,7 @@ export class TrendsService { timezone, machine, project, + gitBranch, agent, model, dow, @@ -51,6 +52,10 @@ export class TrendsService { * Filter by project */ project?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Filter by agent */ @@ -109,6 +114,7 @@ export class TrendsService { 'timezone': timezone, 'machine': machine, 'project': project, + 'git_branch': gitBranch, 'agent': agent, 'model': model, 'dow': dow, diff --git a/frontend/src/lib/api/generated/services/UsageService.ts b/frontend/src/lib/api/generated/services/UsageService.ts index 3ba2fec33..ef30894b1 100644 --- a/frontend/src/lib/api/generated/services/UsageService.ts +++ b/frontend/src/lib/api/generated/services/UsageService.ts @@ -21,6 +21,7 @@ export class UsageService { agent, project, machine, + gitBranch, excludeProject, excludeAgent, excludeModel, @@ -62,6 +63,10 @@ export class UsageService { * Filter by machine */ machine?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Exclude a project */ @@ -121,6 +126,7 @@ export class UsageService { 'agent': agent, 'project': project, 'machine': machine, + 'git_branch': gitBranch, 'exclude_project': excludeProject, 'exclude_agent': excludeAgent, 'exclude_model': excludeModel, @@ -162,6 +168,7 @@ export class UsageService { agent, project, machine, + gitBranch, excludeProject, excludeAgent, excludeModel, @@ -199,6 +206,10 @@ export class UsageService { * Filter by machine */ machine?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Exclude a project */ @@ -258,6 +269,7 @@ export class UsageService { 'agent': agent, 'project': project, 'machine': machine, + 'git_branch': gitBranch, 'exclude_project': excludeProject, 'exclude_agent': excludeAgent, 'exclude_model': excludeModel, @@ -298,6 +310,7 @@ export class UsageService { agent, project, machine, + gitBranch, excludeProject, excludeAgent, excludeModel, @@ -336,6 +349,10 @@ export class UsageService { * Filter by machine */ machine?: string, + /** + * Filter by git branch; opaque (project, branch) tokens from the /branches endpoint + */ + gitBranch?: string, /** * Exclude a project */ @@ -399,6 +416,7 @@ export class UsageService { 'agent': agent, 'project': project, 'machine': machine, + 'git_branch': gitBranch, 'exclude_project': excludeProject, 'exclude_agent': excludeAgent, 'exclude_model': excludeModel, diff --git a/frontend/src/lib/api/types/core.ts b/frontend/src/lib/api/types/core.ts index d0b0d3bfe..eb736e992 100644 --- a/frontend/src/lib/api/types/core.ts +++ b/frontend/src/lib/api/types/core.ts @@ -197,6 +197,16 @@ export interface MachinesResponse { machines: string[]; } +/** Matches Go BranchInfo struct in internal/db/sessions.go */ +export interface BranchInfo { + project: string; + branch: string; +} + +export interface BranchesResponse { + branches: BranchInfo[]; +} + /** Matches Go AgentInfo struct */ export interface AgentInfo { name: string; diff --git a/frontend/src/lib/branchFilters.test.ts b/frontend/src/lib/branchFilters.test.ts new file mode 100644 index 000000000..89443becb --- /dev/null +++ b/frontend/src/lib/branchFilters.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { + branchFilterToken, + branchLabel, + branchTokenLabel, +} from "./branchFilters.js"; + +describe("branch filter labels", () => { + it("keeps empty branch labels distinct from a real unknown branch", () => { + const noBranch = "(no branch)"; + expect(branchLabel("proj", "", noBranch)).toBe("proj/(no branch)"); + expect(branchLabel("proj", "unknown", noBranch)).toBe("proj/unknown"); + expect(branchTokenLabel(branchFilterToken("proj", ""), noBranch)).toBe( + "proj/(no branch)", + ); + expect(branchTokenLabel( + branchFilterToken("proj", ""), + "No branch", + )).toBe("proj/No branch"); + }); +}); diff --git a/frontend/src/lib/branchFilters.ts b/frontend/src/lib/branchFilters.ts new file mode 100644 index 000000000..2e8c26cfc --- /dev/null +++ b/frontend/src/lib/branchFilters.ts @@ -0,0 +1,34 @@ +// Keep these in sync with internal/db branch token separators. +export const BRANCH_TOKEN_SEP = "\u001f"; +export const BRANCH_LIST_SEP = "\u001e"; + +export function branchFilterToken(project: string, branch: string): string { + return project + BRANCH_TOKEN_SEP + branch; +} + +export function splitBranchFilterToken(token: string): { + project: string; + branch: string; +} { + const i = token.indexOf(BRANCH_TOKEN_SEP); + return i < 0 + ? { project: "", branch: token } + : { project: token.slice(0, i), branch: token.slice(i + 1) }; +} + +export function branchLabel( + project: string, + branch: string, + noBranchLabel: string, +): string { + const label = branch || noBranchLabel; + return project ? `${project}/${label}` : label; +} + +export function branchTokenLabel( + token: string, + noBranchLabel: string, +): string { + const { project, branch } = splitBranchFilterToken(token); + return branchLabel(project, branch, noBranchLabel); +} From 159bb91175bccf2461964afeee9096bce688219d Mon Sep 17 00:00:00 2001 From: Prateek Rungta Date: Tue, 30 Jun 2026 03:51:51 -0400 Subject: [PATCH 3/7] fix(filter): close branch filter contract gaps --- .../lib/api/generated/models/DbBranchInfo.ts | 1 + frontend/src/lib/api/types/core.ts | 1 + internal/db/branch_filter_test.go | 30 +++++--- internal/db/query_dialect.go | 6 +- internal/db/sessions.go | 2 + internal/duckdb/store.go | 3 +- internal/duckdb/store_test.go | 72 +++++++++++++++++-- internal/postgres/search_content.go | 2 +- .../postgres/search_content_pgtest_test.go | 41 +++++++++++ internal/postgres/sessions.go | 1 + internal/server/huma_routes_usage.go | 1 + internal/server/usage_internal_test.go | 14 ++++ 12 files changed, 156 insertions(+), 18 deletions(-) diff --git a/frontend/src/lib/api/generated/models/DbBranchInfo.ts b/frontend/src/lib/api/generated/models/DbBranchInfo.ts index 69abd4e2f..0eb5262cb 100644 --- a/frontend/src/lib/api/generated/models/DbBranchInfo.ts +++ b/frontend/src/lib/api/generated/models/DbBranchInfo.ts @@ -5,5 +5,6 @@ export type DbBranchInfo = { branch: string; project: string; + token: string; }; diff --git a/frontend/src/lib/api/types/core.ts b/frontend/src/lib/api/types/core.ts index eb736e992..5014f1a1b 100644 --- a/frontend/src/lib/api/types/core.ts +++ b/frontend/src/lib/api/types/core.ts @@ -201,6 +201,7 @@ export interface MachinesResponse { export interface BranchInfo { project: string; branch: string; + token: string; } export interface BranchesResponse { diff --git a/internal/db/branch_filter_test.go b/internal/db/branch_filter_test.go index f7ad00f1f..0c82ab2ed 100644 --- a/internal/db/branch_filter_test.go +++ b/internal/db/branch_filter_test.go @@ -8,6 +8,14 @@ import ( "github.com/stretchr/testify/require" ) +func branchInfoForTest(project, branch string) BranchInfo { + return BranchInfo{ + Project: project, + Branch: branch, + Token: EncodeBranchFilterToken(project, branch), + } +} + func TestGetDailyUsageGitBranchFilter(t *testing.T) { d := testDB(t) ctx := context.Background() @@ -60,7 +68,7 @@ func TestSplitBranchFilterTokens(t *testing.T) { { name: "round trip single", in: EncodeBranchFilterToken("alpha", "main"), - want: []BranchInfo{{Project: "alpha", Branch: "main"}}, + want: []BranchInfo{branchInfoForTest("alpha", "main")}, }, { name: "multiple", @@ -69,24 +77,24 @@ func TestSplitBranchFilterTokens(t *testing.T) { BranchInfo{Project: "beta", Branch: "main"}, ), want: []BranchInfo{ - {Project: "alpha", Branch: "feat/x"}, - {Project: "beta", Branch: "main"}, + branchInfoForTest("alpha", "feat/x"), + branchInfoForTest("beta", "main"), }, }, { name: "comma in branch name round-trips", in: EncodeBranchFilterToken("proj", "wip,test"), - want: []BranchInfo{{Project: "proj", Branch: "wip,test"}}, + want: []BranchInfo{branchInfoForTest("proj", "wip,test")}, }, { name: "drops blank and separator-less tokens", in: branchListSep + EncodeBranchFilterToken("alpha", "main") + branchListSep + "noseparator", - want: []BranchInfo{{Project: "alpha", Branch: "main"}}, + want: []BranchInfo{branchInfoForTest("alpha", "main")}, }, { name: "empty branch component survives", in: EncodeBranchFilterToken("alpha", ""), - want: []BranchInfo{{Project: "alpha", Branch: ""}}, + want: []BranchInfo{branchInfoForTest("alpha", "")}, }, } for _, tt := range tests { @@ -123,15 +131,15 @@ func TestGetBranches(t *testing.T) { all, err := d.GetBranches(context.Background(), false, false) require.NoError(t, err, "GetBranches includeAll") assert.Equal(t, []BranchInfo{ - {Project: "alpha", Branch: "feat/x"}, - {Project: "alpha", Branch: "main"}, - {Project: "beta", Branch: "main"}, - {Project: "gamma", Branch: "solo"}, + branchInfoForTest("alpha", "feat/x"), + branchInfoForTest("alpha", "main"), + branchInfoForTest("beta", "main"), + branchInfoForTest("gamma", "solo"), }, all, "distinct (project, branch) pairs, ordered, empty excluded") filtered, err := d.GetBranches(context.Background(), true, false) require.NoError(t, err, "GetBranches excludeOneShot") - assert.NotContains(t, filtered, BranchInfo{Project: "gamma", Branch: "solo"}, + assert.NotContains(t, filtered, branchInfoForTest("gamma", "solo"), "one-shot branch excluded when excludeOneShot is set") } diff --git a/internal/db/query_dialect.go b/internal/db/query_dialect.go index 9be657d59..594764855 100644 --- a/internal/db/query_dialect.go +++ b/internal/db/query_dialect.go @@ -636,7 +636,11 @@ func SplitBranchFilterTokens(s string) []BranchInfo { if !ok { continue } - out = append(out, BranchInfo{Project: project, Branch: branch}) + out = append(out, BranchInfo{ + Project: project, + Branch: branch, + Token: EncodeBranchFilterToken(project, branch), + }) } return out } diff --git a/internal/db/sessions.go b/internal/db/sessions.go index 1f5a4ac1c..d721a6897 100644 --- a/internal/db/sessions.go +++ b/internal/db/sessions.go @@ -2424,6 +2424,7 @@ func (db *DB) GetMachines( type BranchInfo struct { Project string `json:"project"` Branch string `json:"branch"` + Token string `json:"token"` } // GetBranches returns distinct (project, git_branch) pairs for sessions with a @@ -2462,6 +2463,7 @@ func (db *DB) GetBranches( if err := rows.Scan(&bi.Project, &bi.Branch); err != nil { return nil, fmt.Errorf("scanning branch: %w", err) } + bi.Token = EncodeBranchFilterToken(bi.Project, bi.Branch) branches = append(branches, bi) } return branches, rows.Err() diff --git a/internal/duckdb/store.go b/internal/duckdb/store.go index 25d05688e..4ee91b4d2 100644 --- a/internal/duckdb/store.go +++ b/internal/duckdb/store.go @@ -530,6 +530,7 @@ func (s *Store) GetBranches(ctx context.Context, excludeOneShot, excludeAutomate if err := rows.Scan(&bi.Project, &bi.Branch); err != nil { return nil, fmt.Errorf("scanning duckdb branch: %w", err) } + bi.Token = db.EncodeBranchFilterToken(bi.Project, bi.Branch) out = append(out, bi) } return out, rows.Err() @@ -1039,7 +1040,7 @@ func contentCandidateMatches(candidates []duckContentCandidate) []db.ContentMatc func contentSessionFilter(f db.ContentSearchFilter) db.SessionFilter { return db.SessionFilter{ Project: f.Project, ExcludeProject: f.ExcludeProject, - Machine: f.Machine, Agent: f.Agent, + Machine: f.Machine, GitBranch: f.GitBranch, Agent: f.Agent, Date: f.Date, DateFrom: f.DateFrom, DateTo: f.DateTo, ActiveSince: f.ActiveSince, ExcludeOneShot: !f.IncludeOneShot, diff --git a/internal/duckdb/store_test.go b/internal/duckdb/store_test.go index eed3ed6bd..b6c4771a8 100644 --- a/internal/duckdb/store_test.go +++ b/internal/duckdb/store_test.go @@ -1080,6 +1080,54 @@ func TestSearchContentRegexOrdersBySessionRecency(t *testing.T) { assert.Equal(t, "a-old-regex", got.Matches[1].SessionID) } +func TestSearchContentGitBranchFilter(t *testing.T) { + ctx := context.Background() + local := newLocalDB(t) + alphaMain := syncSession("branch-alpha-main", "alpha", "main session", "2026-01-11T00:00:00Z", 1) + alphaMain.GitBranch = "main" + alphaFeature := syncSession("branch-alpha-feature", "alpha", "feature session", "2026-01-11T00:01:00Z", 1) + alphaFeature.GitBranch = "feature" + betaMain := syncSession("branch-beta-main", "beta", "beta session", "2026-01-11T00:02:00Z", 1) + betaMain.GitBranch = "main" + _, err := local.WriteSessionBatchAtomic([]db.SessionBatchWrite{ + { + Session: alphaMain, + Messages: []db.Message{syncMessage(alphaMain.ID, 0, "user", "BRANCHNEEDLE alpha main", "2026-01-11T00:00:00Z")}, + DataVersion: 1, + ReplaceMessages: true, + }, + { + Session: alphaFeature, + Messages: []db.Message{syncMessage(alphaFeature.ID, 0, "user", "BRANCHNEEDLE alpha feature", "2026-01-11T00:01:00Z")}, + DataVersion: 1, + ReplaceMessages: true, + }, + { + Session: betaMain, + Messages: []db.Message{syncMessage(betaMain.ID, 0, "user", "BRANCHNEEDLE beta main", "2026-01-11T00:02:00Z")}, + DataVersion: 1, + ReplaceMessages: true, + }, + }) + require.NoError(t, err) + syncer := newInMemoryTestSync(t, local, SyncOptions{}) + _, err = syncer.Push(ctx, true, nil) + require.NoError(t, err) + store := NewStoreFromDB(syncer.DB()) + + got, err := store.SearchContent(ctx, db.ContentSearchFilter{ + Pattern: "BRANCHNEEDLE", + Mode: "substring", + Sources: []string{"messages"}, + GitBranch: db.EncodeBranchFilterToken("alpha", "main"), + IncludeOneShot: true, + Limit: 10, + }) + require.NoError(t, err) + require.Len(t, got.Matches, 1) + assert.Equal(t, alphaMain.ID, got.Matches[0].SessionID) +} + func TestSearchContentSubstringPaginatesAfterGlobalOrdering(t *testing.T) { ctx := context.Background() store, _ := newSyncedStore(t) @@ -2363,10 +2411,26 @@ func TestDuckDBBranchDimension(t *testing.T) { branches, err := store.GetBranches(ctx, false, false) require.NoError(t, err) assert.Equal(t, []db.BranchInfo{ - {Project: "alpha", Branch: "feature-x"}, - {Project: "alpha", Branch: "main"}, - {Project: "alpha", Branch: "unknown"}, - {Project: "beta", Branch: "main"}, + { + Project: "alpha", + Branch: "feature-x", + Token: db.EncodeBranchFilterToken("alpha", "feature-x"), + }, + { + Project: "alpha", + Branch: "main", + Token: db.EncodeBranchFilterToken("alpha", "main"), + }, + { + Project: "alpha", + Branch: "unknown", + Token: db.EncodeBranchFilterToken("alpha", "unknown"), + }, + { + Project: "beta", + Branch: "main", + Token: db.EncodeBranchFilterToken("beta", "main"), + }, }, branches) filtered, err := store.GetDailyUsage(ctx, db.UsageFilter{ diff --git a/internal/postgres/search_content.go b/internal/postgres/search_content.go index 8a7d49ad4..f47702505 100644 --- a/internal/postgres/search_content.go +++ b/internal/postgres/search_content.go @@ -61,7 +61,7 @@ func pgHasSource(f db.ContentSearchFilter, src string) bool { func pgSessionFilter(f db.ContentSearchFilter) db.SessionFilter { return db.SessionFilter{ Project: f.Project, ExcludeProject: f.ExcludeProject, - Machine: f.Machine, Agent: f.Agent, + Machine: f.Machine, GitBranch: f.GitBranch, Agent: f.Agent, Date: f.Date, DateFrom: f.DateFrom, DateTo: f.DateTo, ActiveSince: f.ActiveSince, ExcludeOneShot: !f.IncludeOneShot, diff --git a/internal/postgres/search_content_pgtest_test.go b/internal/postgres/search_content_pgtest_test.go index 3096b3843..183a437f5 100644 --- a/internal/postgres/search_content_pgtest_test.go +++ b/internal/postgres/search_content_pgtest_test.go @@ -59,6 +59,15 @@ func insertCSSession( require.NoError(t, err, "insert session %s", id) } +func setCSSessionBranch(t *testing.T, store *Store, id, branch string) { + t.Helper() + _, err := store.DB().Exec( + `UPDATE sessions SET git_branch = $1 WHERE id = $2`, + branch, id, + ) + require.NoError(t, err, "set session branch %s", id) +} + // insertCSMessage inserts a message; isSystem=true sets is_system. func insertCSMessage( t *testing.T, store *Store, @@ -351,6 +360,38 @@ func TestPGSearchContentProjectFilter(t *testing.T) { assert.NotEmpty(t, got.Matches, "expected matches in alpha project") } +func TestPGSearchContentGitBranchFilter(t *testing.T) { + store := setupContentSearch(t) + insertCSSession(t, store, "cs-branch-alpha-main", "alpha", "claude", + "2026-05-01T10:00:00Z", "2026-05-01T10:30:00Z") + setCSSessionBranch(t, store, "cs-branch-alpha-main", "main") + insertCSMessage(t, store, "cs-branch-alpha-main", 0, "user", + "BRANCHNEEDLE alpha main", "2026-05-01T10:00:00Z", false) + insertCSSession(t, store, "cs-branch-alpha-feature", "alpha", "claude", + "2026-05-01T11:00:00Z", "2026-05-01T11:30:00Z") + setCSSessionBranch(t, store, "cs-branch-alpha-feature", "feature") + insertCSMessage(t, store, "cs-branch-alpha-feature", 0, "user", + "BRANCHNEEDLE alpha feature", "2026-05-01T11:00:00Z", false) + insertCSSession(t, store, "cs-branch-beta-main", "beta", "claude", + "2026-05-01T12:00:00Z", "2026-05-01T12:30:00Z") + setCSSessionBranch(t, store, "cs-branch-beta-main", "main") + insertCSMessage(t, store, "cs-branch-beta-main", 0, "user", + "BRANCHNEEDLE beta main", "2026-05-01T12:00:00Z", false) + + ctx := context.Background() + got, err := store.SearchContent(ctx, db.ContentSearchFilter{ + Pattern: "BRANCHNEEDLE", + Mode: "substring", + Sources: []string{"messages"}, + GitBranch: db.EncodeBranchFilterToken("alpha", "main"), + IncludeOneShot: true, + Limit: 50, + }) + require.NoError(t, err) + require.Len(t, got.Matches, 1) + assert.Equal(t, "cs-branch-alpha-main", got.Matches[0].SessionID) +} + // TestPGSearchContentPagination verifies Limit+1 sentinel and NextCursor. func TestPGSearchContentPagination(t *testing.T) { store := setupContentSearch(t) diff --git a/internal/postgres/sessions.go b/internal/postgres/sessions.go index 9254a07d3..a7fa2e072 100644 --- a/internal/postgres/sessions.go +++ b/internal/postgres/sessions.go @@ -1101,6 +1101,7 @@ func (s *Store) GetBranches( if err := rows.Scan(&bi.Project, &bi.Branch); err != nil { return nil, fmt.Errorf("scanning branch: %w", err) } + bi.Token = db.EncodeBranchFilterToken(bi.Project, bi.Branch) branches = append(branches, bi) } return branches, rows.Err() diff --git a/internal/server/huma_routes_usage.go b/internal/server/huma_routes_usage.go index e759ad575..79c5a0135 100644 --- a/internal/server/huma_routes_usage.go +++ b/internal/server/huma_routes_usage.go @@ -163,6 +163,7 @@ func (s *Server) computeUsageComparison( Agent: f.Agent, Project: f.Project, Machine: f.Machine, + GitBranch: f.GitBranch, Model: f.Model, ExcludeProject: f.ExcludeProject, ExcludeAgent: f.ExcludeAgent, diff --git a/internal/server/usage_internal_test.go b/internal/server/usage_internal_test.go index 9c1d5e13c..ca66e46a1 100644 --- a/internal/server/usage_internal_test.go +++ b/internal/server/usage_internal_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "net/url" "testing" "github.com/stretchr/testify/assert" @@ -134,6 +135,19 @@ func TestUsageComparisonScansPriorPeriodOnly(t *testing.T) { assert.Equal(t, 2.0, out.DeltaPct) } +func TestUsageComparisonCopiesGitBranchFilterToPriorPeriod(t *testing.T) { + spy := &usageSummaryCountsSpy{} + s := newRoutedTestServerWithStore(t, spy) + branch := db.EncodeBranchFilterToken("alpha", "main") + + w := serveGet(t, s, + "/api/v1/usage/comparison?"+oneDayUsageRange+"¤t_cost=3&git_branch="+url.QueryEscape(branch)) + assertRecorderStatus(t, w, http.StatusOK) + + require.Len(t, spy.filters, 1) + assert.Equal(t, branch, spy.filters[0].GitBranch) +} + func TestUsageComparisonRequiresCurrentCost(t *testing.T) { spy := &usageSummaryCountsSpy{} s := newRoutedTestServerWithStore(t, spy) From 2e4fd7a72901069111a171c41816f5515fcc62a3 Mon Sep 17 00:00:00 2001 From: Prateek Rungta Date: Tue, 30 Jun 2026 03:16:04 -0400 Subject: [PATCH 4/7] feat(filter): expose branch filters in clients --- cmd/agentsview/activity.go | 17 +++ cmd/agentsview/cli.go | 13 +++ cmd/agentsview/cli_test.go | 13 +++ cmd/agentsview/session_get.go | 3 + cmd/agentsview/session_list.go | 8 ++ cmd/agentsview/session_search.go | 9 +- frontend/messages/en.json | 5 + frontend/messages/zh-CN.json | 5 + .../filters/SessionActiveFilters.svelte | 32 +++++- .../filters/SessionFilterControl.svelte | 68 +++++++++++- frontend/src/lib/stores/sessions.svelte.ts | 103 ++++++++++++++++++ frontend/src/lib/stores/sessions.test.ts | 21 ++++ internal/mcp/tools.go | 61 ++++++++--- 13 files changed, 337 insertions(+), 21 deletions(-) diff --git a/cmd/agentsview/activity.go b/cmd/agentsview/activity.go index 3bcb40679..d5bb3a6ea 100644 --- a/cmd/agentsview/activity.go +++ b/cmd/agentsview/activity.go @@ -26,6 +26,7 @@ type ActivityReportConfig struct { Timezone string Bucket string Project string + Branch string Agent string Machine string JSON bool @@ -36,6 +37,12 @@ type ActivityReportConfig struct { // runActivityReport syncs, resolves the range, runs the report, and prints it. func runActivityReport(cfg ActivityReportConfig) { ctx := context.Background() + // Validate the (project, branch) flag combo up front so a bad combination + // fails before we resolve a backend, which may sync or start a daemon. + if _, err := branchFilterToken(cfg.Project, cfg.Branch); err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) + os.Exit(1) + } backend, cleanup, err := resolveArchiveQueryBackend(ctx, archiveQueryPolicy{ Offline: cfg.Offline, NoSync: cfg.NoSync, @@ -89,6 +96,11 @@ func fetchHTTPActivityReport( setIfNotEmpty("project", cfg.Project) setIfNotEmpty("agent", cfg.Agent) setIfNotEmpty("machine", cfg.Machine) + gitBranch, err := branchFilterToken(cfg.Project, cfg.Branch) + if err != nil { + return activity.Report{}, err + } + setIfNotEmpty("git_branch", gitBranch) endpoint := strings.TrimSuffix(tr.URL, "/") + "/api/v1/activity/report?" + q.Encode() @@ -159,9 +171,14 @@ func resolveActivityReport( return activity.Report{}, err } + gitBranch, err := branchFilterToken(cfg.Project, cfg.Branch) + if err != nil { + return activity.Report{}, err + } f := db.AnalyticsFilter{ Timezone: tz, Project: cfg.Project, + GitBranch: gitBranch, Agent: cfg.Agent, Machine: cfg.Machine, ExcludeOneShot: false, diff --git a/cmd/agentsview/cli.go b/cmd/agentsview/cli.go index f83d5008b..304dad23c 100644 --- a/cmd/agentsview/cli.go +++ b/cmd/agentsview/cli.go @@ -24,6 +24,18 @@ const ( const dataVersionTooNewExitCode = 3 +// branchFilterToken builds the (project, branch) filter token; a branch needs a +// project to scope it (like the MCP tools), so it errors without --project. +func branchFilterToken(project, branch string) (string, error) { + if branch == "" { + return "", nil + } + if project == "" { + return "", fmt.Errorf("--branch requires --project") + } + return db.EncodeBranchFilterToken(project, branch), nil +} + type cliExitError struct { code int err error @@ -518,6 +530,7 @@ func newActivityReportCommand() *cobra.Command { cmd.Flags().StringVar(&cfg.Timezone, "timezone", "", "IANA timezone for range bucketing") cmd.Flags().StringVar(&cfg.Bucket, "bucket", "", "Bucket size: 5m, 15m, 1h, 1d, 1w") cmd.Flags().StringVar(&cfg.Project, "project", "", "Filter by project") + cmd.Flags().StringVar(&cfg.Branch, "branch", "", "Filter by git branch name (requires --project)") cmd.Flags().StringVar(&cfg.Agent, "agent", "", "Filter by agent name") cmd.Flags().StringVar(&cfg.Machine, "machine", "", "Filter by machine name") registerFormatFlags(cmd.Flags()) diff --git a/cmd/agentsview/cli_test.go b/cmd/agentsview/cli_test.go index 6153d6efe..f5f944b90 100644 --- a/cmd/agentsview/cli_test.go +++ b/cmd/agentsview/cli_test.go @@ -316,3 +316,16 @@ func TestSyncHelpMentionsConfiguredHosts(t *testing.T) { assert.Contains(t, help, want, "sync help missing %q", want) } } + +func TestBranchFilterToken(t *testing.T) { + tok, err := branchFilterToken("proj", "") + require.NoError(t, err) + assert.Empty(t, tok, "empty branch yields no token") + + _, err = branchFilterToken("", "main") + assert.Error(t, err, "branch without project must error") + + tok, err = branchFilterToken("proj", "main") + require.NoError(t, err) + assert.Equal(t, db.EncodeBranchFilterToken("proj", "main"), tok) +} diff --git a/cmd/agentsview/session_get.go b/cmd/agentsview/session_get.go index 55ef21203..d40add05b 100644 --- a/cmd/agentsview/session_get.go +++ b/cmd/agentsview/session_get.go @@ -136,6 +136,9 @@ func printSessionDetailHuman(w io.Writer, s *service.SessionDetail) error { fmt.Fprintf(w, "%s %s\n", label("ID"), sanitizeTerminal(s.ID)) fmt.Fprintf(w, "%s %s\n", label("Name"), sanitizeTerminal(name)) fmt.Fprintf(w, "%s %s\n", label("Project"), sanitizeTerminal(s.Project)) + if s.GitBranch != "" { + fmt.Fprintf(w, "%s %s\n", label("Branch"), sanitizeTerminal(s.GitBranch)) + } fmt.Fprintf(w, "%s %s\n", label("Agent"), sanitizeTerminal(s.Agent)) fmt.Fprintf(w, "%s %s\n", label("Machine"), sanitizeTerminal(s.Machine)) fmt.Fprintf(w, "%s %s\n", diff --git a/cmd/agentsview/session_list.go b/cmd/agentsview/session_list.go index a2df60432..9d8a2c4cf 100644 --- a/cmd/agentsview/session_list.go +++ b/cmd/agentsview/session_list.go @@ -19,6 +19,7 @@ import ( func newSessionListCommand() *cobra.Command { var ( project, excludeProject, machine, agent string + branch string date, dateFrom, dateTo, activeSince string minMessages, maxMessages int minUserMessages int @@ -39,6 +40,10 @@ func newSessionListCommand() *cobra.Command { Args: cobra.NoArgs, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { + gitBranch, err := branchFilterToken(project, branch) + if err != nil { + return err + } svc, cleanup, err := resolveService(cmd) if err != nil { return err @@ -49,6 +54,7 @@ func newSessionListCommand() *cobra.Command { Project: project, ExcludeProject: excludeProject, Machine: machine, + GitBranch: gitBranch, Agent: agent, Date: date, DateFrom: dateFrom, @@ -123,6 +129,8 @@ func newSessionListCommand() *cobra.Command { "Exclude sessions from the given project") flags.StringVar(&machine, "machine", "", "Filter by machine name") + flags.StringVar(&branch, "branch", "", + "Filter by git branch name (requires --project)") flags.StringVar(&agent, "agent", "", "Filter by agent (claude, codex, cursor, ...)") flags.StringVar(&date, "date", "", diff --git a/cmd/agentsview/session_search.go b/cmd/agentsview/session_search.go index 54fff1f27..135d6f70a 100644 --- a/cmd/agentsview/session_search.go +++ b/cmd/agentsview/session_search.go @@ -18,7 +18,8 @@ func newSessionSearchCommand() *cobra.Command { in string excludeSystem, reveal bool project, excludeProject, agent string - machine, date, dateFrom, dateTo string + machine, branch string + date, dateFrom, dateTo string activeSince string includeChildren, includeAutomated bool includeOneShot bool @@ -54,6 +55,10 @@ func newSessionSearchCommand() *cobra.Command { case useFTS: mode = "fts" } + gitBranch, err := branchFilterToken(project, branch) + if err != nil { + return err + } svc, cleanup, err := resolveService(cmd) if err != nil { return err @@ -69,6 +74,7 @@ func newSessionSearchCommand() *cobra.Command { Project: project, ExcludeProject: excludeProject, Machine: machine, + GitBranch: gitBranch, Agent: agent, Date: date, DateFrom: dateFrom, @@ -105,6 +111,7 @@ func newSessionSearchCommand() *cobra.Command { flags.StringVar(&project, "project", "", "Filter by project name") flags.StringVar(&excludeProject, "exclude-project", "", "Exclude project") flags.StringVar(&machine, "machine", "", "Filter by machine") + flags.StringVar(&branch, "branch", "", "Filter by git branch name (requires --project)") flags.StringVar(&agent, "agent", "", "Filter by agent") flags.StringVar(&date, "date", "", "Sessions started on YYYY-MM-DD") flags.StringVar(&dateFrom, "date-from", "", "Sessions on or after YYYY-MM-DD") diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 1af05cef1..e211225ac 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -378,6 +378,9 @@ "sidebar_filters_machine": "Machine", "sidebar_filters_search_machines": "Search machines...", "sidebar_filters_no_machines": "No machines", + "sidebar_filters_branch": "Branch", + "sidebar_filters_search_branches": "Search branches...", + "sidebar_filters_no_branches": "No branches", "sidebar_filters_min_prompts": "Min Prompts", "sidebar_filters_clear_filters": "Clear filters", "sidebar_row_expand": "Expand", @@ -396,6 +399,7 @@ "shared_active_filters_label": "Filters:", "shared_active_filters_clear_project": "Clear project filter", "shared_active_filters_remove_machine": "Remove {machine} filter", + "shared_active_filters_remove_branch": "Remove {branch} filter", "shared_active_filters_remove_agent": "Remove {agent} filter", "shared_active_filters_clear_min_prompts": "Clear min prompts filter", "shared_active_filters_min_prompts": "≥{count} prompts", @@ -778,6 +782,7 @@ "activity_project": "Project", "activity_model": "Model", "activity_agent": "Agent", + "activity_branch": "Branch", "activity_min_unit": " min", "activity_int_auto_split": "int {int} / auto {auto}", "activity_breakdown": "Breakdown", diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index 9bad54a70..e0e2fc691 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -368,6 +368,9 @@ "sidebar_filters_machine": "Machine", "sidebar_filters_search_machines": "搜索 machines...", "sidebar_filters_no_machines": "无 machines", + "sidebar_filters_branch": "分支", + "sidebar_filters_search_branches": "搜索分支...", + "sidebar_filters_no_branches": "无分支", "sidebar_filters_min_prompts": "最少提示数", "sidebar_filters_clear_filters": "清除筛选器", "sidebar_row_expand": "展开", @@ -386,6 +389,7 @@ "shared_active_filters_label": "筛选器:", "shared_active_filters_clear_project": "清除项目筛选器", "shared_active_filters_remove_machine": "移除 {machine} 筛选器", + "shared_active_filters_remove_branch": "移除 {branch} 筛选器", "shared_active_filters_remove_agent": "移除 {agent} 筛选器", "shared_active_filters_clear_min_prompts": "清除最少提示数筛选器", "shared_active_filters_min_prompts": "≥{count} 条提示", @@ -757,6 +761,7 @@ "activity_project": "项目", "activity_model": "模型", "activity_agent": "代理", + "activity_branch": "分支", "activity_min_unit": " 分钟", "activity_int_auto_split": "交互 {int} / 自动 {auto}", "activity_breakdown": "细分", diff --git a/frontend/src/lib/components/filters/SessionActiveFilters.svelte b/frontend/src/lib/components/filters/SessionActiveFilters.svelte index 2886494ee..56d2b5dcd 100644 --- a/frontend/src/lib/components/filters/SessionActiveFilters.svelte +++ b/frontend/src/lib/components/filters/SessionActiveFilters.svelte @@ -1,6 +1,9 @@