diff --git a/cmd/list.go b/cmd/list.go index 9bb59bb56..3ef2dbdf4 100644 --- a/cmd/list.go +++ b/cmd/list.go @@ -20,6 +20,7 @@ import ( "fmt" "os" "sort" + "strings" "text/tabwriter" "time" @@ -35,18 +36,31 @@ import ( ) var ( - listAll bool - listDeleted bool - listRunning bool - sortByTime bool + listAll bool + listDeleted bool + listRunning bool + sortByTime bool + filterPhase string + filterActivity string + filterTemplate string + sortField string + sortReverse bool ) +var validSortFields = map[string]bool{ + "name": true, "phase": true, "created": true, "updated": true, "last-seen": true, +} + // listCmd represents the list command var listCmd = &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Short: "List running scion agents", RunE: func(cmd *cobra.Command, args []string) error { + if err := validateListFlags(); err != nil { + return err + } + // Check if Hub should be used hubCtx, err := CheckHubAvailability(projectPath) if err != nil { @@ -108,6 +122,7 @@ func listAgentsViaHub(hubCtx *HubContext) error { opts := &hubclient.ListAgentsOptions{ IncludeDeleted: listDeleted, + Phase: filterPhase, } agentSvc := hubCtx.Client.Agents() @@ -269,6 +284,88 @@ func filterRunningAgents(agents []api.AgentInfo) []api.AgentInfo { return filtered } +// validateListFlags checks that filter and sort flag values are valid. +func validateListFlags() error { + if filterPhase != "" { + if !state.Phase(filterPhase).IsValid() { + valid := make([]string, 0, len(state.Phases())) + for _, p := range state.Phases() { + valid = append(valid, string(p)) + } + return fmt.Errorf("invalid phase %q; valid values: %s", filterPhase, strings.Join(valid, ", ")) + } + } + if filterActivity != "" { + if !state.Activity(filterActivity).IsValid() || filterActivity == "" { + valid := make([]string, 0, len(state.Activities())) + for _, a := range state.Activities() { + valid = append(valid, string(a)) + } + return fmt.Errorf("invalid activity %q; valid values: %s", filterActivity, strings.Join(valid, ", ")) + } + } + if sortField != "" && !validSortFields[sortField] { + valid := make([]string, 0, len(validSortFields)) + for k := range validSortFields { + valid = append(valid, k) + } + sort.Strings(valid) + return fmt.Errorf("invalid sort field %q; valid values: %s", sortField, strings.Join(valid, ", ")) + } + return nil +} + +// filterAgentsByFlags applies --phase, --activity, and --template filters. +func filterAgentsByFlags(agents []api.AgentInfo) []api.AgentInfo { + if filterPhase == "" && filterActivity == "" && filterTemplate == "" { + return agents + } + filtered := make([]api.AgentInfo, 0, len(agents)) + for _, a := range agents { + if filterPhase != "" && !strings.EqualFold(a.Phase, filterPhase) { + continue + } + if filterActivity != "" && !strings.EqualFold(a.Activity, filterActivity) { + continue + } + if filterTemplate != "" && !strings.EqualFold(a.Template, filterTemplate) { + continue + } + filtered = append(filtered, a) + } + return filtered +} + +// sortAgentsByField sorts agents by the --sort field. +func sortAgentsByField(agents []api.AgentInfo) { + if sortField == "" { + return + } + sort.SliceStable(agents, func(i, j int) bool { + var less bool + switch sortField { + case "name": + less = strings.ToLower(agents[i].Name) < strings.ToLower(agents[j].Name) + case "phase": + less = agents[i].Phase < agents[j].Phase + case "created": + less = agents[i].Created.Before(agents[j].Created) + case "updated": + less = agents[i].Updated.Before(agents[j].Updated) + case "last-seen": + less = agents[i].LastSeen.Before(agents[j].LastSeen) + default: + return false + } + // Timestamps default to descending (newest first); name/phase default to ascending + descByDefault := sortField == "created" || sortField == "updated" || sortField == "last-seen" + if descByDefault != sortReverse { + return !less + } + return less + }) +} + func displayAgents(agents []api.AgentInfo, all bool, hubMode bool) error { if listRunning { agents = filterRunningAgents(agents) @@ -281,7 +378,12 @@ func displayAgents(agents []api.AgentInfo, all bool, hubMode bool) error { agents[i].Template = config.FriendlyTemplateName(agents[i].Template) } - if sortByTime { + // Apply --phase, --activity, --template filters + agents = filterAgentsByFlags(agents) + + if sortField != "" { + sortAgentsByField(agents) + } else if sortByTime { sort.Slice(agents, func(i, j int) bool { return agents[i].LastSeen.After(agents[j].LastSeen) }) @@ -574,4 +676,9 @@ func init() { listCmd.Flags().BoolVar(&listDeleted, "deleted", false, "Include soft-deleted agents in listing") listCmd.Flags().BoolVarP(&listRunning, "running", "r", false, "Only show agents that are not stopped or errored") listCmd.Flags().BoolVarP(&sortByTime, "time", "t", false, "Sort by last activity, most recent first") + listCmd.Flags().StringVar(&filterPhase, "phase", "", "Filter by lifecycle phase (running, stopped, error, ...)") + listCmd.Flags().StringVar(&filterActivity, "activity", "", "Filter by runtime activity (thinking, waiting_for_input, ...)") + listCmd.Flags().StringVar(&filterTemplate, "template", "", "Filter by template name") + listCmd.Flags().StringVar(&sortField, "sort", "", "Sort by field (name, phase, created, updated, last-seen)") + listCmd.Flags().BoolVar(&sortReverse, "reverse", false, "Reverse sort order") } diff --git a/cmd/list_test.go b/cmd/list_test.go index e28bad216..c91693039 100644 --- a/cmd/list_test.go +++ b/cmd/list_test.go @@ -580,3 +580,352 @@ func TestHubAgentToAgentInfo_HarnessConfigTopLevelTakesPrecedence(t *testing.T) t.Errorf("HarnessConfig = %q, want %q (top-level should take precedence)", info.HarnessConfig, "gemini") } } + +func TestFilterAgentsByPhase(t *testing.T) { + agents := []api.AgentInfo{ + {Name: "running-1", Phase: "running", Template: "default", Runtime: "docker", Project: "p"}, + {Name: "stopped-1", Phase: "stopped", Template: "default", Runtime: "docker", Project: "p"}, + {Name: "running-2", Phase: "running", Template: "claude", Runtime: "docker", Project: "p"}, + {Name: "error-1", Phase: "error", Template: "default", Runtime: "docker", Project: "p"}, + } + + filterPhase = "running" + defer func() { filterPhase = "" }() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := displayAgents(agents, false, false) + w.Close() + os.Stdout = old + + if err != nil { + t.Fatalf("displayAgents returned error: %v", err) + } + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + if !strings.Contains(output, "running-1") { + t.Errorf("output should contain 'running-1': %s", output) + } + if !strings.Contains(output, "running-2") { + t.Errorf("output should contain 'running-2': %s", output) + } + if strings.Contains(output, "stopped-1") { + t.Errorf("output should NOT contain 'stopped-1': %s", output) + } + if strings.Contains(output, "error-1") { + t.Errorf("output should NOT contain 'error-1': %s", output) + } +} + +func TestFilterAgentsByActivity(t *testing.T) { + agents := []api.AgentInfo{ + {Name: "thinking-agent", Phase: "running", Activity: "thinking", Template: "default", Runtime: "docker", Project: "p"}, + {Name: "waiting-agent", Phase: "running", Activity: "waiting_for_input", Template: "default", Runtime: "docker", Project: "p"}, + {Name: "no-activity", Phase: "stopped", Template: "default", Runtime: "docker", Project: "p"}, + } + + filterActivity = "thinking" + defer func() { filterActivity = "" }() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := displayAgents(agents, false, false) + w.Close() + os.Stdout = old + + if err != nil { + t.Fatalf("displayAgents returned error: %v", err) + } + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + if !strings.Contains(output, "thinking-agent") { + t.Errorf("output should contain 'thinking-agent': %s", output) + } + if strings.Contains(output, "waiting-agent") { + t.Errorf("output should NOT contain 'waiting-agent': %s", output) + } + if strings.Contains(output, "no-activity") { + t.Errorf("output should NOT contain 'no-activity': %s", output) + } +} + +func TestFilterAgentsByTemplate(t *testing.T) { + agents := []api.AgentInfo{ + {Name: "claude-agent", Phase: "running", Template: "claude", Runtime: "docker", Project: "p"}, + {Name: "gemini-agent", Phase: "running", Template: "gemini", Runtime: "docker", Project: "p"}, + } + + filterTemplate = "claude" + defer func() { filterTemplate = "" }() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := displayAgents(agents, false, false) + w.Close() + os.Stdout = old + + if err != nil { + t.Fatalf("displayAgents returned error: %v", err) + } + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + if !strings.Contains(output, "claude-agent") { + t.Errorf("output should contain 'claude-agent': %s", output) + } + if strings.Contains(output, "gemini-agent") { + t.Errorf("output should NOT contain 'gemini-agent': %s", output) + } +} + +func TestFilterAgentsCombined(t *testing.T) { + agents := []api.AgentInfo{ + {Name: "match", Phase: "running", Activity: "thinking", Template: "claude", Runtime: "docker", Project: "p"}, + {Name: "wrong-phase", Phase: "stopped", Activity: "thinking", Template: "claude", Runtime: "docker", Project: "p"}, + {Name: "wrong-activity", Phase: "running", Activity: "executing", Template: "claude", Runtime: "docker", Project: "p"}, + {Name: "wrong-template", Phase: "running", Activity: "thinking", Template: "gemini", Runtime: "docker", Project: "p"}, + } + + filterPhase = "running" + filterActivity = "thinking" + filterTemplate = "claude" + defer func() { filterPhase = ""; filterActivity = ""; filterTemplate = "" }() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := displayAgents(agents, false, false) + w.Close() + os.Stdout = old + + if err != nil { + t.Fatalf("displayAgents returned error: %v", err) + } + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 lines (header + 1 agent), got %d: %s", len(lines), output) + } + if !strings.Contains(lines[1], "match") { + t.Errorf("only 'match' agent should appear: %s", lines[1]) + } +} + +func TestSortAgentsByName(t *testing.T) { + agents := []api.AgentInfo{ + {Name: "charlie", Template: "default", Runtime: "docker", Project: "p", Phase: "running"}, + {Name: "alice", Template: "default", Runtime: "docker", Project: "p", Phase: "running"}, + {Name: "bob", Template: "default", Runtime: "docker", Project: "p", Phase: "running"}, + } + + sortField = "name" + defer func() { sortField = "" }() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := displayAgents(agents, false, false) + w.Close() + os.Stdout = old + + if err != nil { + t.Fatalf("displayAgents returned error: %v", err) + } + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) < 4 { + t.Fatalf("expected 4 lines, got %d: %s", len(lines), output) + } + if !strings.Contains(lines[1], "alice") { + t.Errorf("first agent should be 'alice': %s", lines[1]) + } + if !strings.Contains(lines[2], "bob") { + t.Errorf("second agent should be 'bob': %s", lines[2]) + } + if !strings.Contains(lines[3], "charlie") { + t.Errorf("third agent should be 'charlie': %s", lines[3]) + } +} + +func TestSortAgentsByCreated(t *testing.T) { + now := time.Now() + agents := []api.AgentInfo{ + {Name: "oldest", Template: "default", Runtime: "docker", Project: "p", Phase: "running", Created: now.Add(-3 * time.Hour)}, + {Name: "newest", Template: "default", Runtime: "docker", Project: "p", Phase: "running", Created: now.Add(-1 * time.Hour)}, + {Name: "middle", Template: "default", Runtime: "docker", Project: "p", Phase: "running", Created: now.Add(-2 * time.Hour)}, + } + + sortField = "created" + defer func() { sortField = "" }() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := displayAgents(agents, false, false) + w.Close() + os.Stdout = old + + if err != nil { + t.Fatalf("displayAgents returned error: %v", err) + } + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) < 4 { + t.Fatalf("expected 4 lines, got %d: %s", len(lines), output) + } + // Timestamps default to descending (newest first) + if !strings.Contains(lines[1], "newest") { + t.Errorf("first agent should be 'newest': %s", lines[1]) + } + if !strings.Contains(lines[2], "middle") { + t.Errorf("second agent should be 'middle': %s", lines[2]) + } + if !strings.Contains(lines[3], "oldest") { + t.Errorf("third agent should be 'oldest': %s", lines[3]) + } +} + +func TestSortAgentsReverse(t *testing.T) { + now := time.Now() + agents := []api.AgentInfo{ + {Name: "oldest", Template: "default", Runtime: "docker", Project: "p", Phase: "running", Created: now.Add(-3 * time.Hour)}, + {Name: "newest", Template: "default", Runtime: "docker", Project: "p", Phase: "running", Created: now.Add(-1 * time.Hour)}, + {Name: "middle", Template: "default", Runtime: "docker", Project: "p", Phase: "running", Created: now.Add(-2 * time.Hour)}, + } + + sortField = "created" + sortReverse = true + defer func() { sortField = ""; sortReverse = false }() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := displayAgents(agents, false, false) + w.Close() + os.Stdout = old + + if err != nil { + t.Fatalf("displayAgents returned error: %v", err) + } + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + lines := strings.Split(strings.TrimSpace(output), "\n") + if len(lines) < 4 { + t.Fatalf("expected 4 lines, got %d: %s", len(lines), output) + } + // --reverse on timestamp: ascending (oldest first) + if !strings.Contains(lines[1], "oldest") { + t.Errorf("first agent should be 'oldest': %s", lines[1]) + } + if !strings.Contains(lines[2], "middle") { + t.Errorf("second agent should be 'middle': %s", lines[2]) + } + if !strings.Contains(lines[3], "newest") { + t.Errorf("third agent should be 'newest': %s", lines[3]) + } +} + +func TestDisplayAgentsFilteredEmpty(t *testing.T) { + agents := []api.AgentInfo{ + {Name: "running-agent", Phase: "running", Template: "default", Runtime: "docker", Project: "p"}, + } + + filterPhase = "error" + defer func() { filterPhase = "" }() + + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + err := displayAgents(agents, false, false) + w.Close() + os.Stdout = old + + if err != nil { + t.Fatalf("displayAgents returned error: %v", err) + } + + var buf bytes.Buffer + buf.ReadFrom(r) + output := buf.String() + + if !strings.Contains(output, "No active agents") { + t.Errorf("expected empty message when filter matches nothing, got: %s", output) + } + if strings.Contains(output, "running-agent") { + t.Errorf("output should NOT contain filtered-out agent: %s", output) + } +} + +func TestValidateListFlags(t *testing.T) { + tests := []struct { + name string + phase string + activity string + sort string + wantErr bool + errContain string + }{ + {"valid phase", "running", "", "", false, ""}, + {"valid activity", "", "thinking", "", false, ""}, + {"valid sort", "", "", "name", false, ""}, + {"invalid phase", "bogus", "", "", true, "invalid phase"}, + {"invalid activity", "", "bogus", "", true, "invalid activity"}, + {"invalid sort", "", "", "bogus", true, "invalid sort field"}, + {"all empty", "", "", "", false, ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filterPhase = tt.phase + filterActivity = tt.activity + sortField = tt.sort + defer func() { filterPhase = ""; filterActivity = ""; sortField = "" }() + + err := validateListFlags() + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + if !strings.Contains(err.Error(), tt.errContain) { + t.Errorf("error %q should contain %q", err.Error(), tt.errContain) + } + } else if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} diff --git a/web/src/components/pages/agents.ts b/web/src/components/pages/agents.ts index c7f099b5d..cf2d9c976 100644 --- a/web/src/components/pages/agents.ts +++ b/web/src/components/pages/agents.ts @@ -23,8 +23,11 @@ import { LitElement, html, css, nothing } from 'lit'; import { customElement, property, state } from 'lit/decorators.js'; -import type { PageData, Agent, Capabilities } from '../../shared/types.js'; +import type { PageData, Agent, AgentPhase, Capabilities } from '../../shared/types.js'; import { can, isTerminalAvailable, getAgentDisplayStatus, isAgentRunning } from '../../shared/types.js'; + +type AgentSortField = 'name' | 'status' | 'created' | 'updated'; +type SortDir = 'asc' | 'desc'; import type { StatusType } from '../shared/status-badge.js'; import { apiFetch, extractApiError } from '../../client/api.js'; import { stateManager } from '../../client/state.js'; @@ -89,6 +92,15 @@ export class ScionPageAgents extends LitElement { @state() private agentScope: 'all' | 'mine' | 'shared' = 'all'; + @state() + private phaseFilter: AgentPhase | '' = ''; + + @state() + private sortField: AgentSortField = 'updated'; + + @state() + private sortDir: SortDir = 'desc'; + static override styles = [ listPageStyles, css` @@ -224,6 +236,41 @@ export class ScionPageAgents extends LitElement { .project-link:hover { text-decoration: underline; } + + .filter-bar { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; + flex-wrap: wrap; + } + + .filter-bar .label { + font-size: 0.8125rem; + color: var(--scion-text-muted, #64748b); + font-weight: 500; + } + + th.sortable { + cursor: pointer; + user-select: none; + } + + th.sortable:hover { + color: var(--scion-text, #1e293b); + } + + .sort-indicator { + display: inline-block; + margin-left: 0.25rem; + font-size: 0.625rem; + vertical-align: middle; + opacity: 0.4; + } + + th.sorted .sort-indicator { + opacity: 1; + } `, ]; @@ -246,6 +293,22 @@ export class ScionPageAgents extends LitElement { } } + // Read persisted phase filter + const storedPhase = localStorage.getItem('scion-filter-agents-phase'); + if (storedPhase) { + this.phaseFilter = storedPhase as AgentPhase; + } + + // Read persisted sort + const storedSort = localStorage.getItem('scion-sort-agents'); + if (storedSort) { + try { + const parsed = JSON.parse(storedSort) as { field: AgentSortField; dir: SortDir }; + this.sortField = parsed.field; + this.sortDir = parsed.dir; + } catch { /* ignore invalid stored sort */ } + } + // Set SSE scope to dashboard (all project summaries). // This must happen before checking hydrated data because setScope clears // state maps when the scope changes (e.g. from agent-detail to dashboard). @@ -468,6 +531,72 @@ export class ScionPageAgents extends LitElement { this.viewMode = e.detail.view; } + private get displayAgents(): Agent[] { + let list = this.agents; + if (this.phaseFilter) { + list = list.filter(a => a.phase === this.phaseFilter); + } + const sorted = [...list]; + sorted.sort((a, b) => { + let cmp = 0; + switch (this.sortField) { + case 'name': + cmp = (a.name || '').localeCompare(b.name || ''); + break; + case 'status': + cmp = getAgentDisplayStatus(a).localeCompare(getAgentDisplayStatus(b)); + break; + case 'created': + cmp = (a.created || '').localeCompare(b.created || ''); + break; + case 'updated': + cmp = (a.updated || '').localeCompare(b.updated || ''); + break; + } + return this.sortDir === 'asc' ? cmp : -cmp; + }); + return sorted; + } + + private formatRelativeTime(isoString: string): string { + const date = new Date(isoString); + const now = Date.now(); + const diffMs = now - date.getTime(); + if (diffMs < 0) return 'just now'; + const seconds = Math.floor(diffMs / 1000); + if (seconds < 60) return 'just now'; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; + } + + private setPhaseFilter(phase: AgentPhase | ''): void { + if (this.phaseFilter === phase) return; + this.phaseFilter = phase; + if (phase) { + localStorage.setItem('scion-filter-agents-phase', phase); + } else { + localStorage.removeItem('scion-filter-agents-phase'); + } + } + + private toggleSort(field: AgentSortField): void { + if (this.sortField === field) { + this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc'; + } else { + this.sortField = field; + this.sortDir = field === 'name' ? 'asc' : 'desc'; + } + localStorage.setItem('scion-sort-agents', JSON.stringify({ field: this.sortField, dir: this.sortDir })); + } + + private sortIndicator(field: AgentSortField): string { + return this.sortField === field ? (this.sortDir === 'asc' ? '▲' : '▼') : '▲'; + } + private setScope(scope: 'all' | 'mine' | 'shared'): void { if (this.agentScope === scope) return; this.agentScope = scope; @@ -538,7 +667,10 @@ export class ScionPageAgents extends LitElement { - ${this.loading ? this.renderLoading() : this.error ? this.renderError() : this.renderAgents()} + ${this.loading ? this.renderLoading() : this.error ? this.renderError() : html` + ${this.renderFilterBar()} + ${this.renderAgents()} + `} `; } @@ -566,6 +698,50 @@ export class ScionPageAgents extends LitElement { `; } + private renderFilterBar() { + return html` +
+ Status: +
+ + + + + +
+ ${this.viewMode === 'grid' ? html` + + + + Sort: ${this.sortField} + + ) => this.toggleSort(e.detail.item.value as AgentSortField)}> + Name + Status + Created + Updated + + + ` : nothing} +
+ `; + } + private renderAgents() { if (this.agents.length === 0) { if (this.agentScope === 'mine') { @@ -589,6 +765,17 @@ export class ScionPageAgents extends LitElement { return this.renderEmptyState(); } + const filtered = this.displayAgents; + if (filtered.length === 0 && this.phaseFilter) { + return html` +
+ +

No Matching Agents

+

No agents match the current filter. Try changing the status filter.

+
+ `; + } + return this.viewMode === 'grid' ? this.renderGrid() : this.renderTable(); } @@ -614,7 +801,7 @@ export class ScionPageAgents extends LitElement { private renderGrid() { return html` -
${this.agents.map((agent) => this.renderAgentCard(agent))}
+
${this.displayAgents.map((agent) => this.renderAgentCard(agent))}
`; } @@ -743,16 +930,26 @@ export class ScionPageAgents extends LitElement { - + - + + - ${this.agents.map((agent) => this.renderAgentRow(agent))} + ${this.displayAgents.map((agent) => this.renderAgentRow(agent))}
Name this.toggleSort('name')} + >Name ${this.sortIndicator('name')} Project TemplateStatus this.toggleSort('status')} + >Status ${this.sortIndicator('status')} this.toggleSort('updated')} + >Updated ${this.sortIndicator('updated')} Task Actions
@@ -779,6 +976,7 @@ export class ScionPageAgents extends LitElement { size="small" > + ${agent.updated ? this.formatRelativeTime(agent.updated) : '\u2014'} ${agent.taskSummary || '\u2014'}