diff --git a/PERF.md b/PERF.md index 2e54b23da1..774d8071cc 100644 --- a/PERF.md +++ b/PERF.md @@ -6,6 +6,28 @@ two thirds off the embedded corpora. This file is what keeps it. Every win below is defended by something that goes red locally, in `go test` or in `make check`, with a message that says what happened. +## Activity/history reading bounds + +The task reading stores each merged row once, with identities mapped to slice +positions. Parent validity is memoized during each tree build, including cycles; +roots and sibling slices are sized before filling them. Filtering shares the +held task/chat slices until an actual deletion requires a copy. A body's paint +shares its filtered reading and layouts by width between the list, cursor, and +preview, then discards that scratch before the next input or engine update. +There is no persistent layout cache that can hide changed folds, filters, +deletions, live state, or terminal size. Layout capacity starts with visible +tasks rather than allocating for every descendant hidden behind a fold. + +`TestActivityParentIndexPreservesMalformedGraphPolicy`, the Activity snapshot +and paint-lifetime regressions, and the existing navigation/live-state suite +defend these rules. `BenchmarkTasksConversationHistory` measures rebuilding and +laying out 5,120 tasks; `BenchmarkActivityScaleFrame` measures painting that +history. `TestActivityScaleTerminalLatency` exercises the actual terminal with +40 project histories and 5,120 tasks, including pointer selection, filtering, +folding and resizing. Its timings include input delivery and observed screen +response, separate from fixture construction and first-page setup. These are +diagnostic measurements, not universal latency promises or new product limits. + ## Teams overview reading bounds Visible teams’ member previews and latest-message times are read once per conversation @@ -55,6 +77,26 @@ path/theme/geometry invalidation, viewport equivalence and keyboard reveal; Timing is diagnostic; regression gates assert reuse and rendered geometry, rather than a machine-dependent time limit. +All teams shares one indexed reporting-home reading per paint, including the +global manager card. Membership repair groups conversations once and retains +the existing home/manager selection policy, instead of repeatedly searching +every membership for every conversation. The paint reading is discarded before +edits or actions; it is never an authority cache across frames. + +Spend reads share one immutable membership snapshot for the most recently used +profile. Its primary/legacy file stamp is checked on every request, membership +changes invalidate it immediately, and concurrent reads share the same load. +Ledger appends still use the existing incremental ledger fold; there is no TTL, +new model call, or change to cap enforcement. The retained snapshot is bounded +to one profile, rather than an accumulating profile cache. + +`TestTeamsAllScaleTerminalLatency` measures input delivery through the real +terminal's changed hover hint with 100 teams and 10,000 memberships; the separate +large-capacity scenario uses 200 teams and 40,000 memberships. Fixture setup and +first overview frame are reported separately, and model calls must remain zero. +The snapshot, repair-policy equivalence, profile isolation and concurrent-read +tests defend the algorithms independently of diagnostic wall-clock timings. + ## Context recovery bounds Conversation request admission sums the existing encoded messages and tool schemas; diff --git a/docs/changes/unreleased/1919-teams-hover-scale.md b/docs/changes/unreleased/1919-teams-hover-scale.md new file mode 100644 index 0000000000..8404e2c15f --- /dev/null +++ b/docs/changes/unreleased/1919-teams-hover-scale.md @@ -0,0 +1,14 @@ +--- +kind: fixed +title: Keep large team and Activity overviews responsive +pr: 1919 +surface: [chat] +invalidates: + - "Hovering All teams previously repeated membership searches across every team for each crew member. Each paint now shares one indexed reporting-home reading, and summaries avoid building full crew rows." + - "Background spend reads previously reloaded and repaired unchanged team membership once per pool. They now share one immutable membership snapshot for the most recent profile, invalidated when its primary or legacy membership file changes." + - "Activity/history previously copied every retained task on each filtering read, rebuilt the same layout for multiple readers in one paint, and repeatedly walked each task's ancestry. Unchanged rows are now shared, each paint shares its layouts, and tree construction memoizes parent validity and sizes sibling storage before filling it." +--- + +Reporting-home policy, spend accounting, and team limits retain their existing +behavior. Real terminal tests cover 100 teams with 100 members each and 200 teams +with 200 members each; these fixtures introduce no new product limits. diff --git a/internal/e2e/activity_scale_e2e_test.go b/internal/e2e/activity_scale_e2e_test.go new file mode 100644 index 0000000000..5e8237ed7d --- /dev/null +++ b/internal/e2e/activity_scale_e2e_test.go @@ -0,0 +1,148 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/session" +) + +// Exercise the real history screen with many project buckets, so each bucket +// stays within the existing index read limit. No model work belongs in a hover. +func TestActivityScaleTerminalLatency(t *testing.T) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux required") + } + began := time.Now() + home := newHome(t, map[string]any{"model_pool": "off", "memory.enabled": false}) + ws := newWorkspace(t, "activity-scale", false) + now := time.Now().UTC() + for chat := 0; chat < 40; chat++ { + workspace := filepath.Join(ws, fmt.Sprintf("project-%03d", chat)) + if err := os.MkdirAll(workspace, 0o700); err != nil { + t.Fatal(err) + } + sid := fmt.Sprintf("%016x", 0x7100000000000000+chat) + bucket := filepath.Join(home, "v3", "projects", strings.ReplaceAll(workspace, string(filepath.Separator), "-")) + dir := filepath.Join(bucket, sid) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + title := fmt.Sprintf("Mock history chat %03d", chat) + if chat == 39 { + title = "Mock history zephyr" + } + writeJSON(t, filepath.Join(dir, "meta.json"), map[string]any{"id": sid, "title": title, "workspace": workspace, "created": now, "lastUserAt": now.Add(-time.Duration(chat) * time.Minute)}) + journal := fmt.Sprintf("{\"type\":\"session\",\"version\":1,\"id\":%q,\"cwd\":%q}\n", sid, workspace) + if err := os.WriteFile(filepath.Join(dir, "transcript.jsonl"), []byte(journal), 0o600); err != nil { + t.Fatal(err) + } + var rows strings.Builder + for task := 1; task <= 128; task++ { + parent := "" + if task > 1 { + parent = fmt.Sprint((task-2)/4 + 1) + } + entry := session.TaskIndexEntry{SessionID: sid, ID: fmt.Sprint(task), Parent: parent, + Title: fmt.Sprintf("Mock parser repair %03d %03d", chat, task), Status: string(session.TaskDone), EndedAt: now.Add(-time.Duration(task) * time.Minute)} + data, err := json.Marshal(entry) + if err != nil { + t.Fatal(err) + } + rows.Write(data) + rows.WriteByte('\n') + } + if err := os.WriteFile(filepath.Join(bucket, "tasks.jsonl"), []byte(rows.String()), 0o600); err != nil { + t.Fatal(err) + } + } + setup := time.Since(began) + var calls atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.HasSuffix(r.URL.Path, "/chat/completions") { + calls.Add(1) + http.Error(w, "navigation must not call a model", 500) + return + } + fmt.Fprint(w, `{"data":[]}`) + })) + defer server.Close() + r := startWithEnv(t, []string{config.APIKeyEnv + "=mock-scale-key", "CODEAF_BASE_URL=" + server.URL}, "activity_scale", home, ws, 100, 45, "chat", "--model", "deepseek/deepseek-v4.1-flash", "--one-model", "--no-host") + r.skipSetup(t) + send := func(text string) { + t.Helper() + if out, err := exec.Command("tmux", "send-keys", "-t", r.name, "-l", text).CombinedOutput(); err != nil { + t.Fatalf("input: %v %s", err, out) + } + } + r.lit("/history") + opened := time.Now() + if out, err := exec.Command("tmux", "send-keys", "-t", r.name, "Enter").CombinedOutput(); err != nil { + t.Fatalf("open history: %v %s", err, out) + } + r.waitFor(30*time.Second, "Mock History Chat 000", "Mock parser repair 000") + opening := time.Since(opened) + // Select different kinds of rows and observe their distinct door hints. + // Find each label again: the selected task can grow a reason line. + r.keys("Down") + r.waitFor(30*time.Second, "enter go inside it") + targets := []struct{ label, hint string }{ + {"Mock History Chat 000", "enter go to that conversation"}, + {"Mock parser repair 000 001", "enter go inside it"}, + } + var samples []float64 + for i := 0; i < 12; i++ { + target := targets[i%2] + y := -1 + for row, line := range strings.Split(r.capture(), "\n") { + if strings.Contains(line, target.label) { + y = row + break + } + } + if y < 0 { + t.Fatalf("no hover target %q:\n%s", target.label, r.capture()) + } + start := time.Now() + send(fmt.Sprintf("\x1b[<35;12;%dM", y+1)) + changed := false + for time.Since(start) < 30*time.Second { + if strings.Contains(r.capture(), target.hint) { + changed = true + break + } + time.Sleep(5 * time.Millisecond) + } + if !changed { + t.Fatalf("hover did not change door hint to %q:\n%s", target.hint, r.capture()) + } + samples = append(samples, float64(time.Since(start))/float64(time.Millisecond)) + } + // Filtering must retain the matching chat's full family; clearing restores it. + send("zephyr") + r.waitFor(30*time.Second, "Mock History Zephyr", "Mock parser repair 039") + r.keys("C-u") + r.waitFor(30*time.Second, "Mock History Chat 000", "Mock parser repair 000") + r.keys("Right", "Down", "Left") + r.resize(160, 55) + r.waitFor(30*time.Second, strings.Repeat("─", 160), "Mock History Chat") + if calls.Load() != 0 { + t.Fatalf("navigation made %d model calls", calls.Load()) + } + sort.Float64s(samples) + t.Logf("chats=40 tasks=5120 setup=%s first_screen=%s hover_ms p50=%.3f p95=%.3f samples=%v model_calls=0", setup, opening, samples[len(samples)/2], samples[len(samples)-1], samples) +} diff --git a/internal/e2e/teams_scale_e2e_test.go b/internal/e2e/teams_scale_e2e_test.go new file mode 100644 index 0000000000..28fe0abfcc --- /dev/null +++ b/internal/e2e/teams_scale_e2e_test.go @@ -0,0 +1,131 @@ +//go:build e2e + +package e2e + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +// Screen timings include input delivery and observed terminal output, but not +// fixture setup or provider latency. No chat request is needed to move a pointer. +func TestTeamsAllScaleTerminalLatency(t *testing.T) { + runTeamsScaleTerminal(t, 100, 100) +} + +func TestTeamsAllScaleTerminalLargeCapacity(t *testing.T) { + runTeamsScaleTerminal(t, 200, 200) +} + +func runTeamsScaleTerminal(t *testing.T, count, members int) { + if _, err := exec.LookPath("tmux"); err != nil { + t.Skip("tmux required") + } + began := time.Now() + home := newHome(t, map[string]any{"model_pool": "off", "memory.enabled": false}) + ws := newWorkspace(t, "teams-scale", false) + var teams []teamstore.Team + for i := 0; i < count; i++ { + tm := teamstore.Team{ID: fmt.Sprintf("%012x", i+1), Name: fmt.Sprintf("Scale team %03d", i)} + for j := 0; j < members; j++ { + sid := fmt.Sprintf("%016x", 0x6000000000000000+i*members+j) + dir := filepath.Join(home, "v3", "projects", strings.ReplaceAll(ws, string(filepath.Separator), "-"), sid) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + at := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + writeJSON(t, filepath.Join(dir, "meta.json"), map[string]any{"id": sid, "title": fmt.Sprintf("Mock member %03d %03d", i, j), "workspace": ws, "created": at, "lastUserAt": at}) + file := filepath.Join(dir, "transcript.jsonl") + journal := fmt.Sprintf("{\"type\":\"session\",\"version\":1,\"id\":%q,\"cwd\":%q}\n{\"type\":\"message\",\"role\":\"user\",\"content\":\"Review this mock change\",\"timestamp\":\"2026-01-01T00:00:00Z\"}\n{\"type\":\"message\",\"role\":\"assistant\",\"content\":\"Mock change reviewed\",\"timestamp\":\"2026-01-01T00:00:01Z\"}\n", sid, ws) + if err := os.WriteFile(file, []byte(journal), 0o600); err != nil { + t.Fatal(err) + } + tm.Members = append(tm.Members, teamstore.Member{Key: file, File: file, Where: ws, Handle: fmt.Sprintf("mock-%03d-%03d", i, j), Word: "Mock member", Home: j != 0, HandleBy: teamstore.HandleByTyped}) + if j == 0 { + tm.Manager = file + } + } + teams = append(teams, tm) + } + writeJSON(t, teamstore.Path(home), teamstore.File{Version: teamstore.Version, Teams: teams}) + seed := time.Since(began) + var chats atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.HasSuffix(r.URL.Path, "/chat/completions") { + chats.Add(1) + http.Error(w, "no model calls belong in this scenario", 500) + return + } + fmt.Fprint(w, `{"data":[]}`) + })) + defer server.Close() + r := startWithEnv(t, []string{config.APIKeyEnv + "=mock-scale-key", "CODEAF_BASE_URL=" + server.URL}, "teams_scale", home, ws, 160, 55, "chat", "--model", "deepseek/deepseek-v4.1-flash", "--one-model", "--no-host") + r.skipSetup(t) + send := func(text string) { + t.Helper() + if out, err := exec.Command("tmux", "send-keys", "-t", r.name, "-l", text).CombinedOutput(); err != nil { + t.Fatalf("input: %v %s", err, out) + } + } + wait := func(start time.Time, text string) (string, time.Duration) { + t.Helper() + for time.Since(start) < 30*time.Second { + screen := r.capture() + if strings.Contains(screen, text) { + return screen, time.Since(start) + } + time.Sleep(5 * time.Millisecond) + } + t.Fatalf("screen never displayed %q:\n%s", text, r.capture()) + return "", 0 + } + r.lit("/teams") + opened := time.Now() + if out, err := exec.Command("tmux", "send-keys", "-t", r.name, "Enter").CombinedOutput(); err != nil { + t.Fatalf("open teams: %v %s", err, out) + } + screen, opening := wait(opened, "Scale team 000") + // These two pinned controls do not move when the activity reading sorts + // team rows. Each sample waits for its distinct, actually rendered hint. + targets := []struct{ label, hint string }{ + {"+ New AI team", "Create a top-level team"}, + {"All teams", "Every team, and what waits on you from any of them"}, + } + var samples []float64 + for i := 0; i < 12; i++ { + target := targets[i%2] + screen = r.capture() + y := 0 + for row, line := range strings.Split(screen, "\n") { + if col := strings.Index(line, target.label); col >= 0 && col < 30 && row > 2 { + y = row + 1 + break + } + } + if y == 0 { + t.Fatalf("no pinned hover target %q:\n%s", target.label, screen) + } + start := time.Now() + send(fmt.Sprintf("\x1b[<35;8;%dM", y)) + _, elapsed := wait(start, target.hint) + samples = append(samples, float64(elapsed)/float64(time.Millisecond)) + } + sort.Float64s(samples) + if chats.Load() != 0 { + t.Fatalf("pointer navigation made %d model calls", chats.Load()) + } + t.Logf("teams=%d memberships=%d setup=%s first_screen=%s hover_ms p50=%.3f p95=%.3f max=%.3f samples=%v model_calls=0", count, count*members, seed, opening, samples[len(samples)/2], samples[len(samples)-1], samples[len(samples)-1], samples) +} diff --git a/internal/e2e/teamsperf_e2e_test.go b/internal/e2e/teamsperf_e2e_test.go index b8285c55aa..51001f4d2d 100644 --- a/internal/e2e/teamsperf_e2e_test.go +++ b/internal/e2e/teamsperf_e2e_test.go @@ -99,7 +99,9 @@ func TestTeamsLargeOverviewPointerAndLivePreview(t *testing.T) { r.lit(wheel.String()) r.waitFor(20*time.Second, "@member-010") r.resize(90, 32) - screen = r.waitFor(20*time.Second, "Performance fixture", "@member-") + // These labels also exist in the previous wide frame. Wait for the narrow + // navigation before using its row coordinates to open a member. + screen = r.waitFor(20*time.Second, "More ▾", "Performance fixture", "@member-") if evidence := os.Getenv("CODEAF_E2E_EVIDENCE_DIR"); evidence != "" { frame, err := exec.Command("tmux", "capture-pane", "-p", "-e", "-t", r.name).Output() if err != nil { diff --git a/internal/teams/home.go b/internal/teams/home.go index abc1892f27..ffadfe0155 100644 --- a/internal/teams/home.go +++ b/internal/teams/home.go @@ -85,6 +85,42 @@ func (f *File) Home(key string) (Report, bool) { return Report{}, false } +// Homes is the same reading as Home for every conversation, in one membership +// pass. Overview readers need all of them; repeating Home per member instead +// searches the entire membership list once for every row being drawn. +func (f *File) Homes() map[string]Report { + index := make(map[string]int, len(f.Teams)) + for i, t := range f.Teams { + if _, exists := index[t.ID]; !exists { + index[t.ID] = i + } + } + out := map[string]Report{} + for _, t := range f.Teams { + if t.Closed() { + continue + } + // Member reads the first occurrence even in an older, untidied file. + seen := make(map[string]bool, len(t.Members)) + for _, m := range t.Members { + if seen[m.Key] { + continue + } + seen[m.Key] = true + if !m.Home { + continue + } + if _, exists := out[m.Key]; exists { + continue + } + if r, ok := managerUpIndexed(f.Teams, t.ID, m.Key, index); ok { + out[m.Key] = r + } + } + } + return out +} + // Links is every other manager the conversation with key can be reached by: // the nearest manager up each of its memberships, in the pick order, each // managed team once, the home left out. @@ -135,9 +171,24 @@ func setHomeFlag(teams []Team, key, id string) { // not key: the team must be open and have a manager. The walk is bounded by // the list, so a loop cannot hang it. func managerUp(teams []Team, id, key string) (Report, bool) { + return managerUpIndexed(teams, id, key, nil) +} + +// The batched reader shares the authority walk and its cycle bound with Home; +// its index changes lookup cost, never which manager a membership reports to. +func managerUpIndexed(teams []Team, id, key string, index map[string]int) (Report, bool) { at := id for distance := 0; at != "" && distance <= len(teams); distance++ { - i := Index(teams, at) + i := -1 + if index == nil { + i = Index(teams, at) + } else { + var found bool + i, found = index[at] + if !found { + i = -1 + } + } if i < 0 { return Report{}, false } @@ -221,29 +272,69 @@ func depthIn(teams []Team, id string) int { // manager to report to and none for any other, and reports whether it moved // a flag. A valid flag is never moved. func assignHomes(teams []Team) bool { + // Group memberships once. Repair used to search every team's members + // several times per conversation, including for a perfectly tidy file. + type position struct{ team, member int } + positions := map[string][]position{} + var keys []string + index, depths := map[string]int{}, map[string]int{} + for i, t := range teams { + if _, exists := index[t.ID]; !exists { + index[t.ID] = i + } + depths[t.ID] = depthIn(teams, t.ID) + for j, m := range t.Members { + if _, exists := positions[m.Key]; !exists { + keys = append(keys, m.Key) + } + positions[m.Key] = append(positions[m.Key], position{i, j}) + } + } changed := false - done := map[string]bool{} - for _, t := range teams { - for _, m := range t.Members { - if done[m.Key] { + for _, key := range keys { + flags, validTeam := 0, "" + independent, hasBest := false, false + var best candidate + lastTeam := -1 + for _, p := range positions[key] { + // Home, independent and candidates all use Team.Member: only + // the first occurrence in an untidied team participates. + if p.team == lastTeam { continue } - done[m.Key] = true - if independent(teams, m.Key) { - if clearOrSet(teams, m.Key, "") { - changed = true - } + lastTeam = p.team + t, m := teams[p.team], teams[p.team].Members[p.member] + independent = independent || m.Independent + if m.Home { + flags++ + } + if t.Closed() { continue } - if homeHolds(teams, m.Key) { + r, ok := managerUpIndexed(teams, t.ID, key, index) + if !ok { continue } - want := "" - if c := candidates(teams, m.Key); len(c) > 0 { - want = c[0].Via + if m.Home && validTeam == "" { + validTeam = t.ID } - if clearOrSet(teams, m.Key, want) { - changed = true + c := candidate{r: r, depth: depths[r.Team], started: m.Started, order: p.team} + if !hasBest || c.before(best) { + best, hasBest = c, true + } + } + if !independent && (flags == 1 && validTeam != "" || flags == 0 && !hasBest) { + continue + } + want := validTeam + if want == "" && !independent && hasBest { + want = best.r.Via + } + for _, p := range positions[key] { + m := &teams[p.team].Members[p.member] + on := want != "" && teams[p.team].ID == want + if m.Home != on { + m.Home, changed = on, true } } } diff --git a/internal/teams/home_index_test.go b/internal/teams/home_index_test.go new file mode 100644 index 0000000000..c213269848 --- /dev/null +++ b/internal/teams/home_index_test.go @@ -0,0 +1,74 @@ +package teams + +import ( + "fmt" + "math/rand" + "reflect" + "testing" + "time" +) + +// The existing single-conversation policy remains the reference, including +// untidied duplicate flags, closed parents, cycles and independent members. +func TestBatchedHomesAndRepairPreserveReportingPolicy(t *testing.T) { + rng := rand.New(rand.NewSource(42)) + for trial := 0; trial < 1000; trial++ { + var teams []Team + for i := 0; i < 7; i++ { + tm := Team{ID: fmt.Sprint(i), Manager: fmt.Sprint(rng.Intn(6))} + if rng.Intn(4) == 0 { + tm.ClosedAt = time.Now() + } + if rng.Intn(2) == 0 { + tm.Parent = fmt.Sprint(rng.Intn(9)) + } + for j := 0; j < 10; j++ { + tm.Members = append(tm.Members, Member{Key: fmt.Sprint(rng.Intn(8)), Home: rng.Intn(3) == 0, Independent: rng.Intn(6) == 0, Started: rng.Intn(2) == 0}) + } + teams = append(teams, tm) + } + f := &File{Teams: teams} + homes := f.Homes() + for key := 0; key < 9; key++ { + want, wantOK := f.Home(fmt.Sprint(key)) + got, gotOK := homes[fmt.Sprint(key)] + if got != want || gotOK != wantOK { + t.Fatalf("trial %d key %d: batch=%+v/%v single=%+v/%v", trial, key, got, gotOK, want, wantOK) + } + } + want := make([]Team, len(teams)) + copy(want, teams) + for i := range want { + want[i].Members = append([]Member(nil), teams[i].Members...) + } + changed := legacyAssignHomesForTest(want) + if got := assignHomes(teams); got != changed || !reflect.DeepEqual(teams, want) { + t.Fatalf("trial %d changed=%v want=%v: indexed repair changed reporting policy", trial, got, changed) + } + } +} + +func legacyAssignHomesForTest(teams []Team) bool { + changed, done := false, map[string]bool{} + for _, tm := range teams { + for _, m := range tm.Members { + if done[m.Key] { + continue + } + done[m.Key] = true + if independent(teams, m.Key) { + changed = clearOrSet(teams, m.Key, "") || changed + continue + } + if homeHolds(teams, m.Key) { + continue + } + want := "" + if cs := candidates(teams, m.Key); len(cs) > 0 { + want = cs[0].Via + } + changed = clearOrSet(teams, m.Key, want) || changed + } + } + return changed +} diff --git a/internal/teams/spend.go b/internal/teams/spend.go index adde3d678b..c6cbb5f57c 100644 --- a/internal/teams/spend.go +++ b/internal/teams/spend.go @@ -72,7 +72,7 @@ func TeamSpend(profileDir, teamID, day string) (Spend, error) { // TeamSpendIn is [TeamSpend] against the ledger at ledger. func TeamSpendIn(profileDir, ledger, teamID, day string) (Spend, error) { - f, err := Load(profileDir) + f, err := spendTeamsFile(profileDir) if err != nil { return Spend{}, err } @@ -113,6 +113,41 @@ func TeamSpendIn(profileDir, ledger, teamID, day string) (Spend, error) { return out, nil } +// All team pools read the same immutable membership snapshot. Keep only the +// most recently used profile, and reload when its membership stamp changes; +// a ledger append still uses the existing incremental ledger reader below. +var spendTeams struct { + sync.Mutex + path, stamp string + file *File + reads int +} + +func spendTeamsFile(profileDir string) (*File, error) { + spendTeams.Lock() + defer spendTeams.Unlock() + path, before := Path(profileDir), spendMembershipStamp(profileDir) + if spendTeams.file != nil && spendTeams.path == path && spendTeams.stamp == before { + return spendTeams.file, nil + } + f, err := Load(profileDir) + spendTeams.reads++ + spendTeams.path, spendTeams.stamp, spendTeams.file = "", "", nil + if err == nil && spendMembershipStamp(profileDir) == before { + spendTeams.path, spendTeams.stamp, spendTeams.file = path, before, f + } + return f, err +} + +func spendMembershipStamp(profileDir string) string { + stamp := Stamp(profileDir) + if stamp == MissingStamp { + // An older installation can acquire its legacy file before migration. + return stamp + "|" + stampOf(legacyPath(profileDir)) + } + return stamp +} + // SpendStamp is one stamp over what a team's spend is read from: the teams // file (membership) and the ledger. Equal stamps on one day are one answer. func SpendStamp(profileDir, ledger string) string { diff --git a/internal/teams/spend_test.go b/internal/teams/spend_test.go index 59ea264746..a643db7141 100644 --- a/internal/teams/spend_test.go +++ b/internal/teams/spend_test.go @@ -1,12 +1,73 @@ package teams import ( + "encoding/json" "fmt" "os" "path/filepath" + "sync" "testing" ) +func TestTeamSpendSharesMembershipReadAndInvalidatesOnChanges(t *testing.T) { + dir, other := t.TempDir(), t.TempDir() + ledger := filepath.Join(dir, "usage.jsonl") + key := filepath.Join("/srv/sessions", "0123456789abcdef", "transcript.jsonl") + must(t, Save(dir, []Team{{ID: "aaaaaaaaaaaa", Members: []Member{{Key: key}}}, {ID: "bbbbbbbbbbbb"}})) + writeLedger(t, ledger, line("2026-09-24", 1, keySessionForTest(key), "")) + spendTeams.Lock() + before := spendTeams.reads + spendTeams.Unlock() + var wg sync.WaitGroup + for i := 0; i < 16; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := TeamSpendIn(dir, ledger, "aaaaaaaaaaaa", "2026-09-24"); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + spendTeams.Lock() + reads := spendTeams.reads - before + spendTeams.Unlock() + if reads != 1 { + t.Fatalf("unchanged membership snapshot loaded %d times, want once", reads) + } + must(t, Save(dir, []Team{{ID: "aaaaaaaaaaaa"}, {ID: "bbbbbbbbbbbb", Members: []Member{{Key: key}}}})) + old, err := TeamSpendIn(dir, ledger, "aaaaaaaaaaaa", "2026-09-24") + must(t, err) + new, err := TeamSpendIn(dir, ledger, "bbbbbbbbbbbb", "2026-09-24") + must(t, err) + if old.USD != 0 || new.USD != 1 { + t.Fatalf("membership change kept stale spend: old=%g new=%g", old.USD, new.USD) + } + appendLedger(t, ledger, line("2026-09-24", 2, keySessionForTest(key), "")) + new, err = TeamSpendIn(dir, ledger, "bbbbbbbbbbbb", "2026-09-24") + must(t, err) + if new.USD != 3 { + t.Fatal("ledger append retained stale price", new.USD) + } + // A different profile must not inherit the previous profile's membership. + new, err = TeamSpendIn(other, ledger, "bbbbbbbbbbbb", "2026-09-24") + must(t, err) + if new.USD != 0 { + t.Fatal("membership snapshot crossed profile boundary") + } + // A legacy file arriving after a missing-profile reading must still migrate. + legacy, err := json.Marshal(map[string]any{"spaces": []Team{{ID: "bbbbbbbbbbbb", Members: []Member{{Key: key}}}}}) + must(t, err) + must(t, os.WriteFile(legacyPath(other), legacy, 0o600)) + new, err = TeamSpendIn(other, ledger, "bbbbbbbbbbbb", "2026-09-24") + must(t, err) + if new.USD != 3 { + t.Fatal("cached absence hid the legacy membership file", new.USD) + } +} + +func keySessionForTest(key string) string { return filepath.Base(filepath.Dir(key)) } + // A TEAM'S DAY IS ITS SUBTREE'S LEDGER LINES, EACH ONCE. harbor holds one // conversation, dock under it another; a third is in neither. A task's line // counts through its root; a line on another day, or of an outsider, does not. diff --git a/internal/tui3/activity_scale_test.go b/internal/tui3/activity_scale_test.go new file mode 100644 index 0000000000..2f37fdb465 --- /dev/null +++ b/internal/tui3/activity_scale_test.go @@ -0,0 +1,124 @@ +package tui3 + +import ( + "fmt" + "math/rand" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" +) + +func activityScaleWorld(chats, tasks int) (session.World, time.Time) { + now := time.Date(2026, 10, 24, 12, 0, 0, 0, time.Local) + world := session.World{Read: now} + project := session.Project{Name: "Mock workspace"} + for chat := 0; chat < chats; chat++ { + row := session.SessionRow{ID: fmt.Sprintf("chat-%03d", chat), Title: fmt.Sprintf("Mock history chat %03d", chat), Transcript: fmt.Sprintf("/mock/%03d/transcript.jsonl", chat), At: now} + for task := 1; task <= tasks; task++ { + parent := "" + if task > 1 { + parent = fmt.Sprint((task-2)/4 + 1) + } + row.Tasks.Rows = append(row.Tasks.Rows, session.TaskIndexEntry{SessionID: row.ID, ID: fmt.Sprint(task), Parent: parent, Title: fmt.Sprintf("Mock repair %03d", task), Status: string(session.TaskDone), EndedAt: now.Add(-time.Duration(task) * time.Minute)}) + } + project.Sessions = append(project.Sessions, row) + } + world.Projects = []session.Project{project} + return world, now +} + +func TestActivityFilteringSharesUnchangedRowsAndNeverMutatesSnapshot(t *testing.T) { + a := tasksTableApp(t) + original := append([]tasksItem(nil), a.taskSheet.reading.items...) + unchanged := a.taskSheet.filtered(a) + if &unchanged.items[0] != &a.taskSheet.reading.items[0] { + t.Fatal("unfiltered read copied every task") + } + first := original[0] + a.deletedSessionRows = map[tasksKey]bool{tasksKeyOf(first.entry): true} + removed := a.taskSheet.filtered(a) + if len(removed.items) != len(original)-1 { + t.Fatalf("removed %d rows, want one", len(original)-len(removed.items)) + } + if !reflect.DeepEqual(a.taskSheet.reading.items, original) { + t.Fatal("filter mutated the held snapshot") + } + delete(a.deletedSessionRows, tasksKeyOf(first.entry)) + if restored := a.taskSheet.filtered(a); !reflect.DeepEqual(restored.items, original) { + t.Fatal("clearing deletion did not restore the held reading") + } +} + +func TestActivityPaintScratchExpiresBeforeFilterAndResize(t *testing.T) { + a := tasksTableApp(t) + a.taskSheetBody(122, 35) + if a.taskSheet.paint != nil || a.taskSheet.reading.layouts != nil { + t.Fatal("paint scratch survived its frame") + } + a.taskSheet.query.insert("Thor") + filtered := a.tasksFiltered() + if len(filtered.items) >= len(a.taskSheet.reading.items) || len(filtered.items) == 0 { + t.Fatalf("filter reused the previous frame: %d items", len(filtered.items)) + } + a.taskSheetBody(70, 20) + if a.taskSheet.paint != nil { + t.Fatal("resize retained paint scratch") + } +} + +// Compare memoized ancestry with the old independent walk for malformed graphs, +// including out-of-order parents, cycles, self-links and missing parents. +func TestActivityParentIndexPreservesMalformedGraphPolicy(t *testing.T) { + rng := rand.New(rand.NewSource(42)) + for trial := 0; trial < 300; trial++ { + items := make([]tasksItem, 40) + for i := range items { + items[i].entry = session.TaskIndexEntry{SessionID: "mock", ID: fmt.Sprint(i), Parent: fmt.Sprint(rng.Intn(45)), Status: string(session.TaskDone)} + } + tree := tasksTreeOf(items, time.Now(), tasksSort{}) + for _, item := range items { + key := tasksKeyOf(item.entry) + seen := map[tasksKey]bool{key: true} + at, valid := key, true + var first tasksKey + for { + current := tree.at[at] + parent := tasksKey{session: "mock", id: current.entry.Parent} + if _, exists := tree.at[parent]; !exists || parent == at { + break + } + if at == key { + first = parent + } + if seen[parent] { + valid = false + break + } + seen[parent], at = true, parent + } + got, nested := tree.up[key] + wantNested := valid && first != (tasksKey{}) + if nested != wantNested || (nested && got != first) { + t.Fatalf("trial %d key %v parent=%v nested=%v; want %v nested=%v", trial, key, got, nested, first, wantNested) + } + } + } +} + +func BenchmarkActivityScaleFrame(b *testing.B) { + world, now := activityScaleWorld(40, 128) + a := &app{pal: newPalette(tokens.NoColor, false), width: 160, height: 55, clock: func() time.Time { return now }} + a.raisePlace(pageTasks) + a.taskSheet.world = world + a.taskSheet.reading = readTasks(world, tasksMine{}, session.LastDays(now, 14), tasksSort{}, time.Time{}, now) + a.taskSheet.awayAt = a.elsewhere().Read + a.taskSheetBody(160, 45) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + a.taskSheetBody(160, 45) + } +} diff --git a/internal/tui3/place_sessions.go b/internal/tui3/place_sessions.go index ca8b99fd4a..0b100ef4db 100644 --- a/internal/tui3/place_sessions.go +++ b/internal/tui3/place_sessions.go @@ -37,6 +37,9 @@ import ( // cursor is a line of that walk rather than a count of items the frame would // have to re-derive. type tasksPlace struct { + // paint is shared only while this body's immutable snapshot is being drawn. + // It is discarded before any input, fold, filter, or engine update can run. + paint *tasksReading // actionNote reports the last explicit row action, including write failures. actionNote string cursor int @@ -341,17 +344,35 @@ func (p *tasksPlace) lineOf(a *app, want tasksKey) (int, bool) { // place is holding at this instant. func (p *tasksPlace) filtered(a *app) tasksReading { r := p.reading - undeletedItems := r.items[:0:0] - for _, item := range r.items { - if !a.deletedSessionRows[tasksKeyOf(item.entry)] && !a.deletedSessionRows[tasksChatKey(item.row.ID)] { - undeletedItems = append(undeletedItems, item) + undeletedItems := r.items + if len(a.deletedSessionRows) > 0 { + for i, item := range r.items { + if a.deletedSessionRows[tasksKeyOf(item.entry)] || a.deletedSessionRows[tasksChatKey(item.row.ID)] { + if len(undeletedItems) == len(r.items) { + undeletedItems = make([]tasksItem, 0, len(r.items)-1) + undeletedItems = append(undeletedItems, r.items[:i]...) + } + continue + } + if len(undeletedItems) != len(r.items) { + undeletedItems = append(undeletedItems, item) + } } } r.items = undeletedItems - undeletedChats := r.chats[:0:0] - for _, row := range r.chats { - if !a.deletedSessionRows[tasksChatKey(row.ID)] { - undeletedChats = append(undeletedChats, row) + undeletedChats := r.chats + if len(a.deletedSessionRows) > 0 { + for i, row := range r.chats { + if a.deletedSessionRows[tasksChatKey(row.ID)] { + if len(undeletedChats) == len(r.chats) { + undeletedChats = make([]session.SessionRow, 0, len(r.chats)-1) + undeletedChats = append(undeletedChats, r.chats[:i]...) + } + continue + } + if len(undeletedChats) != len(r.chats) { + undeletedChats = append(undeletedChats, row) + } } } r.chats = undeletedChats @@ -720,6 +741,9 @@ func (a *app) taskSheetFiltering() bool { return a.taskSheetFilter() != "" } // remember is asking about the whole of the machine's work — and a section a // query empties is not drawn at all ([tasksReading.lay] states that half). func (a *app) tasksFiltered() tasksReading { + if a.taskSheet.paint != nil { + return *a.taskSheet.paint + } // IT IS THE ONE DOOR ONTO THE READING, so the freshness check hangs here: // every path that draws, counts, moves the cursor or answers a key comes // through this function, and a check on one of them would be the page fresh diff --git a/internal/tui3/taskpane.go b/internal/tui3/taskpane.go index d87ed5381c..86bed7d19d 100644 --- a/internal/tui3/taskpane.go +++ b/internal/tui3/taskpane.go @@ -219,6 +219,12 @@ func taskPaneHitsOf(hits []placeHit) []taskPaneHit { // head cut to the list's half would unbind four keys on a frame wide enough to // offer them, which is why the seam starts under it and not beside it. func (a *app) taskSheetBody(width, room int) []placeRow { + // Share filtering and layout across the list, cursor and preview for this + // paint only. The next input always starts from the current authorities. + r := a.tasksFiltered() + r.layouts = make(map[int][]tasksLine) + a.taskSheet.paint = &r + defer func() { a.taskSheet.paint = nil }() left := taskPaneList(width) if left == width || a.taskSheet.reading.held == 0 { // A PLACE WITH NOTHING IN IT SPENDS THE FRAME TEACHING WHAT IT IS @@ -232,7 +238,6 @@ func (a *app) taskSheetBody(width, room int) []placeRow { // the reading this place is already holding, taken once. Each of them used to // take its own, and a filtered reading builds a fresh tree every time it is // asked for ([tasksPlace.filtered]), so a frame paid for four. - r := a.tasksFiltered() lines := r.lay(left) // THE HEAD SPENDS A ROW OF THE FRAME AND NONE OF THE PANE, so the pane is // built at the room that is actually left for it. A pane built at the body's diff --git a/internal/tui3/tasksplace.go b/internal/tui3/tasksplace.go index 529d12b50d..051a6abcb7 100644 --- a/internal/tui3/tasksplace.go +++ b/internal/tui3/tasksplace.go @@ -182,6 +182,9 @@ type tasksChatView struct { // tasksReading is everything drawing and routing need from one world reading. // The seen stamp travels with the snapshot across place adapters. type tasksReading struct { + // layouts is optional scratch owned by one body's paint, never a reading + // retained across input. Cursor, pane, and body readers share each width. + layouts map[int][]tasksLine chatViews map[string]tasksChatView items []tasksItem shape *tasksTree @@ -277,13 +280,28 @@ func readTasks(world session.World, mine tasksMine, win session.UsageWindow, by // order keeps the pass stable: a map alone would re-order the page on every // frame it was rebuilt, and the sections below are drawn in the order the // rows arrived within each one. - var order []tasksKey - held := map[tasksKey]tasksItem{} + // Store each large row once; the identity map holds only its position. + capacity := len(mine.rows) + len(mine.plan) + len(mine.away) + for _, project := range world.Projects { + for _, row := range project.Sessions { + capacity += len(row.Tasks.Rows) + } + } + order := make([]tasksItem, 0, capacity) + held := make(map[tasksKey]int, capacity) + get := func(key tasksKey) tasksItem { + if i, ok := held[key]; ok { + return order[i] + } + return tasksItem{} + } put := func(key tasksKey, item tasksItem) { - if _, seen := held[key]; !seen { - order = append(order, key) + if i, seen := held[key]; seen { + order[i] = item + } else { + held[key] = len(order) + order = append(order, item) } - held[key] = item } for _, project := range world.Projects { @@ -303,7 +321,7 @@ func readTasks(world session.World, mine tasksMine, win session.UsageWindow, by } for _, row := range mine.rows { if row.entry.Parent == "" { - row.entry.Parent = held[tasksKeyOf(row.entry)].entry.Parent + row.entry.Parent = get(tasksKeyOf(row.entry)).entry.Parent } put(tasksKeyOf(row.entry), tasksItem{ entry: row.entry, row: tasksRowFor(world, mine, row.entry), runs: row.runs, live: row.live, @@ -346,7 +364,7 @@ func readTasks(world session.World, mine tasksMine, win session.UsageWindow, by // there is one: presence carries neither, and a row that dropped the // program would open a page with no badge over a program's work // ([taskGuestNode]). - entry.Parent, entry.Program = held[key].entry.Parent, held[key].entry.Program + entry.Parent, entry.Program = get(key).entry.Parent, get(key).entry.Program put(key, tasksItem{ entry: entry, row: tasksRowFor(world, mine, entry), runs: true, away: true, window: task.Session, here: mine.here[strings.TrimSpace(task.SessionID)], @@ -388,14 +406,12 @@ func readTasks(world session.World, mine tasksMine, win session.UsageWindow, by for _, row := range r.chats { included[row.ID] = true } - for _, key := range order { - item := held[key] + for _, item := range order { if r.win.Holds(tasksEntryAt(item.entry, now)) { included[tasksChatOf(item)] = true } } - for _, key := range order { - item := held[key] + for _, item := range order { if !included[tasksChatOf(item)] { continue } @@ -631,6 +647,17 @@ const tasksBareLead = placeLead // lay is the one walk of the reading: what line the page draws, in order, and // which of them a person can act on. func (r tasksReading) lay(width int) []tasksLine { + if lines, ok := r.layouts[width]; ok { + return lines + } + lines := r.layout(width) + if r.layouts != nil { + r.layouts[width] = lines + } + return lines +} + +func (r tasksReading) layout(width int) []tasksLine { // A MACHINE THAT HAS RUN NOTHING LAYS NOTHING OUT, and the place spends the // frame on [tasksTeach] instead. A machine that HAS and whose window holds // none of it still lays out its head line, because that line is the only @@ -639,13 +666,17 @@ func (r tasksReading) lay(width int) []tasksLine { if width <= 0 || r.held == 0 { return nil } - lines := make([]tasksLine, 0, len(r.items)+8) + tree := r.tree() + visible := 8 + for _, section := range tasksSectionOrder { + visible += tree.shown(r, section) + } + lines := make([]tasksLine, 0, visible+len(tree.groups)) // THE FAMILY COLUMN APPEARS ONLY WHERE THERE IS A TREE TO DRAW. On a machine // whose work this surface cannot place in any conversation, and that has never // split a task up, every row is a root with nothing under it — and two cells // of empty gutter in front of all of them would be a column saying "there is // structure here" about a page that has none. - tree := r.tree() column := tree.column() levels := tasksKinRoom(width) add := func(kind tasksLineKind, text string) { @@ -1164,30 +1195,54 @@ func tasksTreeOf(items []tasksItem, now time.Time, order tasksSort, chats ...ses // row with no parent is drawn, which loses the claimed nesting and keeps the // work on the page rather than the other way round — and, more to the point, // keeps this walk from being the thing that never returns. + ground := make(map[tasksKey]uint8, len(items)) + var path []tasksKey grounded := func(key tasksKey) bool { - seen := map[tasksKey]bool{key: true} - at := key + path = path[:0] + at, valid := key, true for { + if state := ground[at]; state != 0 { + valid = state == 2 + break + } + ground[at] = 1 // visiting, or invalid after this walk + path = append(path, at) parent, ok := parentOf(at) if !ok { - return true - } - if seen[parent] { - return false + break } - seen[parent] = true at = parent } + if valid { + for _, key := range path { + ground[key] = 2 + } + } + return valid } - roots := make([]tasksItem, 0, len(items)) + // Count first so roots and sibling slices do not repeatedly copy large rows. + counts := make(map[tasksKey]int) + rootCount := 0 for _, item := range items { key := tasksKeyOf(item.entry) if parent, ok := parentOf(key); ok && grounded(key) { t.up[key] = parent + counts[parent]++ + } else { + rootCount++ + } + } + for parent, count := range counts { + t.kids[parent] = make([]tasksItem, 0, count) + } + roots := make([]tasksItem, 0, rootCount) + for _, item := range items { + key := tasksKeyOf(item.entry) + if parent, ok := t.up[key]; ok { t.kids[parent] = append(t.kids[parent], item) - continue + } else { + roots = append(roots, item) } - roots = append(roots, item) } // EVERY ROW'S RANK IS FOLDED BEFORE ANYTHING IS ORDERED, because a parent is // compared by what is under it and a walk per comparison would be a walk per diff --git a/internal/tui3/teamcrew.go b/internal/tui3/teamcrew.go index 10dd5a2de2..9920840269 100644 --- a/internal/tui3/teamcrew.go +++ b/internal/tui3/teamcrew.go @@ -89,7 +89,6 @@ func (a *app) teamsCrewMembers(t team) []teamMember { // teamsCrew is every member of team t, the manager first. Memory only. func (a *app) teamsCrew(t team) []teamsCrewRow { - tree := a.teamTree() members := a.teamsCrewMembers(t) out := make([]teamsCrewRow, 0, len(members)) for pass := 0; pass < 2; pass++ { @@ -100,15 +99,9 @@ func (a *app) teamsCrew(t team) []teamsCrewRow { st := a.teamsMember(m) r := teamsCrewRow{key: m.Key, handle: m.Handle, title: strings.TrimSpace(m.Word), word: "idle", manager: m.Key == t.Manager, independent: m.Independent, held: a.trafficHeld(m.Key), at: st.at, file: m.File} - switch { - case st.asking: - r.word, r.asking = "asking", true - case st.word == "running": - r.word = "working" - case a.teamsFailed(t, m): - r.word = "failed" - } - if home, ok := tree.Home(m.Key); ok && home.Team != t.ID && home.Via != t.ID && !r.manager { + r.word, r.asking = a.teamsMemberWord(t, m, st), st.asking + home, hasHome := a.teamReportingHome(m.Key) + if hasHome && home.Team != t.ID && home.Via != t.ID && !r.manager { r.also = a.teamNameOf(home.Team) } if row, known := a.tp.world[m.File]; known && strings.TrimSpace(row.Title) != "" { @@ -120,6 +113,21 @@ func (a *app) teamsCrew(t team) []teamsCrewRow { return out } +// Summary cards need the same status as the member list, but not its title, +// reporting relationship, or a full allocated crew row for every conversation. +func (a *app) teamsMemberWord(t team, m teamMember, st teamsMemberState) string { + switch { + case st.asking: + return "asking" + case st.word == "running": + return "working" + case a.teamsFailed(t, m): + return "failed" + default: + return "idle" + } +} + // teamsFailed reports whether member m's newest event in team t's Traffic says // its last turn failed. It reads the Traffic this window already holds and // allocates nothing. diff --git a/internal/tui3/teams.go b/internal/tui3/teams.go index 24823bae26..c07f74ca1c 100644 --- a/internal/tui3/teams.go +++ b/internal/tui3/teams.go @@ -79,6 +79,19 @@ func (a *app) teamTree() *teamstore.File { // teamByID is the team with id. Frame-safe: memory only. func (a *app) teamByID(id string) (team, bool) { return a.teamTree().Team(id) } +// teamReportingHome shares the frame's membership reading with every team +// card, including the global manager. Actions outside paint read fresh state. +func (a *app) teamReportingHome(key string) (teamstore.Report, bool) { + if a.tp.homesPending { + a.tp.homes, a.tp.homesPending = a.teamTree().Homes(), false + } + if a.tp.homes != nil { + r, ok := a.tp.homes[key] + return r, ok + } + return a.teamTree().Home(key) +} + // teamChildren is every team whose parent is id, in stored order; id "" is // the top level. func (a *app) teamChildren(id string) []team { return a.teamTree().Children(id) } diff --git a/internal/tui3/teams_scale_test.go b/internal/tui3/teams_scale_test.go new file mode 100644 index 0000000000..c9b1bc4529 --- /dev/null +++ b/internal/tui3/teams_scale_test.go @@ -0,0 +1,70 @@ +package tui3 + +import ( + "fmt" + "reflect" + "testing" +) + +func teamsScaleApp(tb testing.TB, count, members int) *app { + tb.Helper() + a := teamsPerfApp(tb, 0) + a.tp.sel = teamsAllRow + a.wall.teams = nil + for i := 0; i < count; i++ { + tm := team{ID: fmt.Sprintf("scale-%03d", i), Name: fmt.Sprintf("Scale team %03d", i)} + for j := 0; j < members; j++ { + key := fmt.Sprintf("scale-%03d-member-%03d", i, j) + if j == 0 { + tm.Manager = key + } + tm.Members = append(tm.Members, teamMember{Key: key, File: key, Handle: key, Word: key}) + a.tp.previews[key] = teamsPreview{count: 2, messages: [2]teamsPreviewMessage{{role: "user", text: "Review this fictional change"}, {role: "assistant", text: "The fictional change is ready for review."}}} + } + a.wall.teams = append(a.wall.teams, tm) + } + return a +} + +func TestTeamsPaintHomesMatchFreshCrewAndExpireAfterFrame(t *testing.T) { + a := teamsScaleApp(t, 3, 8) + m := a.wall.teams[0].Members[1] + m.Home = true + a.wall.teams[0].Members[1] = m + a.wall.teams[1].Members = append(a.wall.teams[1].Members, m) + a.wall.teams[1].Members[len(a.wall.teams[1].Members)-1].Home = false + want := a.teamsCrew(a.wall.teams[1]) + a.tp.homes = a.teamTree().Homes() + if got := a.teamsCrew(a.wall.teams[1]); !reflect.DeepEqual(got, want) { + t.Fatal("batched home changed shared member's reporting label") + } + a.tp.homes = nil + a.teamsBody(a.width, 40) + if a.tp.homes != nil { + t.Fatal("paint retained membership facts across edits") + } + a.wall.teams[0].Members[1].Home = false + a.wall.teams[1].Members[len(a.wall.teams[1].Members)-1].Home = true + a.tp.homes = a.teamTree().Homes() + got := a.teamsCrew(a.wall.teams[1]) + a.tp.homes = nil + if !reflect.DeepEqual(got, a.teamsCrew(a.wall.teams[1])) { + t.Fatal("membership edit retained an earlier reporting label") + } +} + +func BenchmarkTeamsAllScale(b *testing.B) { + for _, count := range []int{10, 100, 200} { + b.Run(fmt.Sprintf("teams=%d/members=100", count), func(b *testing.B) { + a := teamsScaleApp(b, count, 100) + room := a.height - placeHeadRows - placeFootRowsFor(pageTeams, a.height) + a.teamsBody(a.width, room) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + a.tp.hot = teamsRef{act: teamsActSelect, id: fmt.Sprintf("scale-%03d", i%2), arg: "overview"} + a.teamsBody(a.width, room) + } + }) + } +} diff --git a/internal/tui3/teamsall.go b/internal/tui3/teamsall.go index d8319f0666..4668a303f6 100644 --- a/internal/tui3/teamsall.go +++ b/internal/tui3/teamsall.go @@ -62,8 +62,8 @@ func (a *app) teamsAncestryName(t team) string { // can also have unread messages. Counts describe this team's own memberships. func (a *app) teamsOverviewState(t team) string { working, asking, failed, unread := 0, 0, 0, 0 - for _, r := range a.teamsCrew(t) { - switch r.word { + for _, m := range a.teamsCrewMembers(t) { + switch a.teamsMemberWord(t, m, a.teamsMember(m)) { case "working": working++ case "asking": @@ -71,7 +71,7 @@ func (a *app) teamsOverviewState(t team) string { case "failed": failed++ } - if a.unreadChats[r.key] { + if a.unreadChats[m.Key] { unread++ } } @@ -218,11 +218,9 @@ func (a *app) teamsOverviewCard(d *teamsDraw, t team, width, x, y int, compact b if m.Handle != "" { name = "@" + m.Handle } - title := m.Word - for _, r := range a.teamsCrew(t) { - if r.manager { - title = r.title - } + title := strings.TrimSpace(m.Word) + if row, known := a.tp.world[m.File]; known && strings.TrimSpace(row.Title) != "" { + title = row.Title } word := a.pal.dim("Manager ") + a.teamsConversationLabel(name, m.Key, m.File, inner-8) if !t.Closed() && !a.tp.previews[m.Key].missing { diff --git a/internal/tui3/teamsglobal.go b/internal/tui3/teamsglobal.go index d0c27d3070..64b3d4bce6 100644 --- a/internal/tui3/teamsglobal.go +++ b/internal/tui3/teamsglobal.go @@ -38,9 +38,8 @@ func (a *app) teamsGlobalManagerCard(d *teamsDraw, width, y int) []string { } } var reports []string - tree := a.teamTree() for _, t := range a.teamsOverviewRoots() { - home, reportsHere := tree.Home(t.Manager) + home, reportsHere := a.teamReportingHome(t.Manager) if !t.Closed() && t.Manager != "" && reportsHere && home.Team == root.ID && home.Manager == root.Manager { reports = append(reports, t.Name) } diff --git a/internal/tui3/teamspage.go b/internal/tui3/teamspage.go index 35d3c254b5..d22416aa67 100644 --- a/internal/tui3/teamspage.go +++ b/internal/tui3/teamspage.go @@ -86,6 +86,10 @@ type teamsPage struct { // recent exists only during a paint, sharing one subtree-time pass between // the sidebar and every nested card. Readers outside paint use fresh state. recent map[string]time.Time + // homes exists only during paint. Shared membership lookups must be built + // once, not rescan every team's members for every card and status count. + homes map[string]teamstore.Report + homesPending bool // reading says a read is out, so a beat that comes round before it is // answered does not start a second; again says a read was asked for while // it was out, and againWorld that the ask wanted the members' rows too. diff --git a/internal/tui3/teamspagedraw.go b/internal/tui3/teamspagedraw.go index 8de8ac76d2..ff0753ee12 100644 --- a/internal/tui3/teamspagedraw.go +++ b/internal/tui3/teamspagedraw.go @@ -975,7 +975,8 @@ func (a *app) teamsEmpty(d *teamsDraw, width, height int) []string { // recorded in frame cells. func (a *app) teamsBody(width, room int) []placeRow { a.tp.recent = a.teamsRecentTimes() - defer func() { a.tp.recent = nil }() + a.tp.homesPending = true + defer func() { a.tp.recent, a.tp.homes, a.tp.homesPending = nil, nil, false }() d := &teamsDraw{a: a} a.tp.table = teamsTableRect{} a.teamsSettle()