diff --git a/docs/handoffs/learning-browse.md b/docs/handoffs/learning-browse.md new file mode 100644 index 0000000..85e022c --- /dev/null +++ b/docs/handoffs/learning-browse.md @@ -0,0 +1,39 @@ +# Learning browse handoff + +## Result + +Learned workloads now opens on **Projects**, busiest first. Projects with two workloads or fewer share one **Other projects** row. Choosing a project shows one row per operation (tests, build, lint and so on) plus **All operations**. Choosing an operation opens the existing paged group list with selection, details and merge. The phone dashboard has a breadcrumb; the native app has a Back button and breadcrumb buttons. Search always covers every project and shows a flat list. + +This builds on the repository grouping and catalog API from the learning grouping change (`docs/handoffs/learning-grouping.md`). That branch is this one's base. + +## What changed + +- `remote/static/profiles.js`, `remote/static/app.css`, `remote/static/index.html`: the three levels, breadcrumb, focus and announcements, failed-load cleanup. +- `native/AgentLearning.swift`, `native/AgentQueue.swift`: the same levels in `LearningStore`, the hierarchy transport, and a submitted query kept apart from the search box. +- `internal/backend/profiles_learning.go`, `learning.go`: + - Group project names are compared in NFC. + - The CLI normalises its string flags, because Foundation's `Process` passes them decomposed. + - Search also matches member project names, ranked after label and id matches. +- `internal/preview`: fixture projects and the hierarchy flags for screenshots and browser tests. + +## Behaviour worth knowing + +- When a merge folds a project into Other projects, its groups load directly. Back and the project crumb then return to Projects, because that project has no operations level. +- A page past the end after a merge re-reads the last page once. +- The overview lists at most 100 projects. Search reaches groups behind that limit. + +## Known limits + +- The `folded` flag is set only on the way into the operations level. If a project regains its own row while you stay on its groups, Back still goes to Projects. If a merge folds it while you are on a group list, the first Back lands on its All operations list. Each costs one extra click; no data is wrong. +- The HTTP handler checks the 120-character limit before NFC normalisation. A direct API client sending a long decomposed name gets a 400. The web client is not affected. +- No browser test covers the case where the re-read of the last page is also empty. + +## Evidence not covered + +- The flaky "Cannot establish process identity" failures described in the grouping handoff were not seen in this branch's runs and remain undiagnosed. + +## Next steps + +1. Merge the measurement fix (#78), then the grouping change (#79), and retarget this PR to `main` so hosted CI runs. +2. After merge, open Learned workloads on the phone dashboard and the native app and check the project counts against `agent-heavy profiles list --hierarchy --json`. +3. Optional later work: tool and memory filters, a `--kind` flag, and a look at the 64 to 66 GiB `rewrite` peaks before switching to Adaptive mode. diff --git a/docs/learning.html b/docs/learning.html index 6374268..acb424f 100644 --- a/docs/learning.html +++ b/docs/learning.html @@ -1,4 +1,4 @@ -learning

Inspect and correct workload learning

Use

Choose Learned workloads from the native app's gear menu or the phone dashboard navigation. Search by name, select a workload, and inspect its measured peaks, successful and incomplete run counts, variants and recent numeric measurements. A job's detail view links to its current learning profile.

Give a profile a useful name and save minimum RAM or CPU reservations when you know a job needs more headroom. Blank minimums return to automatic estimation. Sharing learning combines the original families under the selected target profile, keeping the larger reservations from both profiles. Separate a family when it should learn independently. Reset measurements only when the old evidence is obsolete; reset removes those measurements permanently while preserving your corrections.

Related workloads now share learning automatically within the same verified git repository, whatever subdirectory or linked worktree they ran in. Grouping needs a recognized tool (Go, JavaScript, Python, shell, Cargo, Swift or Make). Each workload also has an operation: tests, browser tests, native tests, race tests, full tests, build, lint, type check, dependencies, benchmark or combined checks, and other for mixed batches and anything unknown. Workloads with other share one group per repository and tool, so one heavy script raises the conservative estimate for the rest of that group. Simple env and fnm exec wrappers, *.sh scripts and inline sh -c or bash -c programs are recognized for new jobs. Opaque scripts can use their safe task descriptions. Unrecognized tools and workloads outside a verified git repository remain independent. Project names alone never establish a repository match. Only the category is stored; command arguments and inline program text are never retained. Automatic groups retain the largest measured peaks rather than averaging dissimilar runs down.

Group estimates deliberately have no exact-variant successful-run count: they describe the conservative envelope a new variant can borrow. Each actual job still has its own prediction and confidence. Measured history, the group estimate and operator minimums are displayed separately. Run history and variant peaks can include multiple machines; each run identifies its machine with an opaque hash, while the group estimate uses the current machine only.

The CLI has the same inspection and correction behavior:

agent-heavy profiles list --json
+learning

Inspect and correct workload learning

Use

Choose Learned workloads from the native app's gear menu or the phone dashboard navigation. Search by name, select a workload, and inspect its measured peaks, successful and incomplete run counts, variants and recent numeric measurements. A job's detail view links to its current learning profile.

The list opens on Projects, busiest first, each with its workload, group and run counts. Projects with two workloads or fewer share one Other projects row, and a note appears when more projects exist than the overview can list, saying how many are not shown; searching a project or group name finds them. Choose a project to see what it ran, one row per operation such as tests, build or lint with the same counts, plus All operations. Choose an operation to reach the group list, which has the selection checkboxes, group details and paging described below. In the phone dashboard a breadcrumb such as All projects / Sample app / Tests goes back to any earlier level; the native app has the same trail, with earlier parts as buttons, and a Back button. Search always covers every project, matching group names and the names of the projects behind them, and shows a flat list with groups whose own name matches before those that match only through a project; clear the box and search again to return to the level you were on. Returning from a group's details lands on the level and page it came from. Counts reload when you go back up a level or refresh, a project that a merge folded into Other projects still lists its groups and Back from them returns to the projects, a page past the end after a merge shows the last page, and a project or operation with nothing left shows an empty state with a way back.

Give a profile a useful name and save minimum RAM or CPU reservations when you know a job needs more headroom. Blank minimums return to automatic estimation. Sharing learning combines the original families under the selected target profile, keeping the larger reservations from both profiles. Separate a family when it should learn independently. Reset measurements only when the old evidence is obsolete; reset removes those measurements permanently while preserving your corrections.

Related workloads now share learning automatically within the same verified git repository, whatever subdirectory or linked worktree they ran in. Grouping needs a recognized tool (Go, JavaScript, Python, shell, Cargo, Swift or Make). Each workload also has an operation: tests, browser tests, native tests, race tests, full tests, build, lint, type check, dependencies, benchmark or combined checks, and other for mixed batches and anything unknown. Workloads with other share one group per repository and tool, so one heavy script raises the conservative estimate for the rest of that group. Simple env and fnm exec wrappers, *.sh scripts and inline sh -c or bash -c programs are recognized for new jobs. Opaque scripts can use their safe task descriptions. Unrecognized tools and workloads outside a verified git repository remain independent. Project names alone never establish a repository match. Only the category is stored; command arguments and inline program text are never retained. Automatic groups retain the largest measured peaks rather than averaging dissimilar runs down.

Group estimates deliberately have no exact-variant successful-run count: they describe the conservative envelope a new variant can borrow. Each actual job still has its own prediction and confidence. Measured history, the group estimate and operator minimums are displayed separately. Run history and variant peaks can include multiple machines; each run identifies its machine with an opaque hash, while the group estimate uses the current machine only.

The CLI has the same inspection and correction behavior:

agent-heavy profiles list --json
 agent-heavy profiles list --query Tests --offset 0 --limit 100 --json
 agent-heavy profiles list --project 'Agent Queue' --operation tests --json
 agent-heavy profiles list --other-projects --json
diff --git a/docs/learning.md b/docs/learning.md
index 5772544..1faa713 100644
--- a/docs/learning.md
+++ b/docs/learning.md
@@ -4,6 +4,8 @@
 
 Choose **Learned workloads** from the native app's gear menu or the phone dashboard navigation. Search by name, select a workload, and inspect its measured peaks, successful and incomplete run counts, variants and recent numeric measurements. A job's detail view links to its current learning profile.
 
+The list opens on **Projects**, busiest first, each with its workload, group and run counts. Projects with two workloads or fewer share one **Other projects** row, and a note appears when more projects exist than the overview can list, saying how many are not shown; searching a project or group name finds them. Choose a project to see what it ran, one row per operation such as tests, build or lint with the same counts, plus **All operations**. Choose an operation to reach the group list, which has the selection checkboxes, group details and paging described below. In the phone dashboard a breadcrumb such as **All projects / Sample app / Tests** goes back to any earlier level; the native app has the same trail, with earlier parts as buttons, and a **Back** button. Search always covers every project, matching group names and the names of the projects behind them, and shows a flat list with groups whose own name matches before those that match only through a project; clear the box and search again to return to the level you were on. Returning from a group's details lands on the level and page it came from. Counts reload when you go back up a level or refresh, a project that a merge folded into **Other projects** still lists its groups and Back from them returns to the projects, a page past the end after a merge shows the last page, and a project or operation with nothing left shows an empty state with a way back.
+
 Open **Group members** to compare each workload's successful and incomplete run counts, peaks and last run. Expand a member to load its own variants and recent measurements, including runs too old to appear in the group's recent list. Search and page within the group to find members beyond the first 256.
 
 For a mismatch, choose **Ungroup workload…** inside its expanded details and confirm. All of that workload's variants and retained measurements move together into an independent profile. Its minimum reservations are preserved. The separated workload and remaining group stay excluded from automatic grouping, so another learning pass cannot undo your correction. Use **Back to group** to continue reviewing. You can explicitly share their learning again later.
diff --git a/internal/backend/learning.go b/internal/backend/learning.go
index af9478d..c20ce3a 100644
--- a/internal/backend/learning.go
+++ b/internal/backend/learning.go
@@ -11,6 +11,8 @@ import (
 	"strconv"
 	"strings"
 	"unicode/utf8"
+
+	"golang.org/x/text/unicode/norm"
 )
 
 const maxLearningInput = 16384
@@ -395,6 +397,7 @@ func LearningCLI(gate *Gate, args []string, input io.Reader, output, errors io.W
 					}
 					value = args[i]
 				}
+				value = norm.NFC.String(value)
 				switch key {
 				case "--family":
 					if e := profileID(value); e != nil {
@@ -447,6 +450,7 @@ func LearningCLI(gate *Gate, args []string, input io.Reader, output, errors io.W
 					}
 					value = args[i]
 				}
+				value = norm.NFC.String(value)
 				switch key {
 				case "--offset", "--limit":
 					n, e := strconv.Atoi(value)
diff --git a/internal/backend/profiles_classification_test.go b/internal/backend/profiles_classification_test.go
index 25b9627..954962f 100644
--- a/internal/backend/profiles_classification_test.go
+++ b/internal/backend/profiles_classification_test.go
@@ -13,6 +13,8 @@ import (
 	"strings"
 	"testing"
 	"time"
+
+	"golang.org/x/text/unicode/norm"
 )
 
 func runGit(t *testing.T, directory string, args ...string) {
@@ -837,9 +839,46 @@ func TestProfileCatalogFiltersAndHierarchy(t *testing.T) {
 	if r, e := store.CatalogFiltered(0, 100, "build", ProfileFilter{Project: "big"}); e != nil || metric(r["total"]) != 1 {
 		t.Fatal("Search and filter do not combine", r, e)
 	}
+	// Search matches the member project name, case-insensitively, so groups of a project missing from the truncated list stay reachable.
+	if r, e := store.CatalogFiltered(0, 100, "SINGLE", ProfileFilter{}); e != nil || metric(r["total"]) != 1 || r["profiles"].([]Object)[0]["project"] != "single" || strings.Contains(strings.ToLower(text(r["profiles"].([]Object)[0]["label"])), "single") {
+		t.Fatal("Search does not match the project name", r, e)
+	}
 	if limited, _ := store.Hierarchy(1); len(limited["projects"].([]Object)) != 1 || metric(limited["total"]) != 1 {
 		t.Fatal("The hierarchy limit counts the other-projects row", limited)
 	}
+	// Foundation's Process passes arguments decomposed (NFD); a precomposed stored name must still be found, and the reverse.
+	add("cafe one", "caf\u00e9", "Go tests", "go", "cafe")
+	add("cafe two", "caf\u00e9", "Go tests", "go", "cafe")
+	add("cafe three", "caf\u00e9", "Go build", "go", "cafe")
+	if r, e := store.CatalogFiltered(0, 100, "", ProfileFilter{Project: "caf\u00e9"}); e != nil || metric(r["total"]) == 0 {
+		t.Fatalf("Project was not found: %v %v", r, e)
+	}
+	if r, e := store.CatalogFiltered(0, 100, "CAFE\u0301", ProfileFilter{}); e != nil || metric(r["total"]) == 0 {
+		t.Fatal("A decomposed search does not match a precomposed project", r, e)
+	}
+	// The same project stored in both forms is one project: one row, one count, and no stray group in the other projects.
+	add("nfc one", "caf\u00e9", "Go tests", "go", "cafe-nfc")
+	add("nfd one", "cafe\u0301", "Go tests", "go", "cafe-nfd")
+	add("nfd two", "cafe\u0301", "Go build", "go", "cafe-nfd")
+	hierarchy, _ = store.Hierarchy(100)
+	rows = hierarchy["projects"].([]Object)
+	cafeRows := 0
+	for _, row := range rows {
+		if norm.NFC.String(text(row["project"])) == "caf\u00e9" {
+			cafeRows++
+			if row["project"] != "caf\u00e9" || metric(row["groupCount"]) != metric(list(ProfileFilter{Project: "caf\u00e9"})["total"]) {
+				t.Fatal("The project row does not match the project filter", row)
+			}
+		}
+	}
+	if cafeRows != 1 {
+		t.Fatalf("A project stored in two forms has %d rows", cafeRows)
+	}
+	for _, p := range list(ProfileFilter{OtherProjects: true})["profiles"].([]Object) {
+		if norm.NFC.String(text(p["project"])) == "caf\u00e9" {
+			t.Fatal("A named project also appears under other projects", p)
+		}
+	}
 	for _, bad := range []ProfileFilter{{Operation: "typo"}, {Project: strings.Repeat("x", 121)}, {Project: "a\nb"}, {Project: " pad"}, {Project: "big", OtherProjects: true}} {
 		if _, e := store.CatalogFiltered(0, 100, "", bad); e == nil {
 			t.Errorf("Invalid filter accepted: %+v", bad)
@@ -852,6 +891,21 @@ func TestProfileCatalogFiltersAndHierarchy(t *testing.T) {
 	}
 }
 
+func TestProfileSearchRanksOwnLabelBeforeMemberProject(t *testing.T) {
+	store := fixtureProfileStore(t)
+	// Sorted by label alone, the project-only match ("Aardvark") would come first.
+	observeClassified(t, store, "project only", "zebra", "Aardvark", "go", "aardvark")
+	observeClassified(t, store, "label match", "other", "Zebra crossing", "go", "crossing")
+	r, e := store.CatalogFiltered(0, 100, "zebra", ProfileFilter{})
+	if e != nil {
+		t.Fatal(e)
+	}
+	entries := r["profiles"].([]Object)
+	if len(entries) != 2 || text(entries[0]["label"]) != "Zebra crossing" || text(entries[1]["label"]) != "Aardvark" {
+		t.Fatal("A label match does not sort before a project-only match", entries)
+	}
+}
+
 func TestProfileCLICatalogFilters(t *testing.T) {
 	g := testGate(t)
 	store, e := NewProfileStore(g.State, false)
@@ -896,6 +950,30 @@ func TestProfileCLICatalogFilters(t *testing.T) {
 	}
 }
 
+func TestProfileCLINormalizesDecomposedFilters(t *testing.T) {
+	g := testGate(t)
+	store, e := NewProfileStore(g.State, false)
+	if e != nil {
+		t.Fatal(e)
+	}
+	run := func(args ...string) (int, Object) {
+		var output, stderr bytes.Buffer
+		code := LearningCLI(g, append([]string{"list", "--json"}, args...), strings.NewReader(""), &output, &stderr)
+		result := Object{}
+		_ = json.Unmarshal(output.Bytes(), &result)
+		return code, result
+	}
+	// Foundation hands the project over decomposed; the stored name is precomposed.
+	observeClassified(t, store, "accent", "caf\u00e9", "Go tests", "go", "accent")
+	if code, r := run("--project", "cafe\u0301"); code != 0 || metric(r["total"]) != 1 {
+		t.Fatal("A decomposed --project missed a precomposed project", code, r)
+	}
+	// Decomposed text is normalized before the length check: 80 characters are 160 runes in NFD.
+	if code, r := run("--project", strings.Repeat("e\u0301", 80)); code != 0 || metric(r["total"]) != 0 {
+		t.Fatal("A long decomposed project name was rejected", code, r)
+	}
+}
+
 func TestProfilePreviewMatchesAppliedCatalog(t *testing.T) {
 	g := testGate(t)
 	store, e := NewProfileStore(g.State, false)
diff --git a/internal/backend/profiles_inspection_test.go b/internal/backend/profiles_inspection_test.go
index b7d5de3..3dfff2c 100644
--- a/internal/backend/profiles_inspection_test.go
+++ b/internal/backend/profiles_inspection_test.go
@@ -116,6 +116,14 @@ func TestProfileMemberPagesSearchAllMembersAndRetainOwnStats(t *testing.T) {
 	}
 }
 
+func TestProfileMemberSearchFoldsDecomposedText(t *testing.T) {
+	group := &profileGroup{members: []Object{{"family": strings.Repeat("a", 64), "title": "Caf\u00e9 build", "project": "fixture", "executable": "go"}}}
+	page, info, err := profileMemberPage(group, ProfileDetailOptions{MemberQuery: "CAFE\u0301", MemberLimit: 256})
+	if err != nil || len(page) != 1 || metric(info["total"]) != 1 {
+		t.Fatal("A decomposed member search missed a precomposed member", page, info, err)
+	}
+}
+
 func TestProfileMemberCLIRejectsInvalidInspectionOptions(t *testing.T) {
 	gate := testGate(t)
 	for _, args := range [][]string{
diff --git a/internal/backend/profiles_learning.go b/internal/backend/profiles_learning.go
index b34d6f9..8567386 100644
--- a/internal/backend/profiles_learning.go
+++ b/internal/backend/profiles_learning.go
@@ -3,6 +3,7 @@ package backend
 import (
 	"fmt"
 	"golang.org/x/text/cases"
+	"golang.org/x/text/unicode/norm"
 	"sort"
 	"strings"
 )
@@ -133,7 +134,7 @@ func (f ProfileFilter) any() bool { return f.Project != "" || f.Operation != ""
 func profileProject(members []Object) string {
 	counts := map[string]int{}
 	for _, member := range members {
-		if name := text(member["project"]); name != "" {
+		if name := norm.NFC.String(text(member["project"])); name != "" {
 			counts[name]++
 		}
 	}
@@ -190,29 +191,41 @@ func (s *ProfileStore) CatalogFiltered(offset, limit int, query string, filter P
 			return e
 		}
 		all := []Object{}
+		memberProjects := map[string][]string{}
 		for id, profile := range profiles {
 			summary, e := profileSummary(profile, groups[id])
 			if e != nil {
 				return e
 			}
 			all = append(all, summary)
+			for _, member := range groups[id].members {
+				memberProjects[id] = append(memberProjects[id], profileFold(text(member["project"])))
+			}
 		}
 		collapsed := map[string]bool{}
 		if filter.OtherProjects {
 			collapsed = profileCollapsedProjects(profileProjectTotals(all))
 		}
 		entries := []Object{}
-		needle := strings.TrimSpace(cases.Fold().String(query))
+		needle := strings.TrimSpace(profileFold(query))
 		for _, summary := range all {
 			id := text(summary["id"])
 			if filter.Project != "" && text(summary["project"]) != filter.Project || filter.Operation != "" && text(summary["operation"]) != filter.Operation || filter.OtherProjects && !collapsed[text(summary["project"])] {
 				continue
 			}
-			if needle == "" || strings.Contains(cases.Fold().String(text(summary["label"])), needle) || strings.Contains(id, needle) {
+			// Search also matches any member project, so groups behind a truncated project list stay reachable.
+			if needle == "" || strings.Contains(profileFold(text(summary["label"])), needle) || strings.Contains(id, needle) || profileAnyContains(memberProjects[id], needle) {
 				entries = append(entries, summary)
 			}
 		}
+		// With a search, groups whose own label or id matches come before those matched only through a member project.
+		direct := func(entry Object) bool {
+			return needle == "" || strings.Contains(profileFold(text(entry["label"])), needle) || strings.Contains(text(entry["id"]), needle)
+		}
 		sort.Slice(entries, func(i, j int) bool {
+			if di, dj := direct(entries[i]), direct(entries[j]); di != dj {
+				return di
+			}
 			a, b := cases.Fold().String(text(entries[i]["label"])), cases.Fold().String(text(entries[j]["label"]))
 			if a == b {
 				return text(entries[i]["id"]) < text(entries[j]["id"])
@@ -225,6 +238,19 @@ func (s *ProfileStore) CatalogFiltered(offset, limit int, query string, filter P
 	return result, e
 }
 
+// profileFold compares text the way a person sees it: precomposed and case folded.
+// Foundation's Process hands arguments over decomposed, while stored names may be either form.
+func profileFold(value string) string { return cases.Fold().String(norm.NFC.String(value)) }
+
+func profileAnyContains(values []string, needle string) bool {
+	for _, value := range values {
+		if strings.Contains(value, needle) {
+			return true
+		}
+	}
+	return false
+}
+
 // A project with this many workloads or fewer folds into the other-projects row.
 const profileCollapseThreshold = 2
 const maxProfileHierarchyRows = 100
@@ -496,10 +522,10 @@ func profileMemberPage(group *profileGroup, options ProfileDetailOptions) ([]Obj
 		family := text(row["family"])
 		byFamily[family].envelopes = append(byFamily[family].envelopes, row)
 	}
-	needle := cases.Fold().String(strings.TrimSpace(options.MemberQuery))
+	needle := strings.TrimSpace(profileFold(options.MemberQuery))
 	matching := []Object{}
 	for _, member := range group.members {
-		if needle == "" || strings.Contains(cases.Fold().String(strings.Join([]string{profileLabel(text(member["family"]), member), text(member["project"]), text(member["executable"]), text(member["family"])}, " ")), needle) {
+		if needle == "" || strings.Contains(profileFold(strings.Join([]string{profileLabel(text(member["family"]), member), text(member["project"]), text(member["executable"]), text(member["family"])}, " ")), needle) {
 			matching = append(matching, member)
 		}
 	}
diff --git a/internal/preview/preview.go b/internal/preview/preview.go
index 9652596..982f976 100644
--- a/internal/preview/preview.go
+++ b/internal/preview/preview.go
@@ -12,6 +12,7 @@ import (
 	"flag"
 	"fmt"
 	"io"
+	"maps"
 	"net"
 	"net/http"
 	"os"
@@ -80,18 +81,18 @@ func (q *fakeQueue) reset() error {
 }
 
 func learningProfiles() map[string]map[string]any {
-	detail := func(id, label string, families []string) map[string]any {
+	detail := func(id, label, project, operation string, families []string) map[string]any {
 		members := make([]map[string]any, 0, len(families))
 		for index, family := range families {
 			measurements := map[string]any{"variantCount": 0, "successCount": 0, "incompleteCount": 0, "peakMemoryBytes": 0, "peakCpuCores": 0, "lastRunAt": nil}
 			if index == 0 {
 				measurements = map[string]any{"variantCount": 1, "successCount": 5, "incompleteCount": 1, "peakMemoryBytes": 1610612736, "peakCpuCores": 1.5, "lastRunAt": 1792404000}
 			}
-			members = append(members, map[string]any{"family": family, "label": fmt.Sprintf("%s task %d", label, index+1), "project": "example-project", "executable": "go", "measurements": measurements})
+			members = append(members, map[string]any{"family": family, "label": fmt.Sprintf("%s task %d", label, index+1), "project": project, "executable": "go", "measurements": measurements})
 		}
 		return map[string]any{
 			"revision": 4,
-			"profile":  map[string]any{"id": id, "label": label, "memberCount": len(members), "variantCount": 1, "successCount": 5, "incompleteCount": 1, "peakMemoryBytes": 1610612736, "peakCpuCores": 1.5, "lastRunAt": 1792404000, "memoryFloorBytes": nil, "cpuFloorCores": nil},
+			"profile":  map[string]any{"id": id, "label": label, "project": project, "operation": operation, "memberCount": len(members), "variantCount": 1, "successCount": 5, "incompleteCount": 1, "peakMemoryBytes": 1610612736, "peakCpuCores": 1.5, "lastRunAt": 1792404000, "memoryFloorBytes": nil, "cpuFloorCores": nil},
 			"members":  members, "controllerAppliedRevision": 3, "controllerStatus": "Waiting for next fresh pass",
 			"variants":          []map[string]any{{"id": strings.Repeat("f", 64), "family": families[0], "successCount": 5, "incompleteCount": 1, "lastRunAt": 1792404000, "peakMemoryBytes": 1610612736, "peakCpuCores": 1.5}},
 			"runs":              []map[string]any{{"id": strings.Repeat("9", 64), "family": families[0], "variant": strings.Repeat("f", 64), "machine": strings.Repeat("2", 64), "sampledAt": 1792404000, "memoryBytes": 1610612736, "cpuCores": 1.5, "cpuSeconds": 20, "durationSeconds": 15, "success": false}},
@@ -99,7 +100,30 @@ func learningProfiles() map[string]map[string]any {
 			"predictionMachine": strings.Repeat("2", 64), "truncated": map[string]any{"members": false, "variants": false, "runs": false},
 		}
 	}
-	return map[string]map[string]any{strings.Repeat("a", 64): detail(strings.Repeat("a", 64), "Browser tests", []string{strings.Repeat("c", 64), strings.Repeat("d", 64)}), strings.Repeat("b", 64): detail(strings.Repeat("b", 64), "Client build", []string{strings.Repeat("e", 64)})}
+	profiles := map[string]map[string]any{
+		strings.Repeat("a", 64): detail(strings.Repeat("a", 64), "Browser tests", "Sample app", "browser-tests", []string{strings.Repeat("c", 64), strings.Repeat("d", 64)}),
+		strings.Repeat("b", 64): detail(strings.Repeat("b", 64), "Client build", "Sample app", "build", []string{strings.Repeat("e", 64)}),
+	}
+	// Two projects with several operations each, plus two small projects that fold into the other-projects row.
+	next := 0
+	for _, group := range []struct {
+		label, project, operation string
+		families                  int
+	}{
+		{"Unit tests", "Sample app", "tests", 3}, {"Component tests", "Sample app", "tests", 2}, {"Lint", "Sample app", "lint", 1}, {"Race tests", "Sample app", "race-tests", 2},
+		{"API tests", "Sample service", "tests", 3}, {"Service build", "Sample service", "build", 2}, {"Integration tests", "Sample service", "full-tests", 2},
+		{"Dependency update", "Sample service", "dependencies", 1}, {"Static checks", "Sample service", "checks", 1}, {"Service helper", "Sample service", "other", 1},
+		{"Docs build", "Docs site", "build", 1}, {"Script tests", "Tooling scripts", "tests", 1},
+	} {
+		next++
+		families := make([]string, group.families)
+		for index := range families {
+			families[index] = fmt.Sprintf("%064x", 0x10000+next*8+index)
+		}
+		id := fmt.Sprintf("%064x", 0x20000+next)
+		profiles[id] = detail(id, group.label, group.project, group.operation, families)
+	}
+	return profiles
 }
 
 func object(value any) (map[string]any, bool) {
@@ -118,32 +142,211 @@ func members(value any) ([]map[string]any, error) {
 	return result, nil
 }
 
-func (q *fakeQueue) profilesCall(arguments []string, input []byte) (map[string]any, error) {
-	action := arguments[1]
-	if action == "list" {
-		offset, _ := strconv.Atoi(arguments[3])
-		limit, _ := strconv.Atoi(arguments[5])
-		query := strings.ToLower(arguments[7])
-		ids := make([]string, 0, len(q.profiles))
-		for id := range q.profiles {
-			ids = append(ids, id)
-		}
-		slices.Sort(ids)
-		profiles := make([]map[string]any, 0)
-		for _, id := range ids {
-			profile, ok := object(q.profiles[id]["profile"])
-			if !ok {
-				return nil, errors.New("Invalid fixture profile")
+var previewOperations = []string{"tests", "browser-tests", "native-tests", "race-tests", "full-tests", "build", "lint", "typecheck", "dependencies", "benchmark", "checks", "other"}
+
+type previewCounts struct{ workloads, groups, runs int }
+
+// previewCount reads a number that fixture code stores as int and JSON state decodes as float64.
+func previewCount(value any) int {
+	switch number := value.(type) {
+	case int:
+		return number
+	case float64:
+		return int(number)
+	}
+	return 0
+}
+func (c *previewCounts) add(profile map[string]any) {
+	c.workloads += previewCount(profile["memberCount"])
+	c.runs += previewCount(profile["successCount"]) + previewCount(profile["incompleteCount"])
+	c.groups++
+}
+func (c previewCounts) row() map[string]any {
+	return map[string]any{"workloadCount": c.workloads, "groupCount": c.groups, "runCount": c.runs}
+}
+func previewLabel(profile map[string]any, key, fallback string) string {
+	if value, _ := profile[key].(string); value != "" {
+		return value
+	}
+	return fallback
+}
+
+// previewFolded mirrors the backend rule: a project with two workloads or fewer joins the other-projects row.
+func previewFolded(profiles []map[string]any) map[string]bool {
+	totals := map[string]*previewCounts{}
+	for _, profile := range profiles {
+		project := previewLabel(profile, "project", "Unknown project")
+		if totals[project] == nil {
+			totals[project] = &previewCounts{}
+		}
+		totals[project].add(profile)
+	}
+	folded := map[string]bool{}
+	for project, total := range totals {
+		folded[project] = total.workloads <= 2
+	}
+	return folded
+}
+
+func (q *fakeQueue) listProfiles(arguments []string) (map[string]any, error) {
+	offset, limit, query, project, operation := 0, 100, "", "", ""
+	var other, hierarchy bool
+	seen := map[string]bool{}
+	for index := 0; index < len(arguments); index++ {
+		flag := arguments[index]
+		seen[flag] = true
+		switch flag {
+		case "--json":
+		case "--other-projects":
+			other = true
+		case "--hierarchy":
+			hierarchy = true
+		case "--offset", "--limit", "--query", "--project", "--operation":
+			if index+1 >= len(arguments) {
+				return nil, errors.New("Missing fixture profile filter value")
+			}
+			index++
+			value := arguments[index]
+			var err error
+			switch flag {
+			case "--offset":
+				offset, err = strconv.Atoi(value)
+			case "--limit":
+				limit, err = strconv.Atoi(value)
+			case "--query":
+				query = strings.ToLower(value)
+			case "--project":
+				project = value
+			case "--operation":
+				if !slices.Contains(previewOperations, value) {
+					err = errors.New("Invalid profile filter")
+				}
+				operation = value
+			}
+			if err != nil || offset < 0 || limit < 1 || limit > 100 {
+				return nil, errors.New("Invalid profile pagination or filter")
+			}
+		default:
+			return nil, errors.New("Unknown profile option")
+		}
+	}
+	if other && seen["--project"] {
+		return nil, errors.New("Invalid profile filter combination")
+	}
+	for flag := range seen {
+		if hierarchy && !slices.Contains([]string{"--hierarchy", "--limit", "--json"}, flag) {
+			return nil, errors.New("The project hierarchy takes only a limit")
+		}
+	}
+	ids := make([]string, 0, len(q.profiles))
+	for id := range q.profiles {
+		ids = append(ids, id)
+	}
+	slices.Sort(ids)
+	all := make([]map[string]any, 0, len(ids))
+	memberProjects := map[string]string{}
+	for _, id := range ids {
+		profile, ok := object(q.profiles[id]["profile"])
+		if !ok {
+			return nil, errors.New("Invalid fixture profile")
+		}
+		all = append(all, profile)
+		fixtureMembers, err := members(q.profiles[id]["members"])
+		if err != nil {
+			return nil, err
+		}
+		for _, member := range fixtureMembers {
+			memberProjects[id] += " " + strings.ToLower(fmt.Sprint(member["project"]))
+		}
+	}
+	status := map[string]any{"controllerAppliedRevision": 3, "controllerStatus": "Waiting for next fresh pass"}
+	if hierarchy {
+		return q.hierarchy(all, limit, status), nil
+	}
+	folded := previewFolded(all)
+	profiles := make([]map[string]any, 0)
+	for _, profile := range all {
+		name := previewLabel(profile, "project", "Unknown project")
+		label, _ := profile["label"].(string)
+		if project != "" && name != project || operation != "" && previewLabel(profile, "operation", "other") != operation || other && !folded[name] || !strings.Contains(strings.ToLower(label)+memberProjects[fmt.Sprint(profile["id"])], query) {
+			continue
+		}
+		profiles = append(profiles, clone(profile))
+	}
+	total := len(profiles)
+	start := min(offset, total)
+	end := min(start+limit, total)
+	result := map[string]any{"revision": q.profileRevision, "profiles": profiles[start:end], "total": total, "offset": offset, "limit": limit}
+	maps.Copy(result, status)
+	return result, nil
+}
+
+func (q *fakeQueue) hierarchy(all []map[string]any, limit int, status map[string]any) map[string]any {
+	folded := previewFolded(all)
+	byProject := map[string][]map[string]any{}
+	var foldedProfiles []map[string]any
+	for _, profile := range all {
+		name := previewLabel(profile, "project", "Unknown project")
+		if folded[name] {
+			foldedProfiles = append(foldedProfiles, profile)
+		} else {
+			byProject[name] = append(byProject[name], profile)
+		}
+	}
+	row := func(project string, collapsed bool, profiles []map[string]any) map[string]any {
+		total, byOperation := previewCounts{}, map[string]*previewCounts{}
+		for _, profile := range profiles {
+			operation := previewLabel(profile, "operation", "other")
+			if byOperation[operation] == nil {
+				byOperation[operation] = &previewCounts{}
+			}
+			byOperation[operation].add(profile)
+			total.add(profile)
+		}
+		operations := make([]map[string]any, 0, len(byOperation))
+		for _, operation := range previewOperations {
+			if counts := byOperation[operation]; counts != nil {
+				entry := counts.row()
+				entry["operation"] = operation
+				operations = append(operations, entry)
 			}
-			label, _ := profile["label"].(string)
-			if strings.Contains(strings.ToLower(label), query) {
-				profiles = append(profiles, clone(profile))
+		}
+		slices.SortStableFunc(operations, func(a, b map[string]any) int { return b["workloadCount"].(int) - a["workloadCount"].(int) })
+		result := total.row()
+		maps.Copy(result, map[string]any{"project": project, "collapsed": collapsed, "operations": operations})
+		return result
+	}
+	rows := make([]map[string]any, 0, len(byProject)+1)
+	for project, profiles := range byProject {
+		rows = append(rows, row(project, false, profiles))
+	}
+	slices.SortFunc(rows, func(a, b map[string]any) int {
+		if a["workloadCount"] != b["workloadCount"] {
+			return b["workloadCount"].(int) - a["workloadCount"].(int)
+		}
+		return strings.Compare(a["project"].(string), b["project"].(string))
+	})
+	total := len(rows)
+	if len(foldedProfiles) > 0 {
+		other := row("Other projects", true, foldedProfiles)
+		count := 0
+		for _, isFolded := range folded {
+			if isFolded {
+				count++
 			}
 		}
-		total := len(profiles)
-		start := min(offset, total)
-		end := min(start+limit, total)
-		return map[string]any{"revision": q.profileRevision, "profiles": profiles[start:end], "total": total, "offset": offset, "limit": limit, "controllerAppliedRevision": 3, "controllerStatus": "Waiting for next fresh pass"}, nil
+		other["projectCount"] = count
+		rows = append(rows, other)
+	}
+	result := map[string]any{"revision": q.profileRevision, "projects": rows[:min(limit, len(rows))], "total": total, "limit": limit}
+	maps.Copy(result, status)
+	return result
+}
+
+func (q *fakeQueue) profilesCall(arguments []string, input []byte) (map[string]any, error) {
+	action := arguments[1]
+	if action == "list" {
+		return q.listProfiles(arguments[2:])
 	}
 	id := arguments[2]
 	detail, ok := q.profiles[id]
diff --git a/internal/preview/preview_test.go b/internal/preview/preview_test.go
index a0a91c5..90fde20 100644
--- a/internal/preview/preview_test.go
+++ b/internal/preview/preview_test.go
@@ -312,3 +312,77 @@ func TestPreviewCombineGroupsAndWorkloadsRetainsSiblings(t *testing.T) {
 		t.Fatal("Failed fixture combine committed changes", err)
 	}
 }
+
+func TestPreviewHierarchyAndFiltersFollowTheBackendContract(t *testing.T) {
+	q := &fakeQueue{root: t.TempDir()}
+	if err := q.reset(); err != nil {
+		t.Fatal(err)
+	}
+	ctx := context.Background()
+	list := func(arguments ...string) map[string]any {
+		t.Helper()
+		result, err := q.run(ctx, append([]string{"profiles", "list"}, append(arguments, "--json")...), nil)
+		if err != nil {
+			t.Fatal(arguments, err)
+		}
+		return result
+	}
+	overview := list("--hierarchy", "--limit", "50")
+	rows, err := members(overview["projects"])
+	if err != nil || overview["total"] != 2 || len(rows) != 3 {
+		t.Fatal(overview, err)
+	}
+	var names []string
+	for _, row := range rows {
+		names = append(names, row["project"].(string))
+	}
+	if !reflect.DeepEqual(names, []string{"Sample app", "Sample service", "Other projects"}) {
+		t.Fatal("projects are not busiest first with the folded row last", names)
+	}
+	other := rows[2]
+	if other["collapsed"] != true || other["projectCount"] != float64(2) || other["groupCount"] != float64(2) || other["workloadCount"] != float64(2) {
+		t.Fatal(other)
+	}
+	operations, _ := members(rows[0]["operations"])
+	if len(operations) < 3 || operations[0]["operation"] != "tests" {
+		t.Fatal("operations are not busiest first", operations)
+	}
+	groups := func(arguments ...string) []map[string]any {
+		t.Helper()
+		page, err := members(list(append([]string{"--offset", "0", "--limit", "100", "--query", ""}, arguments...)...)["profiles"])
+		if err != nil {
+			t.Fatal(err)
+		}
+		return page
+	}
+	// Every count in the overview describes exactly the groups its filter returns.
+	for _, row := range rows {
+		filter := []string{"--project", row["project"].(string)}
+		if row["collapsed"] == true {
+			filter = []string{"--other-projects"}
+		}
+		if got := len(groups(filter...)); float64(got) != row["groupCount"] {
+			t.Fatal(row["project"], got, row["groupCount"])
+		}
+		inner, _ := members(row["operations"])
+		for _, operation := range inner {
+			if got := len(groups(append(filter, "--operation", operation["operation"].(string))...)); float64(got) != operation["groupCount"] {
+				t.Fatal(row["project"], operation["operation"], got, operation["groupCount"])
+			}
+		}
+	}
+	for _, bad := range [][]string{{"--hierarchy", "--query", "x"}, {"--hierarchy", "--project", "Sample app"}, {"--other-projects", "--project", "Sample app"}, {"--operation", "nope"}} {
+		if _, err := q.run(ctx, append(append([]string{"profiles", "list", "--limit", "5"}, bad...), "--json"), nil); err == nil {
+			t.Fatal("accepted", bad)
+		}
+	}
+	if found := groups("--query", "tooling scripts"); len(found) != 1 || strings.Contains(strings.ToLower(found[0]["label"].(string)), "tooling") {
+		t.Fatal("search must also match the project name", found)
+	}
+	if len(groups("--project", "Missing project")) != 0 {
+		t.Fatal("an unknown project must be an empty page, not an error")
+	}
+	if len(q.events) != 0 {
+		t.Fatal("reads recorded events", q.events)
+	}
+}
diff --git a/native/AgentLearning.swift b/native/AgentLearning.swift
index a0930db..f26e2b2 100644
--- a/native/AgentLearning.swift
+++ b/native/AgentLearning.swift
@@ -26,6 +26,72 @@ struct LearningCatalog: Codable, Sendable {
     var controllerStatus: String? = nil
 }
 
+struct LearningOperationRow: Codable, Identifiable, Equatable, Sendable {
+    let operation: String
+    let workloadCount: Int
+    let groupCount: Int
+    let runCount: Int
+    var id: String { operation }
+}
+
+struct LearningProjectRow: Codable, Identifiable, Equatable, Sendable {
+    let project: String
+    let collapsed: Bool
+    var projectCount: Int? = nil
+    let workloadCount: Int
+    let groupCount: Int
+    let runCount: Int
+    let operations: [LearningOperationRow]
+    var id: String { "\(collapsed ? "other" : "project"):\(project)" }
+}
+
+struct LearningHierarchy: Codable, Equatable, Sendable {
+    let revision: Int
+    let projects: [LearningProjectRow]
+    let total: Int
+    let limit: Int
+    var controllerAppliedRevision: Int? = nil
+    var controllerStatus: String? = nil
+}
+
+enum LearningLevel: Sendable { case projects, operations, groups }
+
+/// The project row a drill-down is inside. Identity is the name plus the folded flag.
+struct LearningScope: Equatable, Sendable {
+    let project: String
+    let collapsed: Bool
+    /// Set when a merge left this project without an operations level; going up skips it.
+    var folded = false
+}
+
+func learningOperationName(_ operation: String) -> String {
+    let words = operation.replacingOccurrences(of: "-", with: " ")
+    return words.prefix(1).uppercased() + words.dropFirst()
+}
+
+func learningCounts(workloads: Int, groups: Int, runs: Int) -> String {
+    func plural(_ count: Int, _ word: String) -> String { "\(count.formatted()) \(word)\(count == 1 ? "" : "s")" }
+    return "\(plural(workloads, "workload")) · \(plural(groups, "group")) · \(plural(runs, "run"))"
+}
+
+/// The total counts only projects with three or more workloads; smaller ones fold into the last row, which truncation drops first.
+func learningProjectsNote(total: Int, shown: Int, limit: Int) -> String? {
+    if total > shown {
+        let hidden = total - shown
+        return "Showing the \(shown) busiest of \(total) projects with three or more workloads. \(hidden) \(hidden == 1 ? "project is" : "projects are") not shown, nor any smaller projects. Search a project or group name to find them."
+    }
+    if shown >= limit {
+        return "Showing the \(shown) busiest projects with three or more workloads. Smaller projects are not shown. Search a project or group name to find them."
+    }
+    return nil
+}
+
+struct LearningFilter: Equatable, Sendable {
+    var project: String?
+    var operation: String?
+    var otherProjects = false
+}
+
 struct LearningMember: Codable, Identifiable, Sendable {
     let family: String
     let label: String
@@ -149,6 +215,14 @@ enum LearningMemoryUnit: String, CaseIterable {
 @MainActor
 final class LearningStore: ObservableObject {
     @Published private(set) var catalog: LearningCatalog?
+    @Published private(set) var hierarchy: LearningHierarchy?
+    @Published private(set) var level = LearningLevel.projects
+    @Published private(set) var scope: LearningScope?
+    @Published private(set) var operation: String?
+    /// True while the visible list is a global search; it hides the drill-down until the query is cleared.
+    @Published private(set) var searchActive = false
+    /// The search the visible list answers. The box can hold something newer that was never submitted.
+    private(set) var submittedQuery = ""
     @Published private(set) var detail: LearningDetail?
     @Published private(set) var selectedID: String?
     @Published private(set) var isLoading = false
@@ -177,11 +251,13 @@ final class LearningStore: ObservableObject {
     private var memberTask: Task?
     private let command: @Sendable (QueueClient.Command) async throws -> Data
 
-    init(fixtureMode: Bool = false, command: @escaping @Sendable (QueueClient.Command) async throws -> Data = {
+    /// `flat` is for the merge-target picker, which lists every group and never drills down.
+    init(fixtureMode: Bool = false, flat: Bool = false, command: @escaping @Sendable (QueueClient.Command) async throws -> Data = {
         try await QueueClient.run($0)
     }) {
         self.fixtureMode = fixtureMode
         self.command = command
+        level = flat ? .groups : .projects
         #if AGENT_QUEUE_TEST_HOOKS
         if fixtureMode {
             let preview = Self.previewDetail()
@@ -190,18 +266,52 @@ final class LearningStore: ObservableObject {
             selectedID = preview.profile.id
             detail = preview
             restoreDraft(preview)
+            if !flat {
+                hierarchy = Self.previewHierarchy()
+                level = .groups
+                scope = LearningScope(project: "Sample project", collapsed: false)
+                operation = "tests"
+            }
         }
         #endif
     }
 
+    var displayLevel: LearningLevel { searchActive ? .groups : level }
+    var projectRow: LearningProjectRow? {
+        guard let scope else { return nil }
+        return hierarchy?.projects.first { $0.collapsed == scope.collapsed && $0.project == scope.project }
+    }
+    /// Says what the overview leaves out, or nil when it lists everything.
+    var hierarchyNote: String? {
+        guard let hierarchy else { return nil }
+        return learningProjectsNote(total: hierarchy.total, shown: hierarchy.projects.filter { !$0.collapsed }.count, limit: hierarchy.limit)
+    }
+    var breadcrumb: String {
+        guard let scope else { return "All projects" }
+        let trail = ["All projects", scope.project]
+        return (displayLevel == .groups ? trail + [operation.map(learningOperationName) ?? "All operations"] : trail).joined(separator: " / ")
+    }
+
     var canSave: Bool { !fixtureMode && !isLoading && !isMutating && !isMemberPageLoading && detail != nil }
-    var hasPrevious: Bool { (catalog?.offset ?? 0) > 0 }
+    var hasPrevious: Bool { (catalog?.offset ?? 0) > 0 && (catalog?.total ?? 0) > 0 }
     var hasNext: Bool {
         guard let catalog else { return false }
         return catalog.offset + catalog.profiles.count < catalog.total
     }
+    #if AGENT_QUEUE_TEST_HOOKS
+    func showFixtureLevel(_ level: LearningLevel) {
+        guard fixtureMode else { return }
+        self.level = level
+        if level == .projects {
+            scope = nil
+            operation = nil
+            clearSelection()
+        }
+    }
+    #endif
+
     var pageLabel: String {
-        guard let catalog, catalog.total > 0 else { return "No learned workloads" }
+        guard let catalog, catalog.total > 0, !catalog.profiles.isEmpty else { return "No learned workloads" }
         return "\(catalog.offset + 1)–\(catalog.offset + catalog.profiles.count) of \(catalog.total)"
     }
     var controllerMessage: String? {
@@ -216,30 +326,125 @@ final class LearningStore: ObservableObject {
         return "Waiting for the controller to apply these corrections. Active jobs continue."
     }
 
+    /// Submits the search box and reads the list from `offset`.
     @discardableResult
     func loadPage(offset: Int = 0, preserveSelection: Bool = false) -> Task? {
+        load(query: query.trimmingCharacters(in: .whitespacesAndNewlines), offset: offset, preserveSelection: preserveSelection)
+    }
+
+    /// Re-reads or pages the list on screen: its submitted search, never text typed in the box and not yet submitted.
+    @discardableResult
+    func reloadPage(offset: Int? = nil, preserveSelection: Bool = false) -> Task? {
+        load(query: submittedQuery, offset: offset ?? catalog?.offset ?? 0, preserveSelection: preserveSelection)
+    }
+
+    private func load(query requestedQuery: String, offset: Int, preserveSelection: Bool) -> Task? {
         guard !fixtureMode, !isLoading, !isMutating else { return nil }
         isLoading = true
         error = nil
-        let requestedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
+        let searching = !requestedQuery.isEmpty
+        let requestedLevel = level
         let requestedGeneration = selectionGeneration
         return Task {
             defer { isLoading = false }
             do {
-                let result = try await readCatalog(offset: max(0, offset), query: requestedQuery)
+                if !searching && requestedLevel != .groups {
+                    if try await readOverview(from: requestedLevel) {
+                        searchActive = false
+                        submittedQuery = ""
+                        // A group opened from a job link while this read ran stays open.
+                        if (!preserveSelection || selectedID == nil) && selectionGeneration == requestedGeneration { clearSelection() }
+                        return
+                    }
+                }
+                let result = try await readCatalog(offset: max(0, offset), query: requestedQuery, filter: searching ? LearningFilter() : filter)
                 catalog = result
+                searchActive = searching
+                submittedQuery = requestedQuery
                 if (!preserveSelection || selectedID == nil) && selectionGeneration == requestedGeneration {
                     if let id = result.profiles.first?.id { await select(id)?.value }
-                    else {
-                        selectionTask?.cancel()
-                        selectionGeneration += 1
-                        selectedID = nil
-                        detail = nil
-                        isDetailLoading = false
-                        clearDraft()
-                    }
+                    else { clearSelection() }
                 }
-            } catch { self.error = "Could not load learned workloads. \(error.localizedDescription)" }
+            } catch {
+                // Clearing a search that cannot be left would strand the view on results the box no longer asks for.
+                if !searching && searchActive {
+                    searchActive = false
+                    submittedQuery = ""
+                    if requestedLevel == .groups { catalog = nil }
+                    if selectionGeneration == requestedGeneration { clearSelection() }
+                }
+                self.error = "Could not load learned workloads. \(error.localizedDescription)"
+            }
+        }
+    }
+
+    /// Re-reads the overview. A merge can fold the project into the other-projects row, which has no
+    /// operations level: then this switches to its groups and returns false for the caller to list them.
+    private func readOverview(from requestedLevel: LearningLevel) async throws -> Bool {
+        hierarchy = try await readHierarchy()
+        if requestedLevel != .operations || projectRow != nil { return true }
+        level = .groups
+        operation = nil
+        catalog = nil
+        scope?.folded = true
+        return false
+    }
+
+    private func clearSelection() {
+        selectionTask?.cancel()
+        selectionGeneration += 1
+        selectedID = nil
+        detail = nil
+        isDetailLoading = false
+        clearDraft()
+    }
+
+    private var filter: LearningFilter {
+        guard let scope else { return LearningFilter() }
+        return LearningFilter(project: scope.collapsed ? nil : scope.project, operation: operation, otherProjects: scope.collapsed)
+    }
+
+    /// Drilling down reads nothing: the overview already holds every operation of its project.
+    func openProject(_ row: LearningProjectRow) {
+        guard !fixtureMode, !isLoading, !isMutating else { return }
+        scope = LearningScope(project: row.project, collapsed: row.collapsed)
+        operation = nil
+        level = .operations
+        searchActive = false
+        submittedQuery = ""
+        query = ""
+        clearSelection()
+    }
+
+    @discardableResult
+    func openOperation(_ operation: String?) -> Task? {
+        guard !fixtureMode, !isLoading, !isMutating, scope != nil else { return nil }
+        self.operation = operation
+        level = .groups
+        catalog = nil
+        clearSelection()
+        return reloadPage()
+    }
+
+    /// Going up reloads, so the counts reflect any merge or edit made below.
+    @discardableResult
+    func show(_ target: LearningLevel) -> Task? {
+        guard !fixtureMode, !isLoading, !isMutating, target != .groups else { return nil }
+        level = target
+        operation = nil
+        if target == .projects { scope = nil }
+        query = ""
+        catalog = nil
+        clearSelection()
+        return loadPage()
+    }
+
+    @discardableResult
+    func goBack() -> Task? {
+        switch level {
+        case .groups: show(scope?.folded == true ? .projects : .operations)
+        case .operations: show(.projects)
+        case .projects: nil
         }
     }
 
@@ -284,7 +489,7 @@ final class LearningStore: ObservableObject {
     func reloadSelection() -> Task? {
         guard !isMutating else { return nil }
         if let selectedID { return select(selectedID) }
-        return loadPage()
+        return reloadPage()
     }
 
     func closeMember() {
@@ -520,17 +725,33 @@ final class LearningStore: ObservableObject {
                     else if operation == "merge" || operation == "combine" { ungroupedFrom = nil }
                     notice = success
                 }
-                do { catalog = try await readCatalog(offset: catalog?.offset ?? 0, query: query) }
-                catch { self.error = "The correction was saved, but the list could not refresh. \(error.localizedDescription)" }
+                do {
+                    if searchActive || level == .groups {
+                        catalog = try await readCatalog(offset: catalog?.offset ?? 0, query: searchActive ? submittedQuery : "",
+                            filter: searchActive ? LearningFilter() : filter)
+                    } else if try await !readOverview(from: level) {
+                        catalog = try await readCatalog(offset: 0, query: "", filter: filter)
+                    }
+                } catch { self.error = "The correction was saved, but the list could not refresh. \(error.localizedDescription)" }
             } catch {
                 self.error = "Could not confirm the correction. \(error.localizedDescription) Reload to check whether it was applied before retrying. Your edits are preserved."
             }
         }
     }
 
-    private func readCatalog(offset: Int, query: String) async throws -> LearningCatalog {
-        try JSONDecoder().decode(LearningCatalog.self, from: try await command(
-            .profilesList(offset: offset, limit: pageSize, query: query)))
+    private func readCatalog(offset: Int, query: String, filter: LearningFilter) async throws -> LearningCatalog {
+        func read(_ offset: Int) async throws -> LearningCatalog {
+            try JSONDecoder().decode(LearningCatalog.self, from: try await command(
+                .profilesList(offset: offset, limit: pageSize, query: query, project: filter.project,
+                    operation: filter.operation, otherProjects: filter.otherProjects)))
+        }
+        let page = try await read(offset)
+        // A merge can shrink the list below this page: show the last page that exists.
+        guard page.total > 0, page.offset >= page.total else { return page }
+        return try await read((page.total - 1) / pageSize * pageSize)
+    }
+    private func readHierarchy() async throws -> LearningHierarchy {
+        try JSONDecoder().decode(LearningHierarchy.self, from: try await command(.profilesHierarchy(limit: 100)))
     }
     private func readDetail(_ id: String) async throws -> LearningDetail {
         try JSONDecoder().decode(LearningDetail.self, from: try await command(.profilesShow(id)))
@@ -558,6 +779,19 @@ final class LearningStore: ObservableObject {
     }
 
     #if AGENT_QUEUE_TEST_HOOKS
+    nonisolated static func previewHierarchy() -> LearningHierarchy {
+        func operations(_ rows: [(String, Int, Int, Int)]) -> [LearningOperationRow] {
+            rows.map { LearningOperationRow(operation: $0.0, workloadCount: $0.1, groupCount: $0.2, runCount: $0.3) }
+        }
+        return LearningHierarchy(revision: 4, projects: [
+            LearningProjectRow(project: "Sample project", collapsed: false, workloadCount: 9, groupCount: 3, runCount: 44,
+                operations: operations([("tests", 1, 1, 20), ("build", 6, 1, 16), ("lint", 2, 1, 8)])),
+            LearningProjectRow(project: "Sample service", collapsed: false, workloadCount: 5, groupCount: 2, runCount: 30,
+                operations: operations([("tests", 4, 1, 22), ("race-tests", 1, 1, 8)])),
+            LearningProjectRow(project: "Other projects", collapsed: true, projectCount: 3, workloadCount: 3, groupCount: 3, runCount: 9,
+                operations: operations([("build", 2, 2, 6), ("other", 1, 1, 3)])),
+        ], total: 2, limit: 100, controllerAppliedRevision: 4, controllerStatus: "applied")
+    }
     nonisolated static func previewDetail() -> LearningDetail {
         let family = String(repeating: "b", count: 64)
         let variant = String(repeating: "c", count: 64)
@@ -660,15 +894,21 @@ struct LearningWindow: View {
                             description: Text("Reload the selected workload to try again."))
                             .frame(maxWidth: .infinity, minHeight: 250)
                     } else {
-                        ContentUnavailableView(store.query.isEmpty ? "No learned workloads" : "No matching workloads", systemImage: "chart.bar.doc.horizontal",
-                            description: Text(store.query.isEmpty ? "Run jobs with resource monitoring enabled to build a local history." : "Try another search or refresh the list."))
-                            .frame(maxWidth: .infinity, minHeight: 250)
+                        if store.displayLevel != .groups, store.hierarchy?.projects.isEmpty == false {
+                            ContentUnavailableView("Choose a group", systemImage: "chart.bar.doc.horizontal",
+                                description: Text("Pick a project, then an operation, to list its learned groups."))
+                                .frame(maxWidth: .infinity, minHeight: 250)
+                        } else {
+                            ContentUnavailableView(store.searchActive ? "No matching workloads" : "No learned workloads", systemImage: "chart.bar.doc.horizontal",
+                                description: Text(store.searchActive ? "Try another search or refresh the list." : "Run jobs with resource monitoring enabled to build a local history."))
+                                .frame(maxWidth: .infinity, minHeight: 250)
+                        }
                     }
                 }.frame(minWidth: 480, maxWidth: .infinity, maxHeight: .infinity)
             }
         }
         .frame(maxWidth: .infinity, maxHeight: .infinity)
-        .task { if store.catalog == nil { await store.loadPage()?.value } }
+        .task { if store.catalog == nil && store.hierarchy == nil { await store.loadPage()?.value } }
         .sheet(isPresented: $choosingTarget, onDismiss: {
             if let pendingConfirmation {
                 confirmation = pendingConfirmation
@@ -729,45 +969,138 @@ struct LearningWindow: View {
                 TextField("Search workloads", text: $store.query).textFieldStyle(.roundedBorder)
                     .onSubmit { store.loadPage() }
                 Button("Search", systemImage: "magnifyingglass") { store.loadPage() }
-                    .labelStyle(.iconOnly).help("Search workload names and safe job descriptions")
+                    .labelStyle(.iconOnly).help("Search every project by workload, project or safe job description")
             }.disabled(store.fixtureMode || store.isLoading || store.isMutating)
+            if store.searchActive {
+                Text("Search results across every project").font(.caption).foregroundStyle(.secondary)
+            } else if store.level != .projects {
+                HStack(alignment: .firstTextBaseline) {
+                    Button("Back", systemImage: "chevron.left") { store.goBack() }
+                        .disabled(store.fixtureMode || store.isLoading || store.isMutating)
+                    breadcrumbTrail
+                }
+            }
             ScrollView {
                 LazyVStack(alignment: .leading, spacing: 4) {
-                    ForEach(store.catalog?.profiles ?? []) { profile in
-                        HStack(alignment: .top, spacing: 6) {
-                            mergeToggle(LearningMergeItem(identity: profile.id, label: profile.label, isGroup: true, sourceGroup: profile.id, revision: store.catalog?.revision ?? 0))
-                            Button { store.select(profile.id) } label: {
-                                VStack(alignment: .leading, spacing: 5) {
-                                    Text(profile.label).font(.headline).lineLimit(2)
-                                    Text("\(profile.memberCount) families · \(profile.variantCount) variants")
-                                    Text("\(profile.successCount) successful · \(profile.incompleteCount) incomplete")
-                                }.font(.caption).frame(maxWidth: .infinity, alignment: .leading).padding(10)
-                                    .contentShape(Rectangle())
-                                    .background(store.selectedID == profile.id ? Color.accentColor.opacity(0.13) : Color.clear)
-                                    .clipShape(RoundedRectangle(cornerRadius: 7))
-                            }.buttonStyle(.plain).disabled(store.fixtureMode)
-                        }
+                    switch store.displayLevel {
+                    case .projects: projectRows
+                    case .operations: operationRows
+                    case .groups: groupRows
                     }
                 }
             }
-            Divider()
-            HStack {
-                Button("Previous", systemImage: "chevron.left") {
-                    store.loadPage(offset: max(0, (store.catalog?.offset ?? 0) - store.pageSize))
-                }.labelStyle(.iconOnly).disabled(!store.hasPrevious)
-                Spacer()
-                Text(store.pageLabel).font(.caption).foregroundStyle(.secondary)
-                Spacer()
-                Button("Next", systemImage: "chevron.right") {
-                    store.loadPage(offset: (store.catalog?.offset ?? 0) + store.pageSize)
-                }.labelStyle(.iconOnly).disabled(!store.hasNext)
-            }.disabled(store.fixtureMode || store.isLoading || store.isMutating)
+            if store.displayLevel == .groups {
+                Divider()
+                HStack {
+                    Button("Previous", systemImage: "chevron.left") {
+                        store.reloadPage(offset: max(0, (store.catalog?.offset ?? 0) - store.pageSize))
+                    }.labelStyle(.iconOnly).disabled(!store.hasPrevious)
+                    Spacer()
+                    Text(store.pageLabel).font(.caption).foregroundStyle(.secondary)
+                    Spacer()
+                    Button("Next", systemImage: "chevron.right") {
+                        store.reloadPage(offset: (store.catalog?.offset ?? 0) + store.pageSize)
+                    }.labelStyle(.iconOnly).disabled(!store.hasNext)
+                }.disabled(store.fixtureMode || store.isLoading || store.isMutating)
+            }
             Button("Refresh list", systemImage: "arrow.clockwise") {
-                store.loadPage(offset: store.catalog?.offset ?? 0, preserveSelection: true)
+                store.reloadPage(preserveSelection: true)
             }.disabled(store.fixtureMode || store.isLoading || store.isMutating)
         }.padding(16)
     }
 
+    private func browseRow(_ title: String, _ counts: String, note: String? = nil, action: @escaping () -> Void) -> some View {
+        Button(action: action) {
+            VStack(alignment: .leading, spacing: 5) {
+                Text(title).font(.headline).lineLimit(2)
+                Text(counts)
+                if let note { Text(note) }
+            }.font(.caption).frame(maxWidth: .infinity, alignment: .leading).padding(10).contentShape(Rectangle())
+        }.buttonStyle(.plain).disabled(store.fixtureMode || store.isLoading || store.isMutating)
+    }
+
+    private var backToProjects: some View {
+        Button("Back to all projects") { store.show(.projects) }.disabled(store.fixtureMode || store.isLoading || store.isMutating)
+    }
+
+    /// Earlier parts of the location go to their level; the last part is where the operator is.
+    private var breadcrumbTrail: some View {
+        let blocked = store.fixtureMode || store.isLoading || store.isMutating
+        return HStack(spacing: 4) {
+            Button("All projects") { store.show(.projects) }.buttonStyle(.link).disabled(blocked)
+            if let scope = store.scope {
+                Text("/")
+                if store.displayLevel == .groups {
+                    Button(scope.project) { store.show(scope.folded ? .projects : .operations) }.buttonStyle(.link).disabled(blocked)
+                    Text("/")
+                    Text(store.operation.map(learningOperationName) ?? "All operations").fontWeight(.semibold)
+                } else {
+                    Text(scope.project).fontWeight(.semibold)
+                }
+            }
+        }.font(.caption).lineLimit(2).accessibilityElement(children: .contain).accessibilityLabel("Location: \(store.breadcrumb)")
+    }
+
+    @ViewBuilder private var projectRows: some View {
+        if let hierarchy = store.hierarchy, !hierarchy.projects.isEmpty {
+            if let note = store.hierarchyNote {
+                Text(note)
+                    .font(.caption).foregroundStyle(.secondary).padding(.horizontal, 10)
+            }
+            ForEach(hierarchy.projects) { row in
+                browseRow(row.project, learningCounts(workloads: row.workloadCount, groups: row.groupCount, runs: row.runCount),
+                    note: row.collapsed ? row.projectCount.map { "Covers \($0) smaller \($0 == 1 ? "project" : "projects")" } : nil) { store.openProject(row) }
+            }
+        } else if store.hierarchy != nil {
+            Text("No learned workloads yet.").font(.callout).foregroundStyle(.secondary).padding(10)
+        } else if !store.isLoading {
+            Text("The project overview is not loaded. Use Refresh list.").font(.callout).foregroundStyle(.secondary).padding(10)
+        }
+    }
+
+    @ViewBuilder private var operationRows: some View {
+        if let row = store.projectRow {
+            browseRow("All operations", learningCounts(workloads: row.workloadCount, groups: row.groupCount, runs: row.runCount)) {
+                store.openOperation(nil)
+            }
+            ForEach(row.operations) { operation in
+                browseRow(learningOperationName(operation.operation),
+                    learningCounts(workloads: operation.workloadCount, groups: operation.groupCount, runs: operation.runCount)) {
+                    store.openOperation(operation.operation)
+                }
+            }
+        } else if store.hierarchy != nil {
+            VStack(alignment: .leading, spacing: 8) {
+                Text("\(store.scope?.project ?? "This project") no longer has any learned workloads.").font(.callout)
+                backToProjects
+            }.padding(10)
+        }
+    }
+
+    @ViewBuilder private var groupRows: some View {
+        ForEach(store.catalog?.profiles ?? []) { profile in
+            HStack(alignment: .top, spacing: 6) {
+                mergeToggle(LearningMergeItem(identity: profile.id, label: profile.label, isGroup: true, sourceGroup: profile.id, revision: store.catalog?.revision ?? 0))
+                Button { store.select(profile.id) } label: {
+                    VStack(alignment: .leading, spacing: 5) {
+                        Text(profile.label).font(.headline).lineLimit(2)
+                        Text("\(profile.memberCount) families · \(profile.variantCount) variants")
+                        Text("\(profile.successCount) successful · \(profile.incompleteCount) incomplete")
+                    }.font(.caption).frame(maxWidth: .infinity, alignment: .leading).padding(10)
+                        .contentShape(Rectangle())
+                        .background(store.selectedID == profile.id ? Color.accentColor.opacity(0.13) : Color.clear)
+                        .clipShape(RoundedRectangle(cornerRadius: 7))
+                }.buttonStyle(.plain).disabled(store.fixtureMode)
+            }
+        }
+        if let catalog = store.catalog, catalog.profiles.isEmpty, !store.searchActive, store.scope != nil, !store.isLoading {
+            VStack(alignment: .leading, spacing: 8) {
+                Text("Nothing is left here.").font(.callout)
+                backToProjects
+            }.padding(10)
+        }
+    }
+
     private func detailView(_ detail: LearningDetail) -> some View {
         VStack(alignment: .leading, spacing: 22) {
             if let group = store.ungroupedFrom, group.id != detail.profile.id {
@@ -1037,7 +1370,7 @@ private struct LearningTargetPicker: View {
         self.sourceID = sourceID
         self.onChoose = onChoose
         self.onCancel = onCancel
-        _store = StateObject(wrappedValue: LearningStore(fixtureMode: fixtureMode))
+        _store = StateObject(wrappedValue: LearningStore(fixtureMode: fixtureMode, flat: true))
     }
     var body: some View {
         VStack(alignment: .leading, spacing: 14) {
@@ -1064,9 +1397,9 @@ private struct LearningTargetPicker: View {
                 }
             }.frame(minHeight: 160)
             HStack {
-                Button("Previous") { store.loadPage(offset: max(0, (store.catalog?.offset ?? 0) - store.pageSize)) }.disabled(!store.hasPrevious)
+                Button("Previous") { store.reloadPage(offset: max(0, (store.catalog?.offset ?? 0) - store.pageSize)) }.disabled(!store.hasPrevious)
                 Text(store.pageLabel).font(.caption).foregroundStyle(.secondary)
-                Button("Next") { store.loadPage(offset: (store.catalog?.offset ?? 0) + store.pageSize) }.disabled(!store.hasNext)
+                Button("Next") { store.reloadPage(offset: (store.catalog?.offset ?? 0) + store.pageSize) }.disabled(!store.hasNext)
                 Spacer()
                 Button("Cancel", action: onCancel).keyboardShortcut(.cancelAction)
             }.disabled(store.isLoading)
diff --git a/native/AgentQueue.swift b/native/AgentQueue.swift
index 75e6ded..d6922f0 100644
--- a/native/AgentQueue.swift
+++ b/native/AgentQueue.swift
@@ -513,9 +513,15 @@ extension QueueClient.Command {
     private static func profilesRead(_ arguments: [String]) -> Self {
         .init(arguments: ["profiles"] + arguments + ["--json"], cancellation: .discard, decodesStructuredErrors: true)
     }
-    static func profilesList(offset: Int, limit: Int, query: String) -> Self {
-        profilesRead(["list", "--offset", String(offset), "--limit", String(limit), "--query", query])
-    }
+    static func profilesList(offset: Int, limit: Int, query: String, project: String? = nil,
+                             operation: String? = nil, otherProjects: Bool = false) -> Self {
+        var arguments = ["list", "--offset", String(offset), "--limit", String(limit), "--query", query]
+        if let project { arguments += ["--project", project] }
+        if let operation { arguments += ["--operation", operation] }
+        if otherProjects { arguments.append("--other-projects") }
+        return profilesRead(arguments)
+    }
+    static func profilesHierarchy(limit: Int) -> Self { profilesRead(["list", "--hierarchy", "--limit", String(limit)]) }
     static func profilesShow(_ id: String) -> Self { profilesRead(["show", id]) }
     static func profilesShow(_ id: String, family: String) -> Self { profilesRead(["show", id, "--family", family]) }
     static func profilesShow(_ id: String, membersQuery: String, membersOffset: Int) -> Self {
diff --git a/native/tests/LearningTests.swift b/native/tests/LearningTests.swift
index 4099ce4..5045b74 100644
--- a/native/tests/LearningTests.swift
+++ b/native/tests/LearningTests.swift
@@ -50,11 +50,29 @@ private func testDetail(id: String = sourceID, revision: Int = 7, label: String
         controllerAppliedRevision: revision - 1, controllerStatus: "pending")
 }
 
+private let hierarchyFixture = LearningHierarchy(revision: 7, projects: [
+    LearningProjectRow(project: "Sample app", collapsed: false, workloadCount: 9, groupCount: 3, runCount: 40, operations: [
+        LearningOperationRow(operation: "tests", workloadCount: 5, groupCount: 2, runCount: 30),
+        LearningOperationRow(operation: "race-tests", workloadCount: 4, groupCount: 1, runCount: 10)]),
+    LearningProjectRow(project: "Other projects", collapsed: true, projectCount: 2, workloadCount: 2, groupCount: 2, runCount: 5, operations: [
+        LearningOperationRow(operation: "other", workloadCount: 2, groupCount: 2, runCount: 5)]),
+], total: 1, limit: 100, controllerAppliedRevision: 6, controllerStatus: "pending")
+
+/// Opens the first project's "All operations" list, the level where groups are browsed.
+@MainActor private func openGroups(_ store: LearningStore) async {
+    await store.loadPage()?.value
+    if let row = store.hierarchy?.projects.first { store.openProject(row) }
+    await store.openOperation(nil)?.value
+}
+
 private actor LearningFixture {
     private(set) var calls: [LearningCall] = []
     var revision = 7
     var shouldFail = false
     var shouldFailRead = false
+    var hierarchyEmpty = false
+    var catalogTotal = 104
+    var shouldFailHierarchy = false
     private var shouldHold = false
     private var held: CheckedContinuation?
     private var waiter: CheckedContinuation?
@@ -62,6 +80,11 @@ private actor LearningFixture {
     func holdNextCall() { shouldHold = true }
     func failNextMutation(_ value: Bool) { shouldFail = value }
     func failRead(_ value: Bool) { shouldFailRead = value }
+    /// Group selection follows every list read with a detail read; this finds the list read itself.
+    func lastList() -> [String]? { calls.last(where: { $0.arguments.count > 1 && $0.arguments[1] == "list" })?.arguments }
+    func emptyHierarchy(_ value: Bool) { hierarchyEmpty = value }
+    func setCatalogTotal(_ value: Int) { catalogTotal = value }
+    func failHierarchy(_ value: Bool) { shouldFailHierarchy = value }
     func waitUntilHeld() async {
         if held != nil { return }
         await withCheckedContinuation { waiter = $0 }
@@ -80,11 +103,16 @@ private actor LearningFixture {
         guard arguments.first == "profiles", arguments.count >= 3 else { throw ClientError.message("Unexpected command") }
         let encoder = JSONEncoder()
         let operation = arguments[1]
+        if operation == "list" && arguments.contains("--hierarchy") {
+            if shouldFailHierarchy { throw ClientError.message("Overview unavailable") }
+            return try encoder.encode(hierarchyEmpty ? LearningHierarchy(revision: revision, projects: [], total: 0, limit: 100) : hierarchyFixture)
+        }
         if operation == "list" {
             let offset = arguments.firstIndex(of: "--offset").flatMap { Int(arguments[$0 + 1]) } ?? 0
+            let page = offset < catalogTotal ? [testDetail(revision: revision).profile, testDetail(id: targetID, revision: revision, label: "Build tests").profile] : []
             return try encoder.encode(LearningCatalog(revision: revision,
-                profiles: [testDetail(revision: revision).profile, testDetail(id: targetID, revision: revision, label: "Build tests").profile],
-                total: 104, offset: offset, limit: 50, controllerAppliedRevision: revision - 1, controllerStatus: "pending"))
+                profiles: page,
+                total: catalogTotal, offset: offset, limit: 50, controllerAppliedRevision: revision - 1, controllerStatus: "pending"))
         }
         if operation == "show" {
             if shouldFailRead { throw ClientError.message("Profile no longer available") }
@@ -171,6 +199,7 @@ struct LearningTests {
 
         let linkingFixture = LearningFixture()
         let linkingStore = LearningStore(command: { try await linkingFixture.load($0.arguments, $0.input) })
+        await openGroups(linkingStore)
         await linkingFixture.holdNextCall()
         let catalogLoad = linkingStore.loadPage()
         await linkingFixture.waitUntilHeld()
@@ -180,13 +209,27 @@ struct LearningTests {
         try check(linkingStore.selectedID == targetID && linkingStore.detail?.profile.id == targetID,
             "Initial catalog I/O cannot overwrite a profile selected through a job link")
 
+        // First open from a job link: the window's own load is still reading the projects when the link selects its group.
+        let firstOpenFixture = LearningFixture()
+        let firstOpen = LearningStore(command: { try await firstOpenFixture.load($0.arguments, $0.input) })
+        await firstOpenFixture.holdNextCall()
+        let windowLoad = firstOpen.loadPage()
+        await firstOpenFixture.waitUntilHeld()
+        await firstOpen.loadPage(preserveSelection: true)?.value
+        await firstOpen.select(targetID)?.value
+        await firstOpenFixture.release()
+        await windowLoad?.value
+        try check(firstOpen.selectedID == targetID && firstOpen.detail?.profile.id == targetID,
+            "A group opened from a job link while the first projects read is in flight stays open")
+
         let fixture = LearningFixture()
         let store = LearningStore(command: { try await fixture.load($0.arguments, $0.input) })
         let openingCalls = await fixture.calls
         try check(openingCalls.isEmpty && store.catalog == nil, "Constructing learning state performs no implicit I/O")
-        await store.loadPage()?.value
+        await openGroups(store)
         let initialCalls = await fixture.calls
-        try check(initialCalls.first?.arguments == ["profiles", "list", "--offset", "0", "--limit", "50", "--query", "", "--json"] &&
+        try check(initialCalls.first?.arguments == ["profiles", "list", "--hierarchy", "--limit", "100", "--json"] &&
+            initialCalls.contains { $0.arguments == ["profiles", "list", "--offset", "0", "--limit", "50", "--query", "", "--project", "Sample app", "--json"] } &&
             initialCalls.allSatisfy { $0.input == nil }, "Catalog reads use bounded pagination without mutation input")
         try check(store.detail?.profile.id == sourceID && store.detail?.profile.successCount == 18 &&
             store.detail?.profile.incompleteCount == 2 && store.hasNext && !store.hasPrevious,
@@ -283,6 +326,141 @@ struct LearningTests {
         bulk.clearMergeSelection()
         try check(bulk.mergeSelection.isEmpty, "Clearing the bulk selection removes stale items")
 
+        let browseFixture = LearningFixture()
+        let browse = LearningStore(command: { try await browseFixture.load($0.arguments, $0.input) })
+        await browse.loadPage()?.value
+        try check(browse.displayLevel == .projects && browse.selectedID == nil && browse.detail == nil && browse.catalog == nil &&
+            browse.hierarchy?.projects.map(\.project) == ["Sample app", "Other projects"],
+            "The project level lists the overview and selects nothing")
+        guard let appRow = browse.hierarchy?.projects.first, let otherRow = browse.hierarchy?.projects.last else {
+            throw ClientError.message("Missing hierarchy fixture")
+        }
+        let callsBeforeDrill = await browseFixture.calls.count
+        browse.openProject(appRow)
+        let callsAfterDrill = await browseFixture.calls.count
+        try check(browse.displayLevel == .operations && browse.projectRow?.operations.count == 2 && browse.selectedID == nil &&
+            callsAfterDrill == callsBeforeDrill && browse.breadcrumb == "All projects / Sample app",
+            "Opening a project reads nothing, selects nothing and lists its operations")
+        try check(learningOperationName("race-tests") == "Race tests" && learningOperationName("other") == "Other" &&
+            learningCounts(workloads: 1, groups: 2, runs: 0) == "1 workload · 2 groups · 0 runs",
+            "Operations and counts have readable names")
+        await browse.openOperation("tests")?.value
+        let tests = ["profiles", "list", "--offset", "0", "--limit", "50", "--query", "", "--project", "Sample app", "--operation", "tests", "--json"]
+        try check(await browseFixture.lastList() == tests && browse.displayLevel == .groups && browse.selectedID == sourceID &&
+            browse.breadcrumb == "All projects / Sample app / Tests",
+            "Choosing an operation filters the group list and selects its first group")
+        browse.query = "web"
+        await browse.loadPage()?.value
+        try check(await browseFixture.lastList() == ["profiles", "list", "--offset", "0", "--limit", "50", "--query", "web", "--json"] &&
+            browse.searchActive && browse.displayLevel == .groups && browse.level == .groups && browse.selectedID == sourceID,
+            "Search ignores the drill-down and lists every project")
+        browse.query = ""
+        await browse.loadPage()?.value
+        try check(await browseFixture.lastList() == tests && !browse.searchActive && browse.breadcrumb == "All projects / Sample app / Tests",
+            "Clearing the search restores the group list of the level the operator was on")
+        await browse.goBack()?.value
+        try check(await browseFixture.lastList() == ["profiles", "list", "--hierarchy", "--limit", "100", "--json"] &&
+            browse.displayLevel == .operations && browse.selectedID == nil && browse.detail == nil && browse.operation == nil,
+            "Going back reloads the overview so counts are current and clears the group")
+        browse.query = "web"
+        await browse.loadPage()?.value
+        browse.query = ""
+        await browse.loadPage()?.value
+        try check(browse.displayLevel == .operations && browse.selectedID == nil && browse.detail == nil,
+            "Clearing a search made at the operations level neither selects a group nor leaves the level")
+        browse.openProject(otherRow)
+        await browse.openOperation("other")?.value
+        try check(await browseFixture.lastList() == ["profiles", "list", "--offset", "0", "--limit", "50", "--query", "", "--operation", "other", "--other-projects", "--json"],
+            "The folded row filters with other-projects instead of a project name")
+        await browseFixture.emptyHierarchy(true)
+        await browse.show(.operations)?.value
+        let foldedList = await browseFixture.lastList()
+        try check(browse.projectRow == nil && browse.displayLevel == .groups && browse.operation == nil && browse.selectedID == sourceID &&
+            foldedList == ["profiles", "list", "--offset", "0", "--limit", "50", "--query", "", "--other-projects", "--json"],
+            "A project folded out of the overview still lists its groups instead of claiming it is empty")
+        // A folded project has no operations level, so the user-facing Back and the project crumb leave for the projects.
+        await browse.goBack()?.value
+        try check(browse.displayLevel == .projects && browse.scope == nil && browse.selectedID == nil,
+            "Back from the groups of a folded project goes to the projects instead of looping back to the same groups")
+        browse.openProject(otherRow)
+        await browse.show(.operations)?.value
+        await browseFixture.setCatalogTotal(0)
+        await browse.show(.operations)?.value
+        try check(browse.catalog?.profiles.isEmpty == true && browse.error == nil && browse.displayLevel == .groups,
+            "The empty state appears only when the project filter really returns nothing")
+        await browse.reloadPage(offset: 50)?.value
+        try check(!browse.hasPrevious && !browse.hasNext && browse.pageLabel == "No learned workloads",
+            "An empty list offers neither Previous nor a range")
+        await browse.show(.projects)?.value
+        try check(browse.displayLevel == .projects && browse.scope == nil && browse.hierarchy?.projects.isEmpty == true,
+            "The empty state offers a way back to all projects")
+        await browseFixture.emptyHierarchy(false)
+        await browseFixture.setCatalogTotal(104)
+
+        // Refresh and paging answer the submitted search, not text typed in the box.
+        browse.openProject(appRow)
+        await browse.openOperation(nil)?.value
+        browse.query = "go"
+        await browse.loadPage()?.value
+        await browse.reloadPage(offset: 50)?.value
+        browse.query = ""
+        await browse.reloadPage(preserveSelection: true)?.value
+        try check(await browseFixture.lastList() == ["profiles", "list", "--offset", "50", "--limit", "50", "--query", "go", "--json"] &&
+            browse.searchActive && browse.submittedQuery == "go",
+            "Refresh keeps the submitted search and its page instead of mixing in the unsubmitted box")
+        browse.query = "go"
+        await browse.loadPage()?.value
+        browse.query = ""
+        await browse.loadPage()?.value
+        try check(await browseFixture.lastList() == ["profiles", "list", "--offset", "0", "--limit", "50", "--query", "", "--project", "Sample app", "--json"] &&
+            !browse.searchActive && browse.catalog?.offset == 0,
+            "Submitting a different search resets the page offset")
+
+        // An unsubmitted search must not leak into a drill-down or a reload.
+        browse.query = "lint"
+        await browse.openOperation("tests")?.value
+        try check(await browseFixture.lastList() == tests && !browse.searchActive && browse.displayLevel == .groups,
+            "Choosing an operation ignores text typed in the search box and not yet submitted")
+        await browse.show(.operations)?.value
+        browse.query = "lint"
+        await browse.reloadSelection()?.value
+        try check(!browse.searchActive && browse.submittedQuery.isEmpty && browse.displayLevel == .operations,
+            "Reloading with nothing selected ignores text typed in the search box and not yet submitted")
+
+        // A merge made at the operations level can fold the project away; its groups must still list.
+        await browse.select(sourceID)?.value
+        await browseFixture.emptyHierarchy(true)
+        await browse.merge(into: targetID)?.value
+        try check(browse.projectRow == nil && browse.displayLevel == .groups && browse.error == nil && browse.catalog?.profiles.isEmpty == false,
+            "A merge that folds the project at the operations level lists its groups instead of a false empty state")
+        await browseFixture.emptyHierarchy(false)
+
+        // After a merge a page past the end shows the last page.
+        await browseFixture.setCatalogTotal(101)
+        await browse.reloadPage(offset: 100)?.value
+        await browseFixture.setCatalogTotal(100)
+        await browse.reloadPage(offset: 100)?.value
+        try check(browse.catalog?.offset == 50 && browse.catalog?.total == 100 && browse.pageLabel == "51–52 of 100",
+            "A page past the end after a merge loads the last page that exists")
+        await browseFixture.setCatalogTotal(104)
+
+        // Leaving a search whose overview read fails shows the error at the level being restored.
+        await browse.show(.operations)?.value
+        browse.query = "go"
+        await browse.loadPage()?.value
+        await browseFixture.failHierarchy(true)
+        browse.query = ""
+        await browse.loadPage()?.value
+        try check(!browse.searchActive && browse.displayLevel == .operations && browse.error?.contains("Overview unavailable") == true,
+            "A failed overview read after clearing a search leaves the results and shows the error")
+        await browseFixture.failHierarchy(false)
+        try check(learningProjectsNote(total: 5, shown: 4, limit: 100)?.contains("1 project is not shown") == true &&
+            learningProjectsNote(total: 150, shown: 100, limit: 100)?.contains("50 projects are not shown") == true &&
+            learningProjectsNote(total: 100, shown: 100, limit: 100)?.contains("Smaller projects are not shown") == true &&
+            learningProjectsNote(total: 3, shown: 3, limit: 100) == nil &&
+            learningProjectsNote(total: 150, shown: 100, limit: 100)?.contains("Search a project or group name") == true,
+            "The truncation note counts what is hidden and says searching finds it")
+
         store.query = "tests"
         await store.loadPage(offset: 50)?.value
         let pagedCalls = await fixture.calls
diff --git a/native/tests/TransportTests.swift b/native/tests/TransportTests.swift
index 7d1b111..b157474 100644
--- a/native/tests/TransportTests.swift
+++ b/native/tests/TransportTests.swift
@@ -305,6 +305,11 @@ struct TransportTests {
             ("agents remove", .agents("remove", runner: "claude"), queue(["agents", "remove", "--runner", "claude", "--json"])),
             ("profiles list", .profilesList(offset: 50, limit: 50, query: "go"),
              profiles(["list", "--offset", "50", "--limit", "50", "--query", "go"], cancellation: .discard)),
+            ("profiles list project", .profilesList(offset: 0, limit: 50, query: "", project: "Sample app", operation: "race-tests"),
+             profiles(["list", "--offset", "0", "--limit", "50", "--query", "", "--project", "Sample app", "--operation", "race-tests"], cancellation: .discard)),
+            ("profiles list other projects", .profilesList(offset: 0, limit: 50, query: "", otherProjects: true),
+             profiles(["list", "--offset", "0", "--limit", "50", "--query", "", "--other-projects"], cancellation: .discard)),
+            ("profiles hierarchy", .profilesHierarchy(limit: 100), profiles(["list", "--hierarchy", "--limit", "100"], cancellation: .discard)),
             ("profiles show", .profilesShow("id"), profiles(["show", "id"], cancellation: .discard)),
             ("profiles show family", .profilesShow("id", family: "f"), profiles(["show", "id", "--family", "f"], cancellation: .discard)),
             ("profiles show members", .profilesShow("id", membersQuery: "q", membersOffset: 2),
diff --git a/native/tests/hooks/AppTestHooks.swift b/native/tests/hooks/AppTestHooks.swift
index 84b0cb2..a737122 100644
--- a/native/tests/hooks/AppTestHooks.swift
+++ b/native/tests/hooks/AppTestHooks.swift
@@ -75,6 +75,7 @@ extension AppDelegate {
                 window.center()
             }
             if arguments.contains("--snapshot-learning") {
+                if arguments.contains("--snapshot-learning-projects") { AppRuntime.learningStore.showFixtureLevel(.projects) }
                 window.contentView = NSHostingView(rootView: LearningWindow(store: AppRuntime.learningStore) {})
                 window.minSize = NSSize(width: 800, height: 550)
                 window.setContentSize(NSSize(width: 1000, height: 780))
diff --git a/remote/static/app.css b/remote/static/app.css
index b9c6614..5b3affb 100644
--- a/remote/static/app.css
+++ b/remote/static/app.css
@@ -6,7 +6,7 @@
 
 .settings #scheduling-form{border-top:1px solid var(--line);padding-top:14px;margin-top:14px;align-items:flex-end}.settings fieldset{border:0;margin:0;padding:0;min-width:0}.settings legend{font-weight:650}.settings .mode-options{display:flex;flex-wrap:wrap;gap:4px 12px}.settings .mode-options label{display:flex;align-items:center;gap:5px;font-size:13px;min-height:48px;cursor:pointer}.mode-options input{accent-color:var(--blue);width:16px;height:16px}.mode-options input:focus-visible{outline:3px solid var(--blue);outline-offset:3px}.settings #scheduling-hint,.settings #scheduler-status,.settings #host-status{max-width:none}.settings #scheduler-status{margin-top:14px}.settings #apply-scheduling{flex-shrink:0}@media(max-width:500px){.settings #scheduling-form{flex-wrap:wrap}.settings #apply-scheduling{margin-left:auto}.settings #scheduling-hint{font-size:12px}}
 
-.view-navigation{display:flex;gap:8px;margin:0 0 22px;flex-wrap:wrap}.view-navigation button{min-height:48px}.view-navigation [aria-current=page]{background:var(--blue-tint);color:var(--blue)}.learning-copy{font-size:13px;color:var(--secondary);margin:7px 0 16px;overflow-wrap:anywhere}.profile-search{margin:18px 0}.profile-search label,.profile-field label,.profile-panel>label{display:block;font-size:14px;font-weight:650;margin-bottom:6px}.profile-search .button-row{flex-wrap:nowrap}.profile-search input{flex:1;min-width:0}.profile-panel{background:var(--surface);border:1px solid var(--line);border-radius:13px;padding:18px;margin-block:16px;min-width:0}.profile-field{margin-block:18px}.profile-field input,.profile-search input,.profile-panel select{font:inherit;font-size:16px;background:var(--surface);color:var(--text);border:1px solid var(--secondary);border-radius:9px;padding:11px 12px;min-height:48px;width:100%;min-width:0}.profile-field input:focus-visible,.profile-search input:focus-visible,.profile-panel select:focus-visible{outline:3px solid var(--blue);outline-offset:3px}.profile-panel button{min-height:48px}.profile-panel select{margin-bottom:12px;text-overflow:ellipsis}.profile-metadata{grid-template-columns:110px minmax(0,1fr)}.profile-link{font-size:16px;text-align:start;overflow-wrap:anywhere;background:none;padding:3px 0;min-height:48px;max-width:100%}.profile-member{padding-block:12px;border-top:1px solid var(--line)}.profile-member:first-of-type{margin-top:12px}.profile-member button{margin-top:8px}.profile-panel summary{cursor:pointer;min-height:48px;font-weight:650}.measurement{border-top:1px solid var(--line);padding-block:12px;overflow-wrap:anywhere}.measurement:first-child{margin-top:8px}.pagination{margin-top:18px}.profile-panel h3,#profile-name{overflow-wrap:anywhere}#profiles-view>.section-title{align-items:flex-start;flex-wrap:wrap}#profiles-view>.section-title>div{min-width:0;flex:1 1 230px}#profiles-refresh{min-height:48px;flex-shrink:0}#detail-profile{min-height:48px;max-width:100%;overflow-wrap:anywhere}@media(max-width:500px){.profile-panel{padding:14px}.profile-metadata{grid-template-columns:90px minmax(0,1fr);gap:9px}.profile-search .button-row{flex-wrap:wrap}.profile-search .button-row input{flex-basis:100%}.profile-search button{min-height:48px}.profile-panel .learning-copy{font-size:13px}}
+.view-navigation{display:flex;gap:8px;margin:0 0 22px;flex-wrap:wrap}.view-navigation button{min-height:48px}.view-navigation [aria-current=page]{background:var(--blue-tint);color:var(--blue)}.learning-copy{font-size:13px;color:var(--secondary);margin:7px 0 16px;overflow-wrap:anywhere}.profile-search{margin:18px 0}.profile-search label,.profile-field label,.profile-panel>label{display:block;font-size:14px;font-weight:650;margin-bottom:6px}.profile-search .button-row{flex-wrap:nowrap}.profile-search input{flex:1;min-width:0}.profile-panel{background:var(--surface);border:1px solid var(--line);border-radius:13px;padding:18px;margin-block:16px;min-width:0}.profile-field{margin-block:18px}.profile-field input,.profile-search input,.profile-panel select{font:inherit;font-size:16px;background:var(--surface);color:var(--text);border:1px solid var(--secondary);border-radius:9px;padding:11px 12px;min-height:48px;width:100%;min-width:0}.profile-field input:focus-visible,.profile-search input:focus-visible,.profile-panel select:focus-visible{outline:3px solid var(--blue);outline-offset:3px}.profile-panel button{min-height:48px}.profile-panel select{margin-bottom:12px;text-overflow:ellipsis}.profile-metadata{grid-template-columns:110px minmax(0,1fr)}.profile-link{font-size:16px;text-align:start;overflow-wrap:anywhere;background:none;padding:3px 0;min-height:48px;max-width:100%}.profile-member{padding-block:12px;border-top:1px solid var(--line)}.profile-member:first-of-type{margin-top:12px}.profile-member button{margin-top:8px}.profile-panel summary{cursor:pointer;min-height:48px;font-weight:650}.measurement{border-top:1px solid var(--line);padding-block:12px;overflow-wrap:anywhere}.measurement:first-child{margin-top:8px}.pagination{margin-top:18px}.profile-breadcrumb{display:flex;flex-wrap:wrap;align-items:center;gap:0 4px;margin-top:14px}.profile-breadcrumb [aria-current=page]{font-weight:650;overflow-wrap:anywhere}.profile-breadcrumb .quiet-button{min-height:48px;max-width:100%;text-align:start;overflow-wrap:anywhere}#profiles-level{margin-top:10px;overflow-wrap:anywhere}.profile-panel h3,#profile-name{overflow-wrap:anywhere}#profiles-view>.section-title{align-items:flex-start;flex-wrap:wrap}#profiles-view>.section-title>div{min-width:0;flex:1 1 230px}#profiles-refresh{min-height:48px;flex-shrink:0}#detail-profile{min-height:48px;max-width:100%;overflow-wrap:anywhere}@media(max-width:500px){.profile-panel{padding:14px}.profile-metadata{grid-template-columns:90px minmax(0,1fr);gap:9px}.profile-search .button-row{flex-wrap:wrap}.profile-search .button-row input{flex-basis:100%}.profile-search button{min-height:48px}.profile-panel .learning-copy{font-size:13px}}
 .profile-member>summary{padding-block:6px;overflow-wrap:anywhere}.profile-member-label{font-weight:650}.profile-member-summary{display:block;font-weight:400;margin-top:4px}.profile-member-evidence{padding-top:12px}.profile-member-evidence h4{margin:12px 0 8px;font-size:14px}.profile-member-evidence .profile-metadata{margin-block:12px}.profile-member-identity{overflow-wrap:anywhere}.profile-panel .pagination{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
 .profile-selection-control,.profile-panel>.profile-selection-control{display:flex;align-items:center;gap:10px;min-height:44px;font-size:13px;overflow-wrap:anywhere;cursor:pointer}.profile-selection-control input{width:18px;height:18px;flex:0 0 18px;accent-color:var(--blue)}.profile-selection-control input:focus-visible{outline:3px solid var(--blue);outline-offset:3px}#profile-combine ul{list-style:none;padding:0}#profile-combine li{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;border-bottom:1px solid var(--line);padding-block:8px}#profile-combine li span{overflow-wrap:anywhere;min-width:0}#profile-combine form button{margin-top:8px}#profile-combine .confirmation button{margin:8px 8px 0 0}
 .confirmation p{overflow-wrap:anywhere}
diff --git a/remote/static/index.html b/remote/static/index.html
index f24fd1c..8064c0e 100644
--- a/remote/static/index.html
+++ b/remote/static/index.html
@@ -30,8 +30,10 @@
         
         
+ +

    - +
    diff --git a/remote/static/profiles.js b/remote/static/profiles.js index 2b90323..eee1cb4 100644 --- a/remote/static/profiles.js +++ b/remote/static/profiles.js @@ -1,11 +1,14 @@ import { setupProfileSelection } from "./profile-selection.js"; -import { profileCatalogSchema, profileDetailSchema, csrfSchema } from "./schema.mjs"; +import { profileCatalogSchema, profileDetailSchema, profileHierarchySchema, csrfSchema } from "./schema.mjs"; const byId = (id) => document.getElementById(id); const gib = 1073741824; const bytes = (value) => value > 0 && value / gib < 0.01 ? `${value.toLocaleString()} bytes` : `${(value / gib).toLocaleString(undefined, { maximumFractionDigits: 2 })} GiB`; const number = (value) => value > 0 && value < 0.01 ? value.toExponential(2) : value.toLocaleString(undefined, { maximumFractionDigits: 2 }); const when = (value) => value === null ? "No runs yet" : new Date(value * 1000).toLocaleString(); +const plural = (count, word) => `${count.toLocaleString()} ${word}${count === 1 ? "" : "s"}`; +const operationName = (operation) => `${operation[0].toUpperCase()}${operation.slice(1).replaceAll("-", " ")}`; +const hierarchyCounts = (row) => `${plural(row.workloadCount, "workload")} · ${plural(row.groupCount, "group")} · ${plural(row.runCount, "run")}`; const message = (error) => error instanceof Error ? error.message : "The learning request could not be completed."; function node(tag, className = "", text) { const result = document.createElement(tag); @@ -38,7 +41,8 @@ function definitions(rows) { export function setupProfiles({ request, announce, show, queueBusy }) { const state = { detail: null, catalog: null, selected: null, query: "", offset: 0, busy: false, loading: false, dirty: false, confirmation: null, generation: 0, active: false, mergeGeneration: 0, mergeTargets: [], - memberGeneration: 0, memberController: null, memberQuery: "", memberOffset: 0, returnGroup: null }; + memberGeneration: 0, memberController: null, memberQuery: "", memberOffset: 0, returnGroup: null, + level: "projects", scope: null, operation: null }; const error = (text = "") => { byId("profiles-error").textContent = text; byId("profiles-error").hidden = !text; }; const selection = setupProfileSelection({ request, reportError: error, isBlocked: () => state.busy || state.loading || queueBusy(), isDirty: () => state.dirty, @@ -57,7 +61,7 @@ export function setupProfiles({ request, announce, show, queueBusy }) { } function controls() { byId("profiles-refresh").disabled = state.busy || state.loading; - byId("profiles-previous").disabled = state.loading || state.offset === 0; + byId("profiles-previous").disabled = state.loading || state.offset === 0 || state.catalog?.total === 0; byId("profiles-next").disabled = state.loading || !state.catalog || state.offset + state.catalog.profiles.length >= state.catalog.total; for (const control of byId("profile-detail").querySelectorAll("button, input, select")) control.disabled = state.busy || state.loading || control.dataset.unavailable === "true"; byId("profile-confirm").disabled = state.busy; @@ -137,7 +141,7 @@ export function setupProfiles({ request, announce, show, queueBusy }) { const search = node("form", "profile-search"); const query = field(search, "Find a member in this group", "profile-member-query", data.memberPage?.query ?? state.memberQuery, { type: "search", maxLength: 120, autoComplete: "off" }); const submit = node("button", "quiet-button", "Search members"); submit.type = "submit"; search.append(submit); - search.addEventListener("submit", (event) => { event.preventDefault(); void loadMembers(query.value.trim(), 0); }); + search.addEventListener("submit", (event) => { event.preventDefault(); void loadMembers(query.value.trim().normalize("NFC"), 0); }); section.append(search); for (const member of data.members) { const item = node("details", "profile-member"); item.name = "profile-members"; item.dataset.family = member.family; @@ -183,18 +187,99 @@ export function setupProfiles({ request, announce, show, queueBusy }) { } catch (failure) { if (generation === state.generation) error(message(failure)); } finally { if (generation === state.generation) { state.loading = false; controls(); } } } + // A search is global: it lists groups across every project and hides the drill-down until it is cleared. + const searching = () => state.query !== ""; + const currentLevel = () => searching() ? "groups" : state.level; + function renderLevel() { + const here = currentLevel(); + byId("profiles-level").textContent = searching() ? "Search results" : here === "projects" ? "Projects" : here === "operations" ? `Operations in ${state.scope.project}` : "Groups"; + byId("profiles-pager").hidden = here !== "groups"; + const trail = byId("profiles-breadcrumb"); + trail.hidden = searching() || here === "projects"; + if (trail.hidden) return; + const parts = [["All projects", "projects"], [state.scope.project, state.scope.folded ? "projects" : "operations"], [state.operation ? operationName(state.operation) : "All operations", "groups"]].slice(0, here === "groups" ? 3 : 2); + trail.replaceChildren(...parts.flatMap(([text, target], index) => { + const last = index === parts.length - 1; + const part = last ? node("span", "", text) : button(text, () => browse(target, state.scope)); + if (last) part.setAttribute("aria-current", "page"); + return index ? [node("span", "job-context", "/"), part] : [part]; + })); + } + function browse(level, scope, operation = null) { + if (state.busy) return; + Object.assign(state, { level, scope: level === "projects" ? null : scope, operation: level === "groups" ? operation : null, offset: 0, catalog: null }); + byId("profiles-count").textContent = ""; + byId("profiles-list").replaceChildren(node("li", "empty", "Loading…")); + void loadCatalog(); + byId("profiles-level").focus(); + announce(byId("profiles-level").textContent); + } + const emptyWithBack = (text) => { + const item = node("li", "empty", `${text} `); + item.append(button("Back to all projects", () => browse("projects"))); + return item; + }; + function projectRows(data) { + const shown = data.projects.filter((row) => !row.collapsed).length; + // The total counts only projects with three or more workloads; smaller ones fold into the last row, which truncation drops first. + byId("profiles-count").textContent = data.projects.length === 0 ? "" : data.total > shown + ? `Showing the ${shown} busiest of ${data.total} projects with three or more workloads. ${plural(data.total - shown, "project")} not shown, nor any smaller projects. Search a project or group name to find them.` + : shown >= data.limit ? `Showing the ${shown} busiest projects with three or more workloads. Smaller projects are not shown. Search a project or group name to find them.` + : "Choose a project to see what it ran."; + return data.projects.map((row) => { + const item = node("li", "job-card"); + item.append(button(row.project, () => browse("operations", { project: row.project, collapsed: row.collapsed }), "profile-link"), node("p", "job-context", hierarchyCounts(row))); + if (row.collapsed && row.projectCount) item.append(node("p", "job-context", `Covers ${plural(row.projectCount, "smaller project")}.`)); + return item; + }); + } + const scopeRow = (data) => data.projects.find((candidate) => candidate.collapsed === state.scope.collapsed && candidate.project === state.scope.project); + function operationRows(data, row) { + byId("profiles-count").textContent = "Choose an operation, or view every group in this project."; + return [["All operations", row, null], ...row.operations.map((operation) => [operationName(operation.operation), operation, operation.operation])].map(([label, counts, operation]) => { + const item = node("li", "job-card"); + item.append(button(label, () => browse("groups", state.scope, operation), "profile-link"), node("p", "job-context", hierarchyCounts(counts))); + return item; + }); + } async function loadCatalog() { selection.dismiss(); const generation = ++state.generation; state.loading = true; - error(); controls(); + error(); controls(); renderLevel(); try { - const query = new URLSearchParams({ offset: String(state.offset), limit: "100", query: state.query }); - const data = await request(`/api/profiles?${query}`, profileCatalogSchema); + if (currentLevel() !== "groups") { + const data = await request(`/api/profiles?${new URLSearchParams({ hierarchy: "true", limit: "100" })}`, profileHierarchySchema); + if (generation !== state.generation) return; + applied(data); + const row = state.level === "operations" ? scopeRow(data) : null; + if (state.level === "projects" || row) { + const items = row ? operationRows(data, row) : projectRows(data); + byId("profiles-list").replaceChildren(...(items.length ? items : [node("li", "empty", "Your Mac has not learned any workloads yet. Jobs build this history as they run; learning corrections will appear here.")])); + return; + } + // A merge can fold the project into the other-projects row: list its groups directly. It has no operations level, so its crumb leads back to the projects. + Object.assign(state, { level: "groups", operation: null, offset: 0, scope: { ...state.scope, folded: true } }); renderLevel(); + } + const groupQuery = () => { + const query = new URLSearchParams({ offset: String(state.offset), limit: "100", query: state.query }); + if (!searching()) { + if (state.scope?.collapsed) query.set("otherProjects", "true"); else if (state.scope) query.set("project", state.scope.project); + if (state.operation) query.set("operation", state.operation); + } + return query; + }; + let data = await request(`/api/profiles?${groupQuery()}`, profileCatalogSchema); if (generation !== state.generation) return; + if (data.total > 0 && data.offset >= data.total) { + // A merge shrank the list below this page: show the last page that exists. + state.offset = Math.floor((data.total - 1) / 100) * 100; + data = await request(`/api/profiles?${groupQuery()}`, profileCatalogSchema); + if (generation !== state.generation) return; + } state.catalog = data; applied(data); - byId("profiles-count").textContent = data.total ? `${data.offset + 1}–${data.offset + data.profiles.length} of ${data.total} workloads` : "No matching workloads."; + byId("profiles-count").textContent = data.profiles.length ? `${data.offset + 1}–${data.offset + data.profiles.length} of ${data.total} ${data.total === 1 ? "group" : "groups"}` : "No matching groups."; const items = data.profiles.map((profile) => { const item = node("li", "job-card"); const link = button(profile.label, () => void open(profile.id), "profile-link"); @@ -203,8 +288,15 @@ export function setupProfiles({ request, announce, show, queueBusy }) { node("p", "job-context", `Last run: ${when(profile.lastRunAt)}`)); return item; }); - byId("profiles-list").replaceChildren(...(items.length ? items : [node("li", "empty", state.query ? "Try another name or clear your search." : "Your Mac has not learned any workloads yet. Jobs build this history as they run; learning corrections will appear here.")])); - } catch (failure) { if (generation === state.generation) error(`${message(failure)} Use Refresh learning to retry.`); } + const none = searching() ? node("li", "empty", "Try another name or clear your search.") : state.scope ? emptyWithBack("Nothing is left here.") : node("li", "empty", "Your Mac has not learned any workloads yet. Jobs build this history as they run; learning corrections will appear here."); + byId("profiles-list").replaceChildren(...(items.length ? items : [none])); + } catch (failure) { + if (generation === state.generation) { + // Leave nothing from the failed view behind: no placeholder, no stale count, no Next into another scope. + state.catalog = null; byId("profiles-count").textContent = ""; byId("profiles-list").replaceChildren(); + error(`${message(failure)} Use Refresh learning to retry.`); + } + } finally { if (generation === state.generation) { state.loading = false; controls(); } } } async function mergeTargets(query = "") { @@ -259,7 +351,7 @@ export function setupProfiles({ request, announce, show, queueBusy }) { const targetForm = node("form", "profile-search"); targetForm.action = "/api/profiles"; targetForm.method = "get"; const targetQuery = field(targetForm, "Find a workload to share with", "profile-target-query", "", { type: "search", maxLength: 120, autoComplete: "off" }); const find = node("button", "quiet-button", "Find workloads"); find.type = "submit"; targetForm.append(find); - targetForm.addEventListener("submit", (event) => { event.preventDefault(); void mergeTargets(targetQuery.value.trim()); }); + targetForm.addEventListener("submit", (event) => { event.preventDefault(); void mergeTargets(targetQuery.value.trim().normalize("NFC")); }); const targetLabel = node("label", "", "Target workload"); targetLabel.htmlFor = "profile-merge-target"; const target = node("select"); target.id = "profile-merge-target"; target.name = "targetId"; merging.append(targetForm, targetLabel, target, button("Share learning…", () => { @@ -335,7 +427,16 @@ export function setupProfiles({ request, announce, show, queueBusy }) { if (id === null) { document.title = "Learned workloads | Agent Queue"; byId("profiles-title").focus(); await loadCatalog(); } else { document.title = "Workload learning | Agent Queue"; await loadDetail(id); } } - byId("profiles-search-form").addEventListener("submit", (event) => { event.preventDefault(); if (state.loading || state.busy) return; state.query = byId("profiles-query").value.trim(); state.offset = 0; void loadCatalog(); }); + byId("profiles-search-form").addEventListener("submit", (event) => { + event.preventDefault(); + if (state.loading || state.busy) return; + state.query = byId("profiles-query").value.trim().normalize("NFC"); state.offset = 0; state.catalog = null; + // Rows from the previous level must not stay clickable while the new level loads. + byId("profiles-count").textContent = ""; byId("profiles-list").replaceChildren(node("li", "empty", "Loading…")); + void loadCatalog(); + byId("profiles-level").focus(); + announce(byId("profiles-level").textContent); + }); byId("profiles-previous").addEventListener("click", () => { state.offset = Math.max(0, state.offset - 100); void loadCatalog(); }); byId("profiles-next").addEventListener("click", () => { state.offset += 100; void loadCatalog(); }); byId("profiles-refresh").addEventListener("click", () => { diff --git a/remote/tests/browser/learning.spec.mjs b/remote/tests/browser/learning.spec.mjs index bc3e512..f8aaa16 100644 --- a/remote/tests/browser/learning.spec.mjs +++ b/remote/tests/browser/learning.spec.mjs @@ -5,10 +5,17 @@ import { profileDetailSchema } from "../../static/schema.mjs"; const profileId = "a".repeat(64); const targetId = "b".repeat(64); const editSchema = z.object({ revision: z.number().int(), label: z.string(), memoryFloorBytes: z.number().int().nullable(), cpuFloorCores: z.number().nullable() }); -async function openLearning(page) { +// The catalog opens on projects; the group list is two drill-downs away. +async function openGroups(page, project = "Sample app", operation = "All operations") { await page.goto("/"); await expect(page.locator("#connection-text")).toContainText("Connected"); await page.getByRole("button", { name: "Learned workloads", exact: true }).click(); + await page.getByRole("button", { name: project, exact: true }).click(); + await page.getByRole("button", { name: operation, exact: true }).click(); + await expect(page.locator("#profiles-level")).toHaveText("Groups"); +} +async function openLearning(page) { + await openGroups(page); await expect(page.locator("#profiles-list")).toContainText("Browser tests"); } async function openProfile(page) { @@ -51,7 +58,7 @@ test("queue polls preserve learning drafts and stale revisions require explicit let historyRequests = 0; page.on("request", (request) => { if (request.url().includes("/api/profiles")) historyRequests += 1; }); // Finish the optional merge-target lookup before measuring periodic traffic. - await expect(page.locator("#profile-merge-target option")).toHaveCount(2); + await expect(page.locator("#profile-merge-target option")).toHaveCount(14); historyRequests = 0; await page.getByLabel("Workload name", { exact: true }).fill("My unsaved correction"); await page.getByLabel("Workload name", { exact: true }).focus(); @@ -126,15 +133,14 @@ test("job details link the matching profile and unavailable or malformed learnin test("learning catalog searches and pages through bounded responses", async ({ page, queueFixture }) => { await queueFixture.update({ profileCount: 101 }); - await page.goto("/"); - await page.getByRole("button", { name: "Learned workloads", exact: true }).click(); - await expect(page.locator("#profiles-count")).toHaveText("1–100 of 101 workloads"); + await openGroups(page); + await expect(page.locator("#profiles-count")).toHaveText("1–100 of 101 groups"); await page.getByRole("button", { name: "Next", exact: true }).click(); - await expect(page.locator("#profiles-count")).toHaveText("101–101 of 101 workloads"); + await expect(page.locator("#profiles-count")).toHaveText("101–101 of 101 groups"); await expect(page.getByRole("button", { name: "Next", exact: true })).toBeDisabled(); await page.getByLabel("Find a workload", { exact: true }).fill("Workload 100"); await page.getByRole("button", { name: "Search", exact: true }).click(); - await expect(page.locator("#profiles-count")).toHaveText("1–1 of 1 workloads"); + await expect(page.locator("#profiles-count")).toHaveText("1–1 of 1 group"); await expect(page.locator("#profiles-list .job-card")).toHaveCount(1); }); @@ -281,8 +287,7 @@ test("a late member response cannot overwrite another member's inspection", asyn const combineSchema = z.object({ revision: z.number().int(), profileIds: z.array(z.string().regex(/^[a-f0-9]{64}$/)), families: z.array(z.string().regex(/^[a-f0-9]{64}$/)) }); const fixtureGroup = (number) => number.toString(16).padStart(64, "0"); async function openGeneratedLearning(page) { - await page.goto("/"); - await page.getByRole("button", {name:"Learned workloads",exact:true}).click(); + await openGroups(page); await expect(page.getByRole("button", {name:"Workload 0",exact:true})).toBeVisible(); } @@ -369,8 +374,7 @@ test("long selected group names wrap in the merge review on a narrow phone", asy const label="x".repeat(120); await queueFixture.update({profileFloors:{label}}); await page.setViewportSize({width:375,height:844}); - await page.goto("/"); - await page.getByRole("button",{name:"Learned workloads",exact:true}).click(); + await openGroups(page); await page.getByRole("checkbox",{name:`Select group: ${label}`,exact:true}).check(); await page.getByLabel("Destination group",{exact:true}).selectOption(targetId); await page.getByRole("button",{name:"Merge selected…",exact:true}).click(); @@ -378,3 +382,188 @@ test("long selected group names wrap in the merge review on a narrow phone", asy expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); expect(await queueFixture.events()).toEqual([]); }); + +const profileRequests = (page) => { + const urls = []; + page.on("request", (request) => { if (request.url().includes("/api/profiles?")) urls.push(new URL(request.url()).searchParams); }); + return urls; +}; + +test("learning drills from projects through operations to groups with a working breadcrumb", async ({ page, queueFixture }) => { + const requests = profileRequests(page); + await page.goto("/"); + await page.getByRole("button", { name: "Learned workloads", exact: true }).click(); + await expect(page.locator("#profiles-level")).toHaveText("Projects"); + const rows = page.locator("#profiles-list .job-card"); + await expect(rows).toHaveText([/Sample app.*11 workloads · 6 groups · 36 runs/, /Sample service.*10 workloads · 6 groups · 36 runs/, /Other projects.*2 workloads · 2 groups · 12 runs.*Covers 2 smaller projects/]); + await expect(page.locator("#profiles-breadcrumb")).toBeHidden(); + await expect(page.locator("#profiles-pager")).toBeHidden(); + expect(requests.map((query) => query.get("hierarchy"))).toEqual(["true"]); + + await page.getByRole("button", { name: "Sample service", exact: true }).click(); + await expect(page.locator("#profiles-level")).toHaveText("Operations in Sample service"); + await expect(page.locator("#profiles-level")).toBeFocused(); + await expect(page.locator("#announcement")).toHaveText("Operations in Sample service"); + await expect(rows.first()).toContainText("All operations10 workloads · 6 groups · 36 runs"); + await expect(page.getByRole("button", { name: "Full tests", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Other", exact: true })).toBeVisible(); + await expect(page.locator("#profiles-breadcrumb")).toHaveAttribute("aria-label", "Learned workload location"); + await expect(page.locator("#profiles-breadcrumb [aria-current=page]")).toHaveText("Sample service"); + + await page.getByRole("button", { name: "Tests", exact: true }).click(); + await expect(page.locator("#profiles-level")).toHaveText("Groups"); + await expect(page.locator("#profiles-breadcrumb")).toHaveText("All projects/Sample service/Tests"); + await expect(page.locator("#profiles-list .job-card")).toHaveText([/API tests/]); + await expect(page.locator("#profiles-pager")).toBeVisible(); + expect(Object.fromEntries(requests.at(-1))).toEqual({ offset: "0", limit: "100", query: "", project: "Sample service", operation: "tests" }); + + await page.locator("#profiles-breadcrumb").getByRole("button", { name: "Sample service", exact: true }).click(); + await expect(page.locator("#profiles-level")).toHaveText("Operations in Sample service"); + await page.locator("#profiles-breadcrumb").getByRole("button", { name: "All projects", exact: true }).click(); + await expect(page.locator("#profiles-level")).toHaveText("Projects"); + await expect(rows).toHaveCount(3); + + await page.getByRole("button", { name: "Other projects", exact: true }).click(); + await page.getByRole("button", { name: "Build", exact: true }).click(); + await expect(page.locator("#profiles-list .job-card")).toHaveText([/Docs build/]); + expect(Object.fromEntries(requests.at(-1))).toEqual({ offset: "0", limit: "100", query: "", otherProjects: "true", operation: "build" }); + for (const width of [390, 1280]) { + await page.setViewportSize({ width, height: 844 }); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); + } + expect(await queueFixture.events()).toEqual([]); +}); + +test("search is global, clearing it returns to the level, and a group's detail returns to its page", async ({ page, queueFixture }) => { + await queueFixture.update({ profileCount: 101 }); + const requests = profileRequests(page); + await openGroups(page); + await page.getByRole("button", { name: "Next", exact: true }).click(); + await expect(page.locator("#profiles-count")).toHaveText("101–101 of 101 groups"); + await page.getByRole("button", { name: "Workload 100", exact: true }).click(); + await expect(page.locator("#profile-name")).toHaveText("Workload 100"); + await page.getByRole("button", { name: "‹ All learned workloads", exact: true }).click(); + await expect(page.locator("#profiles-count")).toHaveText("101–101 of 101 groups"); + expect(requests.at(-1).get("project")).toBe("Sample app"); + expect(requests.at(-1).get("offset")).toBe("100"); + + await page.getByLabel("Find a workload", { exact: true }).fill("Workload 7"); + await page.getByRole("button", { name: "Search", exact: true }).click(); + await expect(page.locator("#profiles-level")).toHaveText("Search results"); + await expect(page.locator("#profiles-breadcrumb")).toBeHidden(); + expect(Object.fromEntries(requests.at(-1))).toEqual({ offset: "0", limit: "100", query: "Workload 7" }); + await page.getByLabel("Find a workload", { exact: true }).fill(""); + await page.getByRole("button", { name: "Search", exact: true }).click(); + await expect(page.locator("#profiles-level")).toHaveText("Groups"); + await expect(page.locator("#profiles-breadcrumb")).toHaveText("All projects/Sample app/All operations"); + await expect(page.locator("#profiles-count")).toHaveText("1–100 of 101 groups"); +}); + +test("a slow response from an earlier level cannot replace a later level", async ({ page, queueFixture }) => { + await page.goto("/"); + await page.getByRole("button", { name: "Learned workloads", exact: true }).click(); + await expect(page.getByRole("button", { name: "Sample app", exact: true })).toBeVisible(); + let release = () => {}; + const held = new Promise((resolve) => { release = resolve; }); + const empty = { revision: 4, total: 0, limit: 100, projects: [], controllerAppliedRevision: 3, controllerStatus: "Late" }; + await page.route("**/api/profiles?hierarchy=*", async (route) => { await held; await route.fulfill({ contentType: "application/json", body: JSON.stringify(empty) }); }, { times: 1 }); + await page.getByRole("button", { name: "Refresh learning", exact: true }).click(); + await page.getByRole("button", { name: "Sample app", exact: true }).click(); + await expect(page.getByRole("button", { name: "All operations", exact: true })).toBeVisible(); + release(); + await expect(page.locator("#profiles-controller")).not.toContainText("Late"); + await page.getByRole("button", { name: "All operations", exact: true }).click(); + await expect(page.locator("#profiles-list")).toContainText("Browser tests"); + expect(await queueFixture.events()).toEqual([]); +}); + +test("a project or operation with nothing left shows an empty state with a way back", async ({ page, queueFixture }) => { + await page.goto("/"); + await page.getByRole("button", { name: "Learned workloads", exact: true }).click(); + await page.getByRole("button", { name: "Sample service", exact: true }).click(); + await page.getByRole("button", { name: "Tests", exact: true }).click(); + await expect(page.locator("#profiles-list")).toContainText("API tests"); + await queueFixture.update({ profiles: {} }); + await page.getByRole("button", { name: "Refresh learning", exact: true }).click(); + await expect(page.locator("#profiles-list")).toContainText("Nothing is left here."); + await expect(page.locator("#profiles-error")).toBeHidden(); + await page.getByRole("button", { name: "Back to all projects", exact: true }).click(); + await expect(page.locator("#profiles-level")).toHaveText("Projects"); + await expect(page.locator("#profiles-list")).toContainText("Your Mac has not learned any workloads yet"); +}); + +const summary = (id, label, project, memberCount) => ({ profile: { id: id.repeat(64), label, project, operation: "tests", memberCount, variantCount: 1, successCount: 1, incompleteCount: 0, peakMemoryBytes: 1, peakCpuCores: 1, lastRunAt: null, memoryFloorBytes: null, cpuFloorCores: null } }); +const noOverflow = (page) => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth); + +test("a very long project name wraps in the breadcrumb on a narrow phone", async ({ page, queueFixture }) => { + const project = "p".repeat(120); + await queueFixture.update({ profiles: { a: summary("a", "Long group", project, 3) } }); + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto("/"); + await page.getByRole("button", { name: "Learned workloads", exact: true }).click(); + await page.getByRole("button", { name: project, exact: true }).click(); + expect(await noOverflow(page)).toBe(true); + await page.getByRole("button", { name: "All operations", exact: true }).click(); + await expect(page.locator("#profiles-level")).toHaveText("Groups"); + await expect(page.locator("#profiles-breadcrumb")).toContainText(project); + expect(await noOverflow(page)).toBe(true); +}); + +test("a project folded away by a merge still lists its groups instead of a false empty state", async ({ page, queueFixture }) => { + const requests = profileRequests(page); + await queueFixture.update({ profiles: { a: summary("a", "Big app build", "Big app", 3) } }); + await page.goto("/"); + await page.getByRole("button", { name: "Learned workloads", exact: true }).click(); + await page.getByRole("button", { name: "Big app", exact: true }).click(); + await expect(page.locator("#profiles-level")).toHaveText("Operations in Big app"); + await queueFixture.update({ profiles: { a: summary("a", "Big app build", "Big app", 2) } }); + await page.getByRole("button", { name: "Refresh learning", exact: true }).click(); + await expect(page.locator("#profiles-list")).toContainText("Big app build"); + await expect(page.locator("#profiles-list")).not.toContainText("no longer"); + expect(requests.at(-1).get("project")).toBe("Big app"); + // The folded project has no operations level, so its crumb leaves for the projects instead of looping back here. + await page.locator("#profiles-breadcrumb").getByRole("button", { name: "Big app", exact: true }).click(); + await expect(page.locator("#profiles-level")).toHaveText("Projects"); + await expect(page.getByRole("button", { name: "Other projects", exact: true })).toBeVisible(); +}); + +test("a page past the end after a merge shows the last page, not an empty one", async ({ page, queueFixture }) => { + await queueFixture.update({ profileCount: 101 }); + await openGroups(page); + await page.getByRole("button", { name: "Next", exact: true }).click(); + await expect(page.locator("#profiles-count")).toHaveText("101–101 of 101 groups"); + await queueFixture.update({ profileCount: 100 }); + await page.getByRole("button", { name: "Refresh learning", exact: true }).click(); + await expect(page.locator("#profiles-count")).toHaveText("1–100 of 100 groups"); + await expect(page.getByRole("button", { name: "Next", exact: true })).toBeDisabled(); +}); + +test("a failed group load leaves no placeholder, stale count or Next button", async ({ page, queueFixture }) => { + await queueFixture.update({ profileCount: 150 }); + await openGroups(page); + await expect(page.locator("#profiles-count")).toHaveText("1–100 of 150 groups"); + await page.route("**/api/profiles?offset=*", (route) => route.fulfill({ status: 500, contentType: "application/json", body: JSON.stringify({ error: "Unavailable" }) }), { times: 1 }); + await page.getByRole("button", { name: "Next", exact: true }).click(); + await expect(page.locator("#profiles-error")).toBeVisible(); + await expect(page.locator("#profiles-list")).not.toContainText("Loading"); + await expect(page.locator("#profiles-count")).toHaveText(""); + await expect(page.getByRole("button", { name: "Next", exact: true })).toBeDisabled(); +}); + +test("a search replaces the previous level at once, moves focus and announces itself", async ({ page, queueFixture }) => { + await page.goto("/"); + await page.getByRole("button", { name: "Learned workloads", exact: true }).click(); + await expect(page.getByRole("button", { name: "Sample app", exact: true })).toBeVisible(); + let release = () => {}; + const held = new Promise((resolve) => { release = resolve; }); + await page.route("**/api/profiles?offset=*", async (route) => { await held; await route.continue(); }, { times: 1 }); + await page.getByLabel("Find a workload", { exact: true }).fill("tests"); + await page.getByRole("button", { name: "Search", exact: true }).click(); + await expect(page.locator("#profiles-level")).toHaveText("Search results"); + await expect(page.locator("#profiles-level")).toBeFocused(); + await expect(page.locator("#announcement")).toHaveText("Search results"); + await expect(page.getByRole("button", { name: "Sample app", exact: true })).toHaveCount(0); + release(); + await expect(page.locator("#profiles-list")).toContainText("API tests"); + expect(await queueFixture.events()).toEqual([]); +});