From a851941af7acf49aae0f55f0e1b0b2dc6d813001 Mon Sep 17 00:00:00 2001 From: Quay Robot Date: Thu, 7 May 2026 08:04:38 +0000 Subject: [PATCH 1/5] feat(frontend): add status/author filters and sortable columns to sessions list Add phase filter dropdown (Active/Completed/Failed), "My sessions" toggle using current user identity, and clickable sortable Created column header. Filter params flow through PaginationParams -> API layer -> query keys for proper cache isolation. Includes 4 new tests for filter param passthrough. Ref: #1515 Co-Authored-By: Claude Opus 4.6 --- .../workspace-sections/sessions-section.tsx | 128 ++++++++++++++---- .../frontend/src/services/api/sessions.ts | 4 + .../queries/__tests__/use-sessions.test.ts | 35 +++++ components/frontend/src/types/api/common.ts | 4 + 4 files changed, 144 insertions(+), 27 deletions(-) mode change 100644 => 100755 components/frontend/src/components/workspace-sections/sessions-section.tsx mode change 100644 => 100755 components/frontend/src/types/api/common.ts diff --git a/components/frontend/src/components/workspace-sections/sessions-section.tsx b/components/frontend/src/components/workspace-sections/sessions-section.tsx old mode 100644 new mode 100755 index cbc02854e..e2b086420 --- a/components/frontend/src/components/workspace-sections/sessions-section.tsx +++ b/components/frontend/src/components/workspace-sections/sessions-section.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { formatDistanceToNow } from 'date-fns'; -import { Plus, RefreshCw, MoreVertical, Square, Trash2, ArrowRight, Brain, Search, Pencil, Clock, Cpu, MessageSquare, NotepadText, User } from 'lucide-react'; +import { Plus, RefreshCw, MoreVertical, Square, Trash2, ArrowRight, Brain, Search, Pencil, Clock, Cpu, MessageSquare, NotepadText, User, ArrowUp, ArrowDown } from 'lucide-react'; import Link from 'next/link'; import { Button } from '@/components/ui/button'; @@ -11,6 +11,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Badge } from '@/components/ui/badge'; import { Pagination, PaginationContent, @@ -28,6 +30,7 @@ import { deriveAgentStatusFromPhase } from '@/hooks/use-agent-status'; import { EditSessionNameDialog } from '@/components/edit-session-name-dialog'; import { useSessionsPaginated, useStopSession, useDeleteSession, useContinueSession, useUpdateSessionDisplayName, useRunnerTypes } from '@/services/queries'; +import { useCurrentUser } from '@/services/queries/use-auth'; import { toast } from 'sonner'; import { useWorkspaceList } from '@/services/queries/use-workspace'; import { useProjectAccess } from '@/services/queries/use-project-access'; @@ -66,18 +69,24 @@ type SessionsSectionProps = { }; export function SessionsSection({ projectName }: SessionsSectionProps) { - // Pagination and search state + // Pagination, search, and filter state const [searchInput, setSearchInput] = useState(''); const [offset, setOffset] = useState(0); const limit = DEFAULT_PAGE_SIZE; + const [phaseFilter, setPhaseFilter] = useState(''); + const [mySessionsOnly, setMySessionsOnly] = useState(false); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); // Debounce search to avoid too many API calls const debouncedSearch = useDebounce(searchInput, 300); - // Reset offset when search changes + // Current user for "My sessions" filter + const { data: currentUser } = useCurrentUser(); + + // Reset offset when search or filters change useEffect(() => { setOffset(0); - }, [debouncedSearch]); + }, [debouncedSearch, phaseFilter, mySessionsOnly]); // Access control (default-deny until role is resolved) const { data: access } = useProjectAccess(projectName); @@ -104,6 +113,10 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { limit, offset, search: debouncedSearch || undefined, + phase: phaseFilter || undefined, + userId: mySessionsOnly ? currentUser?.userId : undefined, + sortBy: 'created', + sortDirection, }); const sessions = paginatedData?.items ?? []; @@ -228,31 +241,84 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { )} - {/* Search input */} -
- - + {/* Search and filters */} +
+
+ + +
+ + + +
+ + {/* Active filter chips */} + {(phaseFilter || mySessionsOnly) && ( +
+ Filters: + {phaseFilter && ( + setPhaseFilter('')}> + {phaseFilter === 'Running,Pending,Creating' ? 'Active' : phaseFilter === 'Completed,Stopped' ? 'Completed' : phaseFilter} + × + + )} + {mySessionsOnly && ( + setMySessionsOnly(false)}> + My sessions + × + + )} +
+ )} - {sessions.length === 0 && !debouncedSearch ? ( - - ) : sessions.length === 0 && debouncedSearch ? ( - - ) : ( + {(() => { + const hasActiveFilters = !!debouncedSearch || !!phaseFilter || mySessionsOnly; + if (sessions.length === 0 && !hasActiveFilters) { + return ( + + ); + } + if (sessions.length === 0 && hasActiveFilters) { + return ( + + ); + } + return null; + })()} + {sessions.length > 0 && ( <>
@@ -262,7 +328,15 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { NameStatusModel - Created + setSortDirection(prev => prev === 'desc' ? 'asc' : 'desc')} + > +
+ Created + {sortDirection === 'desc' ? : } +
+
CreatorArtifactsActions diff --git a/components/frontend/src/services/api/sessions.ts b/components/frontend/src/services/api/sessions.ts index 24ff0cc62..a453053e0 100755 --- a/components/frontend/src/services/api/sessions.ts +++ b/components/frontend/src/services/api/sessions.ts @@ -55,6 +55,10 @@ export async function listSessionsPaginated( if (params.offset) searchParams.set('offset', params.offset.toString()); if (params.search) searchParams.set('search', params.search); if (params.continue) searchParams.set('continue', params.continue); + if (params.phase) searchParams.set('phase', params.phase); + if (params.userId) searchParams.set('userId', params.userId); + if (params.sortBy) searchParams.set('sortBy', params.sortBy); + if (params.sortDirection) searchParams.set('sortDirection', params.sortDirection); const queryString = searchParams.toString(); const url = queryString diff --git a/components/frontend/src/services/queries/__tests__/use-sessions.test.ts b/components/frontend/src/services/queries/__tests__/use-sessions.test.ts index 627913243..a61b2c93b 100755 --- a/components/frontend/src/services/queries/__tests__/use-sessions.test.ts +++ b/components/frontend/src/services/queries/__tests__/use-sessions.test.ts @@ -68,6 +68,11 @@ describe('sessionKeys', () => { expect(sessionKeys.export('proj', 'sess')).toEqual(['v1', 'sessions', 'detail', 'proj', 'sess', 'export']); expect(sessionKeys.reposStatus('proj', 'sess')).toEqual(['v1', 'sessions', 'detail', 'proj', 'sess', 'repos-status']); }); + + it('includes filter params in query key', () => { + const key = sessionKeys.list('proj', { phase: 'Running', userId: 'user-1' }); + expect(key).toEqual(['v1', 'sessions', 'list', 'proj', { phase: 'Running', userId: 'user-1' }]); + }); }); describe('useSessions', () => { @@ -102,6 +107,36 @@ describe('useSessionsPaginated', () => { expect(fakePort.listSessions).toHaveBeenCalledWith('proj', { limit: 10 }); expect(result.current.data?.totalCount).toBe(1); }); + + it('passes phase filter to port', async () => { + const fakePort = createFakeSessionsPort(); + const { result } = renderHook( + () => useSessionsPaginated('proj', { limit: 10, phase: 'Running,Pending' }, fakePort), + { wrapper: createWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(fakePort.listSessions).toHaveBeenCalledWith('proj', { limit: 10, phase: 'Running,Pending' }); + }); + + it('passes userId filter to port', async () => { + const fakePort = createFakeSessionsPort(); + const { result } = renderHook( + () => useSessionsPaginated('proj', { limit: 10, userId: 'user-1' }, fakePort), + { wrapper: createWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(fakePort.listSessions).toHaveBeenCalledWith('proj', { limit: 10, userId: 'user-1' }); + }); + + it('passes sort params to port', async () => { + const fakePort = createFakeSessionsPort(); + const { result } = renderHook( + () => useSessionsPaginated('proj', { sortBy: 'created', sortDirection: 'asc' }, fakePort), + { wrapper: createWrapper() }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(fakePort.listSessions).toHaveBeenCalledWith('proj', { sortBy: 'created', sortDirection: 'asc' }); + }); }); describe('useSession', () => { diff --git a/components/frontend/src/types/api/common.ts b/components/frontend/src/types/api/common.ts old mode 100644 new mode 100755 index 6244ea1ad..9e197b8d3 --- a/components/frontend/src/types/api/common.ts +++ b/components/frontend/src/types/api/common.ts @@ -23,6 +23,10 @@ export type PaginationParams = { offset?: number; search?: string; continue?: string; + phase?: string; + userId?: string; + sortBy?: string; + sortDirection?: 'asc' | 'desc'; }; /** From c9d9976ab2a2663e453a2ac5a1eab51466922261 Mon Sep 17 00:00:00 2001 From: Quay Robot Date: Thu, 7 May 2026 08:06:43 +0000 Subject: [PATCH 2/5] feat(backend): add phase/userId filters and sort params to sessions list endpoint Add server-side filtering by phase and userId, plus configurable sort direction to the ListSessions handler, supporting the sessions page filter/sort UI (#1515). Co-Authored-By: Claude Opus 4.6 --- components/backend/handlers/sessions.go | 78 +++++++- components/backend/handlers/sessions_test.go | 194 +++++++++++++++++++ components/backend/types/common.go | 12 +- 3 files changed, 278 insertions(+), 6 deletions(-) mode change 100644 => 100755 components/backend/handlers/sessions_test.go mode change 100644 => 100755 components/backend/types/common.go diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index f49613ee5..8fa268060 100755 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -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) @@ -747,6 +757,70 @@ func filterSessionsBySearch(sessions []types.AgenticSession, search string) []ty return filtered } +// 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 { + var vi, vj string + switch sortBy { + case "name": + if name, ok := sessions[i].Metadata["name"].(string); ok { + vi = strings.ToLower(name) + } + if name, ok := sessions[j].Metadata["name"].(string); ok { + vj = strings.ToLower(name) + } + default: // "created" or any unrecognized value + vi = getSessionCreationTimestamp(sessions[i]) + vj = getSessionCreationTimestamp(sessions[j]) + } + if ascending { + return vi < vj + } + return vi > vj + }) +} + // sortSessionsByCreationTime sorts sessions by creation timestamp (newest first) func sortSessionsByCreationTime(sessions []types.AgenticSession) { // Use sort.Slice for O(n log n) performance diff --git a/components/backend/handlers/sessions_test.go b/components/backend/handlers/sessions_test.go old mode 100644 new mode 100755 index 529748d7a..5c84b7dcc --- a/components/backend/handlers/sessions_test.go +++ b/components/backend/handlers/sessions_test.go @@ -252,6 +252,160 @@ 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 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() { @@ -981,3 +1135,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 +} diff --git a/components/backend/types/common.go b/components/backend/types/common.go old mode 100644 new mode 100755 index 13745df0b..51e5a817b --- a/components/backend/types/common.go +++ b/components/backend/types/common.go @@ -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 From 19d847dcf93c9d63f89e9c5d858780dda6e8233d Mon Sep 17 00:00:00 2001 From: Quay Robot Date: Thu, 7 May 2026 08:10:17 +0000 Subject: [PATCH 3/5] fix: remove dead code and reset offset on sort change - Remove unused sortSessionsByCreationTime() function (replaced by sortSessions()) - Add sortDirection to offset reset dependency array for correct UX on sort toggle Co-Authored-By: Claude Opus 4.6 --- components/backend/handlers/sessions.go | 11 ----------- .../workspace-sections/sessions-section.tsx | 2 +- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index 8fa268060..6711e8edc 100755 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -821,17 +821,6 @@ func sortSessions(sessions []types.AgenticSession, sortBy, sortDirection string) }) } -// sortSessionsByCreationTime sorts sessions by creation timestamp (newest first) -func sortSessionsByCreationTime(sessions []types.AgenticSession) { - // Use sort.Slice for O(n log n) performance - 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 - }) -} - // getSessionCreationTimestamp extracts the creation timestamp from session metadata func getSessionCreationTimestamp(session types.AgenticSession) string { if ts, ok := session.Metadata["creationTimestamp"].(string); ok { diff --git a/components/frontend/src/components/workspace-sections/sessions-section.tsx b/components/frontend/src/components/workspace-sections/sessions-section.tsx index e2b086420..96781923a 100755 --- a/components/frontend/src/components/workspace-sections/sessions-section.tsx +++ b/components/frontend/src/components/workspace-sections/sessions-section.tsx @@ -86,7 +86,7 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { // Reset offset when search or filters change useEffect(() => { setOffset(0); - }, [debouncedSearch, phaseFilter, mySessionsOnly]); + }, [debouncedSearch, phaseFilter, mySessionsOnly, sortDirection]); // Access control (default-deny until role is resolved) const { data: access } = useProjectAccess(projectName); From 0d2928ff5efb09da07f0178d6b68613d18146c91 Mon Sep 17 00:00:00 2001 From: Quay Robot Date: Thu, 7 May 2026 08:18:36 +0000 Subject: [PATCH 4/5] feat(frontend): add sortable Name column to sessions table Make the Name column header clickable for sorting, matching the existing Created column behavior. Clicking the active sort column toggles direction; clicking an inactive column switches to it with a sensible default (asc for name, desc for created). Arrow icons only show on the active sort column. The backend already supports sortBy=name, so no backend changes needed. Refs #1515 --- .../workspace-sections/sessions-section.tsx | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/components/frontend/src/components/workspace-sections/sessions-section.tsx b/components/frontend/src/components/workspace-sections/sessions-section.tsx index 96781923a..91d791023 100755 --- a/components/frontend/src/components/workspace-sections/sessions-section.tsx +++ b/components/frontend/src/components/workspace-sections/sessions-section.tsx @@ -75,6 +75,7 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { const limit = DEFAULT_PAGE_SIZE; const [phaseFilter, setPhaseFilter] = useState(''); const [mySessionsOnly, setMySessionsOnly] = useState(false); + const [sortBy, setSortBy] = useState<'created' | 'name'>('created'); const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); // Debounce search to avoid too many API calls @@ -86,7 +87,7 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { // Reset offset when search or filters change useEffect(() => { setOffset(0); - }, [debouncedSearch, phaseFilter, mySessionsOnly, sortDirection]); + }, [debouncedSearch, phaseFilter, mySessionsOnly, sortBy, sortDirection]); // Access control (default-deny until role is resolved) const { data: access } = useProjectAccess(projectName); @@ -115,7 +116,7 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { search: debouncedSearch || undefined, phase: phaseFilter || undefined, userId: mySessionsOnly ? currentUser?.userId : undefined, - sortBy: 'created', + sortBy, sortDirection, }); @@ -325,16 +326,38 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { - Name + { + if (sortBy === 'name') { + setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); + } else { + setSortBy('name'); + setSortDirection('asc'); + } + }} + > +
+ Name + {sortBy === 'name' && (sortDirection === 'asc' ? : )} +
+
Status Model setSortDirection(prev => prev === 'desc' ? 'asc' : 'desc')} + onClick={() => { + if (sortBy === 'created') { + setSortDirection(prev => prev === 'desc' ? 'asc' : 'desc'); + } else { + setSortBy('created'); + setSortDirection('desc'); + } + }} >
Created - {sortDirection === 'desc' ? : } + {sortBy === 'created' && (sortDirection === 'desc' ? : )}
Creator From 4690f308afca1d27b78a2b1fa701d7e24562916a Mon Sep 17 00:00:00 2001 From: Quay Robot Date: Thu, 7 May 2026 08:26:49 +0000 Subject: [PATCH 5/5] fix: address CodeRabbit review feedback - Sort-by-name now uses displayName with fallback to metadata.name, matching what the UI displays - "My sessions" toggle disabled while currentUser is loading, and userId guard prevents unfiltered queries when user is unresolved - Sortable table headers are keyboard-accessible (tabIndex, role, onKeyDown, aria-sort) - Added sortBy=name test coverage (ascending and descending) Co-Authored-By: Claude Opus 4.6 --- components/backend/handlers/sessions.go | 16 +++++-- components/backend/handlers/sessions_test.go | 44 +++++++++++++++++++ .../workspace-sections/sessions-section.tsx | 33 +++++++++++++- 3 files changed, 87 insertions(+), 6 deletions(-) diff --git a/components/backend/handlers/sessions.go b/components/backend/handlers/sessions.go index 6711e8edc..2e3b4ca1c 100755 --- a/components/backend/handlers/sessions.go +++ b/components/backend/handlers/sessions.go @@ -804,12 +804,20 @@ func sortSessions(sessions []types.AgenticSession, sortBy, sortDirection string) var vi, vj string switch sortBy { case "name": - if name, ok := sessions[i].Metadata["name"].(string); ok { - vi = strings.ToLower(name) + ni := sessions[i].Spec.DisplayName + if strings.TrimSpace(ni) == "" { + if name, ok := sessions[i].Metadata["name"].(string); ok { + ni = name + } } - if name, ok := sessions[j].Metadata["name"].(string); ok { - vj = strings.ToLower(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]) diff --git a/components/backend/handlers/sessions_test.go b/components/backend/handlers/sessions_test.go index 5c84b7dcc..a99890690 100755 --- a/components/backend/handlers/sessions_test.go +++ b/components/backend/handlers/sessions_test.go @@ -385,6 +385,50 @@ var _ = Describe("Sessions Handler", Label(test_constants.LabelUnit, test_consta }) }) + 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) diff --git a/components/frontend/src/components/workspace-sections/sessions-section.tsx b/components/frontend/src/components/workspace-sections/sessions-section.tsx index 91d791023..75eb3a235 100755 --- a/components/frontend/src/components/workspace-sections/sessions-section.tsx +++ b/components/frontend/src/components/workspace-sections/sessions-section.tsx @@ -82,7 +82,7 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { const debouncedSearch = useDebounce(searchInput, 300); // Current user for "My sessions" filter - const { data: currentUser } = useCurrentUser(); + const { data: currentUser, isLoading: isCurrentUserLoading } = useCurrentUser(); // Reset offset when search or filters change useEffect(() => { @@ -115,7 +115,7 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { offset, search: debouncedSearch || undefined, phase: phaseFilter || undefined, - userId: mySessionsOnly ? currentUser?.userId : undefined, + userId: mySessionsOnly && currentUser?.userId ? currentUser.userId : undefined, sortBy, sortDirection, }); @@ -270,6 +270,7 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { variant={mySessionsOnly ? 'default' : 'outline'} size="sm" onClick={() => setMySessionsOnly(!mySessionsOnly)} + disabled={isCurrentUserLoading} className="h-9" > @@ -328,6 +329,9 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { { if (sortBy === 'name') { setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); @@ -336,6 +340,17 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { setSortDirection('asc'); } }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (sortBy === 'name') { + setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); + } else { + setSortBy('name'); + setSortDirection('asc'); + } + } + }} >
Name @@ -346,6 +361,9 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { Model { if (sortBy === 'created') { setSortDirection(prev => prev === 'desc' ? 'asc' : 'desc'); @@ -354,6 +372,17 @@ export function SessionsSection({ projectName }: SessionsSectionProps) { setSortDirection('desc'); } }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + if (sortBy === 'created') { + setSortDirection(prev => prev === 'desc' ? 'asc' : 'desc'); + } else { + setSortBy('created'); + setSortDirection('desc'); + } + } + }} >
Created