Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions PERF.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions docs/changes/unreleased/1919-teams-hover-scale.md
Original file line number Diff line number Diff line change
@@ -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.
148 changes: 148 additions & 0 deletions internal/e2e/activity_scale_e2e_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
131 changes: 131 additions & 0 deletions internal/e2e/teams_scale_e2e_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
4 changes: 3 additions & 1 deletion internal/e2e/teamsperf_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading