Skip to content
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a851941
feat(frontend): add status/author filters and sortable columns to ses…
quay-devel May 7, 2026
c9d9976
feat(backend): add phase/userId filters and sort params to sessions l…
quay-devel May 7, 2026
19d847d
fix: remove dead code and reset offset on sort change
quay-devel May 7, 2026
0d2928f
feat(frontend): add sortable Name column to sessions table
quay-devel May 7, 2026
4690f30
fix: address CodeRabbit review feedback
quay-devel May 7, 2026
8b25574
Merge branch 'main' into feature/issue-1515-session-filters-sort
mergify[bot] May 7, 2026
6a7ad66
Merge branch 'main' into feature/issue-1515-session-filters-sort
mergify[bot] May 7, 2026
240d7d8
Merge branch 'main' into feature/issue-1515-session-filters-sort
mergify[bot] May 7, 2026
66741d3
Merge branch 'main' into feature/issue-1515-session-filters-sort
mergify[bot] May 7, 2026
99735b0
Merge branch 'main' into feature/issue-1515-session-filters-sort
mergify[bot] May 7, 2026
8a2ea43
Merge branch 'main' into feature/issue-1515-session-filters-sort
mergify[bot] May 7, 2026
7175c7d
Merge branch 'main' into feature/issue-1515-session-filters-sort
mergify[bot] May 7, 2026
60ddb6a
Merge branch 'main' into feature/issue-1515-session-filters-sort
mergify[bot] May 8, 2026
c60fbcf
Merge branch 'main' into feature/issue-1515-session-filters-sort
mergify[bot] May 8, 2026
120b47c
Merge branch 'main' into feature/issue-1515-session-filters-sort
mergify[bot] May 8, 2026
5304764
Merge branch 'main' into feature/issue-1515-session-filters-sort
mergify[bot] May 8, 2026
dcc6085
Merge branch 'main' into feature/issue-1515-session-filters-sort
mergify[bot] May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 80 additions & 9 deletions components/backend/handlers/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -687,8 +687,18 @@ func ListSessions(c *gin.Context) {
sessions = filterSessionsBySearch(sessions, params.Search)
}

// Sort by creation timestamp (newest first)
sortSessionsByCreationTime(sessions)
// Apply phase filter if provided
if params.Phase != "" {
sessions = filterSessionsByPhase(sessions, params.Phase)
}

// Apply userId filter if provided
if params.UserID != "" {
sessions = filterSessionsByUserID(sessions, params.UserID)
}

// Sort sessions
sortSessions(sessions, params.SortBy, params.SortDirection)

// Apply pagination
totalCount := len(sessions)
Expand Down Expand Up @@ -747,14 +757,75 @@ func filterSessionsBySearch(sessions []types.AgenticSession, search string) []ty
return filtered
}

// sortSessionsByCreationTime sorts sessions by creation timestamp (newest first)
func sortSessionsByCreationTime(sessions []types.AgenticSession) {
// Use sort.Slice for O(n log n) performance
// filterSessionsByPhase filters sessions by one or more comma-separated phase values
func filterSessionsByPhase(sessions []types.AgenticSession, phaseParam string) []types.AgenticSession {
if phaseParam == "" {
return sessions
}
phases := strings.Split(phaseParam, ",")
phaseSet := make(map[string]bool, len(phases))
for _, p := range phases {
phaseSet[strings.TrimSpace(p)] = true
}
filtered := make([]types.AgenticSession, 0, len(sessions))
for _, session := range sessions {
if session.Status != nil && phaseSet[session.Status.Phase] {
filtered = append(filtered, session)
}
}
return filtered
}

// filterSessionsByUserID filters sessions by creator userId
func filterSessionsByUserID(sessions []types.AgenticSession, userID string) []types.AgenticSession {
if userID == "" {
return sessions
}
filtered := make([]types.AgenticSession, 0, len(sessions))
for _, session := range sessions {
if session.Spec.UserContext != nil && session.Spec.UserContext.UserID == userID {
filtered = append(filtered, session)
}
}
return filtered
}

// sortSessions sorts sessions by the given column and direction
func sortSessions(sessions []types.AgenticSession, sortBy, sortDirection string) {
if sortBy == "" {
sortBy = "created"
}
if sortDirection == "" {
sortDirection = "desc"
}
ascending := sortDirection == "asc"

sort.Slice(sessions, func(i, j int) bool {
ts1 := getSessionCreationTimestamp(sessions[i])
ts2 := getSessionCreationTimestamp(sessions[j])
// Sort descending (newest first) - RFC3339 timestamps sort lexicographically
return ts1 > ts2
var vi, vj string
switch sortBy {
case "name":
ni := sessions[i].Spec.DisplayName
if strings.TrimSpace(ni) == "" {
if name, ok := sessions[i].Metadata["name"].(string); ok {
ni = name
}
}
nj := sessions[j].Spec.DisplayName
if strings.TrimSpace(nj) == "" {
if name, ok := sessions[j].Metadata["name"].(string); ok {
nj = name
}
}
vi = strings.ToLower(ni)
vj = strings.ToLower(nj)
default: // "created" or any unrecognized value
vi = getSessionCreationTimestamp(sessions[i])
vj = getSessionCreationTimestamp(sessions[j])
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if ascending {
return vi < vj
}
return vi > vj
})
Comment on lines 803 to 829
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add deterministic tie-breaker in sortSessions to avoid pagination jitter

When vi == vj, the comparator returns false both ways. With sort.Slice and pagination, equal-key rows can reorder between requests, causing duplicate/missing sessions across pages. Add a stable secondary key (e.g., metadata.name) before returning.

Suggested fix
  sort.Slice(sessions, func(i, j int) bool {
    var vi, vj string
    switch sortBy {
    case "name":
      ni := sessions[i].Spec.DisplayName
      if strings.TrimSpace(ni) == "" {
        if name, ok := sessions[i].Metadata["name"].(string); ok {
          ni = name
        }
      }
      nj := sessions[j].Spec.DisplayName
      if strings.TrimSpace(nj) == "" {
        if name, ok := sessions[j].Metadata["name"].(string); ok {
          nj = name
        }
      }
      vi = strings.ToLower(ni)
      vj = strings.ToLower(nj)
    default:
      vi = getSessionCreationTimestamp(sessions[i])
      vj = getSessionCreationTimestamp(sessions[j])
    }
+   if vi == vj {
+     ni, _ := sessions[i].Metadata["name"].(string)
+     nj, _ := sessions[j].Metadata["name"].(string)
+     if ascending {
+       return strings.ToLower(ni) < strings.ToLower(nj)
+     }
+     return strings.ToLower(ni) > strings.ToLower(nj)
+   }
    if ascending {
      return vi < vj
    }
    return vi > vj
  })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
sort.Slice(sessions, func(i, j int) bool {
ts1 := getSessionCreationTimestamp(sessions[i])
ts2 := getSessionCreationTimestamp(sessions[j])
// Sort descending (newest first) - RFC3339 timestamps sort lexicographically
return ts1 > ts2
var vi, vj string
switch sortBy {
case "name":
ni := sessions[i].Spec.DisplayName
if strings.TrimSpace(ni) == "" {
if name, ok := sessions[i].Metadata["name"].(string); ok {
ni = name
}
}
nj := sessions[j].Spec.DisplayName
if strings.TrimSpace(nj) == "" {
if name, ok := sessions[j].Metadata["name"].(string); ok {
nj = name
}
}
vi = strings.ToLower(ni)
vj = strings.ToLower(nj)
default: // "created" or any unrecognized value
vi = getSessionCreationTimestamp(sessions[i])
vj = getSessionCreationTimestamp(sessions[j])
}
if ascending {
return vi < vj
}
return vi > vj
})
sort.Slice(sessions, func(i, j int) bool {
var vi, vj string
switch sortBy {
case "name":
ni := sessions[i].Spec.DisplayName
if strings.TrimSpace(ni) == "" {
if name, ok := sessions[i].Metadata["name"].(string); ok {
ni = name
}
}
nj := sessions[j].Spec.DisplayName
if strings.TrimSpace(nj) == "" {
if name, ok := sessions[j].Metadata["name"].(string); ok {
nj = name
}
}
vi = strings.ToLower(ni)
vj = strings.ToLower(nj)
default: // "created" or any unrecognized value
vi = getSessionCreationTimestamp(sessions[i])
vj = getSessionCreationTimestamp(sessions[j])
}
if vi == vj {
ni, _ := sessions[i].Metadata["name"].(string)
nj, _ := sessions[j].Metadata["name"].(string)
if ascending {
return strings.ToLower(ni) < strings.ToLower(nj)
}
return strings.ToLower(ni) > strings.ToLower(nj)
}
if ascending {
return vi < vj
}
return vi > vj
})
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/backend/handlers/sessions.go` around lines 803 - 829, The
comparator used in sort.Slice (inside sortSessions) lacks a deterministic
tie-breaker so when vi == vj the order is non-deterministic; update the
comparator in the sort.Slice call to, after computing vi and vj (the primary
keys from Spec.DisplayName or getSessionCreationTimestamp), check if vi == vj
and then compare a stable secondary key such as sessions[i].Metadata["name"]
(string) or another unique identifier (e.g., a session ID field) to break ties;
ensure the secondary comparison respects ascending/descending by inverting the
result when ascending is false so pagination becomes deterministic.

}

Expand Down
238 changes: 238 additions & 0 deletions components/backend/handlers/sessions_test.go
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,204 @@ var _ = Describe("Sessions Handler", Label(test_constants.LabelUnit, test_consta
logger.Log("Unauthorized project returned empty list")
})
})

Context("With phase filter", func() {
BeforeEach(func() {
createTestSessionWithOptions("running-"+randomName, testNamespace, "Running", "", k8sUtils)
createTestSessionWithOptions("completed-"+randomName, testNamespace, "Completed", "", k8sUtils)
createTestSessionWithOptions("failed-"+randomName, testNamespace, "Failed", "", k8sUtils)
})

It("Should filter by single phase", func() {
context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions?phase=Running", nil)
httpUtils.SetAuthHeader(testToken)
httpUtils.SetProjectContext(testNamespace)

ListSessions(context)

httpUtils.AssertHTTPStatus(http.StatusOK)
var response map[string]interface{}
httpUtils.GetResponseJSON(&response)
items := response["items"].([]interface{})
Expect(items).To(HaveLen(1))
})

It("Should filter by multiple phases", func() {
context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions?phase=Running,Failed", nil)
httpUtils.SetAuthHeader(testToken)
httpUtils.SetProjectContext(testNamespace)

ListSessions(context)

httpUtils.AssertHTTPStatus(http.StatusOK)
var response map[string]interface{}
httpUtils.GetResponseJSON(&response)
items := response["items"].([]interface{})
Expect(items).To(HaveLen(2))
})

It("Should return all sessions when no phase filter", func() {
context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions", nil)
httpUtils.SetAuthHeader(testToken)
httpUtils.SetProjectContext(testNamespace)

ListSessions(context)

httpUtils.AssertHTTPStatus(http.StatusOK)
var response map[string]interface{}
httpUtils.GetResponseJSON(&response)
items := response["items"].([]interface{})
Expect(items).To(HaveLen(3))
})
})

Context("With userId filter", func() {
BeforeEach(func() {
createTestSessionWithOptions("user1-session-"+randomName, testNamespace, "Running", "user-1", k8sUtils)
createTestSessionWithOptions("user2-session-"+randomName, testNamespace, "Running", "user-2", k8sUtils)
createTestSessionWithOptions("no-user-session-"+randomName, testNamespace, "Running", "", k8sUtils)
})

It("Should filter by userId", func() {
context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions?userId=user-1", nil)
httpUtils.SetAuthHeader(testToken)
httpUtils.SetProjectContext(testNamespace)

ListSessions(context)

httpUtils.AssertHTTPStatus(http.StatusOK)
var response map[string]interface{}
httpUtils.GetResponseJSON(&response)
items := response["items"].([]interface{})
Expect(items).To(HaveLen(1))
})

It("Should return all sessions when no userId filter", func() {
context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions", nil)
httpUtils.SetAuthHeader(testToken)
httpUtils.SetProjectContext(testNamespace)

ListSessions(context)

httpUtils.AssertHTTPStatus(http.StatusOK)
var response map[string]interface{}
httpUtils.GetResponseJSON(&response)
items := response["items"].([]interface{})
Expect(items).To(HaveLen(3))
})
})

Context("With sort direction", func() {
BeforeEach(func() {
createTestSessionWithOptions("alpha-"+randomName, testNamespace, "Running", "", k8sUtils)
time.Sleep(100 * time.Millisecond)
createTestSessionWithOptions("beta-"+randomName, testNamespace, "Running", "", k8sUtils)
})

It("Should sort ascending when sortDirection=asc", func() {
context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions?sortDirection=asc", nil)
httpUtils.SetAuthHeader(testToken)
httpUtils.SetProjectContext(testNamespace)

ListSessions(context)

httpUtils.AssertHTTPStatus(http.StatusOK)
var response map[string]interface{}
httpUtils.GetResponseJSON(&response)
items := response["items"].([]interface{})
Expect(items).To(HaveLen(2))
// First item should be the oldest (alpha was created first)
firstItem := items[0].(map[string]interface{})
metadata := firstItem["metadata"].(map[string]interface{})
name := metadata["name"].(string)
Expect(name).To(HavePrefix("alpha-"))
})

It("Should sort descending by default", func() {
context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions", nil)
httpUtils.SetAuthHeader(testToken)
httpUtils.SetProjectContext(testNamespace)

ListSessions(context)

httpUtils.AssertHTTPStatus(http.StatusOK)
var response map[string]interface{}
httpUtils.GetResponseJSON(&response)
items := response["items"].([]interface{})
Expect(items).To(HaveLen(2))
// First item should be the newest (beta was created second)
firstItem := items[0].(map[string]interface{})
metadata := firstItem["metadata"].(map[string]interface{})
name := metadata["name"].(string)
Expect(name).To(HavePrefix("beta-"))
})
})

Context("With sortBy=name", func() {
BeforeEach(func() {
createTestSessionWithOptions("alpha-"+randomName, testNamespace, "Running", "", k8sUtils)
time.Sleep(100 * time.Millisecond)
createTestSessionWithOptions("beta-"+randomName, testNamespace, "Running", "", k8sUtils)
})

It("Should sort by name ascending", func() {
context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions?sortBy=name&sortDirection=asc", nil)
httpUtils.SetAuthHeader(testToken)
httpUtils.SetProjectContext(testNamespace)

ListSessions(context)

httpUtils.AssertHTTPStatus(http.StatusOK)
var response map[string]interface{}
httpUtils.GetResponseJSON(&response)
items := response["items"].([]interface{})
Expect(items).To(HaveLen(2))
firstName := items[0].(map[string]interface{})["metadata"].(map[string]interface{})["name"].(string)
secondName := items[1].(map[string]interface{})["metadata"].(map[string]interface{})["name"].(string)
Expect(firstName).To(HavePrefix("alpha-"))
Expect(secondName).To(HavePrefix("beta-"))
})

It("Should sort by name descending", func() {
context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions?sortBy=name&sortDirection=desc", nil)
httpUtils.SetAuthHeader(testToken)
httpUtils.SetProjectContext(testNamespace)

ListSessions(context)

httpUtils.AssertHTTPStatus(http.StatusOK)
var response map[string]interface{}
httpUtils.GetResponseJSON(&response)
items := response["items"].([]interface{})
Expect(items).To(HaveLen(2))
firstName := items[0].(map[string]interface{})["metadata"].(map[string]interface{})["name"].(string)
secondName := items[1].(map[string]interface{})["metadata"].(map[string]interface{})["name"].(string)
Expect(firstName).To(HavePrefix("beta-"))
Expect(secondName).To(HavePrefix("alpha-"))
})
})

Context("With combined filters", func() {
BeforeEach(func() {
createTestSessionWithOptions("running-match-"+randomName, testNamespace, "Running", "user-1", k8sUtils)
createTestSessionWithOptions("completed-match-"+randomName, testNamespace, "Completed", "user-1", k8sUtils)
createTestSessionWithOptions("running-other-"+randomName, testNamespace, "Running", "user-2", k8sUtils)
})

It("Should apply both phase and userId filters", func() {
context := httpUtils.CreateTestGinContext("GET", "/api/projects/"+testNamespace+"/agentic-sessions?phase=Running&userId=user-1", nil)
httpUtils.SetAuthHeader(testToken)
httpUtils.SetProjectContext(testNamespace)

ListSessions(context)

httpUtils.AssertHTTPStatus(http.StatusOK)
var response map[string]interface{}
httpUtils.GetResponseJSON(&response)
items := response["items"].([]interface{})
Expect(items).To(HaveLen(1))
})
})
})

Describe("CreateSession", func() {
Expand Down Expand Up @@ -981,3 +1179,43 @@ func createTestSession(name, namespace string, k8sUtils *test_utils.K8sTestUtils
}
return created
}

func createTestSessionWithOptions(name, namespace, phase, userId string, k8sUtils *test_utils.K8sTestUtils) *unstructured.Unstructured {
session := &unstructured.Unstructured{}
session.SetAPIVersion("vteam.ambient-code/v1alpha1")
session.SetKind("AgenticSession")
session.SetName(name)
session.SetNamespace(namespace)
session.SetLabels(map[string]string{"test-framework": "ambient-code-backend"})

unstructured.SetNestedField(session.Object, "Test prompt for "+name, "spec", "initialPrompt")
repos := []interface{}{
map[string]interface{}{"url": "https://github.com/test/repo.git", "branch": "main"},
}
unstructured.SetNestedSlice(session.Object, repos, "spec", "repos")

if phase != "" {
unstructured.SetNestedField(session.Object, phase, "status", "phase")
} else {
unstructured.SetNestedField(session.Object, "Pending", "status", "phase")
}

if userId != "" {
unstructured.SetNestedField(session.Object, userId, "spec", "userContext", "userId")
}

sessionGVR := schema.GroupVersionResource{
Group: "vteam.ambient-code",
Version: "v1alpha1",
Resource: "agenticsessions",
}

created, err := k8sUtils.DynamicClient.Resource(sessionGVR).Namespace(namespace).Create(
context.Background(), session, v1.CreateOptions{},
)
if err != nil {
Fail(fmt.Sprintf("Failed to create test session %s: %v", name, err))
return nil
}
return created
}
12 changes: 8 additions & 4 deletions components/backend/types/common.go
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,14 @@ func IntPtr(i int) *int {

// PaginationParams represents common pagination request parameters
type PaginationParams struct {
Limit int `form:"limit"` // Number of items per page (default: 20, max: 100)
Offset int `form:"offset"` // Offset for offset-based pagination
Continue string `form:"continue"` // Continuation token for k8s-style pagination
Search string `form:"search"` // Search/filter term
Limit int `form:"limit"` // Number of items per page (default: 20, max: 100)
Offset int `form:"offset"` // Offset for offset-based pagination
Continue string `form:"continue"` // Continuation token for k8s-style pagination
Search string `form:"search"` // Search/filter term
Phase string `form:"phase"` // Comma-separated phases to filter by
UserID string `form:"userId"` // Filter by creator userId
SortBy string `form:"sortBy"` // Sort column (default: "created")
SortDirection string `form:"sortDirection"` // "asc" or "desc" (default: "desc")
}

// PaginatedResponse is a generic paginated response structure
Expand Down
Loading