From c8f726b6975e66927ce851a5b5ee5b6645808c24 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 21:12:46 -0400 Subject: [PATCH 1/4] fix: reuse team previews and skip offscreen member painting --- PERF.md | 26 ++ .../1750-teams-hover-performance.md | 12 + internal/e2e/teamsperf_e2e_test.go | 128 ++++++++++ internal/tui3/place_teams.go | 1 + internal/tui3/teamsoverview.go | 22 +- internal/tui3/teamspage.go | 2 + internal/tui3/teamspagedraw.go | 43 +++- internal/tui3/teamsperf_test.go | 238 ++++++++++++++++++ internal/tui3/teamspreview.go | 66 ++++- 9 files changed, 522 insertions(+), 16 deletions(-) create mode 100644 docs/changes/unreleased/1750-teams-hover-performance.md create mode 100644 internal/e2e/teamsperf_e2e_test.go create mode 100644 internal/tui3/teamsperf_test.go diff --git a/PERF.md b/PERF.md index 8b40a46543..d51871a813 100644 --- a/PERF.md +++ b/PERF.md @@ -29,6 +29,32 @@ the pointer. Paint performs no filesystem or network reads. the overview navigation/geometry regressions and `TestTheFrameNeverReadsTheDisk` defend these bounds. +### Teams overview paint bounds + +Unchanged fitted conversation excerpts reuse their Markdown rows across hover, +keyboard focus and spinner paints. A page visit retains at most **64 excerpt +shapes** (`teamsRenderedPreviewMax`), evicting the least recently used shape and +dropping all rendered excerpts on close. Each shape retains the existing bounded +exchange (at most 64 KiB), only its fitted rows, and inline-code path decisions. +Exact text, clipping/interruption facts, width, row budget, palette ink, Markdown +styler and current path facts determine reuse; a same-length stream correction +cannot retain stale text. There is no new journal read or model request. + +Offscreen fixed-height member cards register their keyboard stops and heading +positions without rendering metadata, Markdown or borders. Targets remain in +the same order and physical scrolling retains the complete pane height. When +keyboard focus or a shrinking pane changes the final offset, the revealed cards +are painted before that same frame publishes its hit targets. Manager cards +retain their existing latest-preview target semantics. + +`teamsperf_test.go` pins excerpt reuse, bounded shape retention, live-front edits, +path/theme/geometry invalidation, viewport equivalence and keyboard reveal; +`TestTeamsLargeOverviewPointerAndLivePreview` drives the built terminal with a +120-member fixture and a live manager exchange. `BenchmarkTeamsHover` measures +12-, 120- and 1,000-member overviews with approximately 48 KiB Markdown replies. +Timing is diagnostic; regression gates assert reuse and rendered geometry, +rather than a machine-dependent time limit. + ## Context recovery bounds Conversation request admission sums the existing encoded messages and tool schemas; diff --git a/docs/changes/unreleased/1750-teams-hover-performance.md b/docs/changes/unreleased/1750-teams-hover-performance.md new file mode 100644 index 0000000000..7ff1af9cd4 --- /dev/null +++ b/docs/changes/unreleased/1750-teams-hover-performance.md @@ -0,0 +1,12 @@ +--- +kind: fixed +title: Keep AI team hover responsive with large conversation previews +pr: 1750 +surface: [chat] +invalidates: + - "AI team hover and spinner paints reparsed every member's Markdown response, including cards below the screen. Unchanged fitted excerpts now reuse bounded rendered rows, and offscreen member cards keep navigation targets without formatting their previews or borders." + - "Skipping offscreen cards can leave placeholders after keyboard scrolling or a shrinking overview. The final viewport now paints revealed cards in the same frame, while streamed text, theme, size and path facts invalidate excerpt reuse." +--- + +Physical scrolling, member click targets, manager previews and conversation drafts +keep their existing behavior. The rendered excerpt cache is cleared on page close. diff --git a/internal/e2e/teamsperf_e2e_test.go b/internal/e2e/teamsperf_e2e_test.go new file mode 100644 index 0000000000..888807f6f6 --- /dev/null +++ b/internal/e2e/teamsperf_e2e_test.go @@ -0,0 +1,128 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + teamstore "github.com/Agent-Field/codeaf/internal/teams" + "github.com/charmbracelet/x/ansi" +) + +// This checks the real input parser, overview and conversation door together. +// Historical excerpts are fixtures; the manager's final exchange is live. +func TestTeamsLargeOverviewPointerAndLivePreview(t *testing.T) { + requireTmuxAndKey(t) + const model = "deepseek/deepseek-v4.1-flash" + if e2eModel != model { + t.Fatalf("set CODEAF_E2E_MODEL=%s to pin auxiliary roles too", model) + } + home := newHome(t, map[string]any{"models.fallbacks": "", "model_pool": "off", "daily_budget_usd": 0}) + ws := newWorkspace(t, "teams-performance", false) + quiet := false + team := teamstore.Team{ID: teamstore.NewID(), Name: "Performance fixture", Settings: teamstore.Settings{Wake: &quiet}} + var manager string + for i := 0; i < 120; i++ { + sid := fmt.Sprintf("%016x", 0x5000000000000000+i) + bucket := strings.ReplaceAll(filepath.Clean(ws), string(filepath.Separator), "-") + dir := filepath.Join(home, "v3", "projects", bucket, sid) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + at := time.Now().Add(-time.Hour) + writeJSON(t, filepath.Join(dir, "meta.json"), map[string]any{"id": sid, "title": fmt.Sprintf("Review member %03d", i), "workspace": ws, "created": at, "lastUserAt": at}) + var journal strings.Builder + for _, row := range []map[string]any{ + {"type": "session", "version": 1, "id": sid, "cwd": ws, "model": model, "timestamp": at}, + {"type": "message", "role": "user", "content": fmt.Sprintf("Please review fixture %03d", i), "timestamp": at}, + {"type": "message", "role": "assistant", "content": strings.Repeat("## Review\n\nThe **rendering** preserves `layout` and navigation.\n\n", 800), "timestamp": at.Add(time.Second)}, + } { + raw, err := json.Marshal(row) + if err != nil { + t.Fatal(err) + } + journal.Write(raw) + journal.WriteByte('\n') + } + file := filepath.Join(dir, "transcript.jsonl") + if err := os.WriteFile(file, []byte(journal.String()), 0o600); err != nil { + t.Fatal(err) + } + team.Members = append(team.Members, teamstore.Member{Key: file, File: file, Where: ws, Handle: fmt.Sprintf("member-%03d", i), Word: fmt.Sprintf("Review member %03d", i), HandleBy: teamstore.HandleByTyped}) + if i == 0 { + manager, team.Manager = file, file + } + } + if err := teamstore.Save(home, []teamstore.Team{team}); err != nil { + t.Fatal(err) + } + r := start(t, "teams_perf", home, ws, 160, 55, "chat", "--session", manager, "--model", model, "--one-model", "--no-host") + r.lit("Reply with exactly these words: Teams live check passed. Do not use tools or delegate.") + r.keys("Enter") + cleanChatWaitAnswer(t, r, "Teams live check passed", 3*time.Minute) + cleanChatAuditModels(t, home, model, "teams-live") + r.lit("/teams") + r.keys("Enter") + screen := r.waitFor(20*time.Second, "Performance fixture") + selected := false + for row, line := range strings.Split(screen, "\n") { + if row > 2 && strings.Contains(line, "Performance fixture") { + r.mouseClick(5, row+1) + selected = true + break + } + } + if !selected { + t.Fatal("team has no sidebar door") + } + r.waitFor(20*time.Second, "Teams live check passed", "@member-001", "Please review fixture 001") + var sweep strings.Builder + for i := 0; i < 600; i++ { + fmt.Fprintf(&sweep, "\x1b[<35;%d;%dM", 35+i%110, 30+i%12) + } + r.lit(sweep.String()) + // One burst owes its complete physical scroll distance after coalescing. + var wheel strings.Builder + for i := 0; i < 12; i++ { + wheel.WriteString("\x1b[<65;100;40M") + } + r.lit(wheel.String()) + r.waitFor(20*time.Second, "@member-010") + r.resize(90, 32) + screen = r.waitFor(20*time.Second, "+ Add chat", "@member-") + // Open a visible member after scrolling and resizing: hits must follow the + // actual card geometry, and chat navigation must survive the pointer burst. + for row, line := range strings.Split(screen, "\n") { + if row < 3 || !strings.Contains(line, "@member-") { + continue + } + col := ansi.StringWidth(line[:strings.Index(line, "@member-")]) + 1 + r.mouseClick(col+1, row+1) + r.waitFor(20*time.Second, "Please review fixture", "Review") + if strings.Contains(r.capture(), "+ Add chat") { + t.Fatal("member click stayed in the overview") + } + if evidence := os.Getenv("CODEAF_E2E_EVIDENCE_DIR"); evidence != "" { + if err := os.MkdirAll(evidence, 0o700); err != nil { + t.Fatal(err) + } + frame, err := exec.Command("tmux", "capture-pane", "-p", "-e", "-t", r.name).Output() + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(evidence, "teams-member-door.ansi"), frame, 0o600); err != nil { + t.Fatal(err) + } + } + t.Log("120-member overview: live manager preview, pointer burst, pane wheel, resize and member click passed") + return + } + t.Fatal("no visible member after resize") +} diff --git a/internal/tui3/place_teams.go b/internal/tui3/place_teams.go index 76a1990cd0..3472dd40ba 100644 --- a/internal/tui3/place_teams.go +++ b/internal/tui3/place_teams.go @@ -54,6 +54,7 @@ func (placeTeams) tick(a *app, now time.Time) (bool, tea.Cmd) { // close drops what the page drew; the selection and the // fold are kept for the next visit, as a place's views are. func (placeTeams) close(a *app) { + a.tp.previewRows = nil a.tp.focus, a.tp.targets = false, nil if a.wall.org.on { a.wallOrganizeClose() diff --git a/internal/tui3/teamsoverview.go b/internal/tui3/teamsoverview.go index 6b1d6c4780..1a135d8f93 100644 --- a/internal/tui3/teamsoverview.go +++ b/internal/tui3/teamsoverview.go @@ -229,8 +229,11 @@ func (a *app) teamsMemberCards(d *teamsDraw, t team, width, y int) []string { if !t.Root && !t.Closed() { labelWidth -= 3 } - words := a.teamsCardMetadata(r, labelWidth) - words = append(words, a.teamsConversationPreview(r.key, w-4, 5-len(words))...) + var words []string + if d.visible(y+len(out), 7) { + words = a.teamsCardMetadata(r, labelWidth) + words = append(words, a.teamsConversationPreview(r.key, w-4, 5-len(words))...) + } for len(words) < 5 { words = append(words, "") } @@ -240,10 +243,19 @@ func (a *app) teamsMemberCards(d *teamsDraw, t team, width, y int) []string { if row == 0 && !t.Root && !t.Closed() { contentWidth -= 3 } - line := teamsPad(word, contentWidth) + line := "" + if d.visible(y+len(out), 7) { + line = teamsPad(word, contentWidth) + } if !a.tp.previews[r.key].missing { - line = d.row(word, contentWidth, teamsTarget{act: teamsActMember, id: t.ID, arg: r.key, - x0: x + 2, y: y + len(out) + 1 + row, hint: a.teamsCrewHint(r), pane: true}, false) + target := teamsTarget{act: teamsActMember, id: t.ID, arg: r.key, + x0: x + 2, y: y + len(out) + 1 + row, hint: a.teamsCrewHint(r), pane: true} + if d.visible(y+len(out), 7) { + line = d.row(word, contentWidth, target, false) + } else { + target.x1 = target.x0 + contentWidth + d.targets = append(d.targets, target) + } } if row == 0 && !t.Root && !t.Closed() { const actionCells = 3 diff --git a/internal/tui3/teamspage.go b/internal/tui3/teamspage.go index 9a1eb28e7e..35d3c254b5 100644 --- a/internal/tui3/teamspage.go +++ b/internal/tui3/teamspage.go @@ -47,6 +47,8 @@ type teamsPage struct { orgHits []wallHit orgRect wallRect previews map[string]teamsPreview + previewRows map[teamsPreviewShape]*teamsRenderedPreview + previewClock uint64 interactionOffsets map[string]int table teamsTableRect tableOver int diff --git a/internal/tui3/teamspagedraw.go b/internal/tui3/teamspagedraw.go index bbd6f7141b..8de8ac76d2 100644 --- a/internal/tui3/teamspagedraw.go +++ b/internal/tui3/teamspagedraw.go @@ -30,6 +30,13 @@ type teamsDraw struct { a *app targets []teamsTarget cards []teamsCardSpot + // Only the body supplies a viewport. Isolated card renderers draw whole. + window bool + first, last int +} + +func (d *teamsDraw) visible(y, height int) bool { + return !d.window || y < d.last && y+height > d.first } // lit reports whether the target named r wears a ground, and which. @@ -988,22 +995,29 @@ func (a *app) teamsBody(width, room int) []placeRow { } top := len(lines) mark := len(d.targets) - pane := a.teamsTop(d, paneW-1) - if !a.teamsAny() { - pane = append(pane, a.teamsAllCards(d, paneW-1, len(pane))...) - emptyMark, emptyY := len(d.targets), len(pane) - empty := a.teamsEmpty(d, paneW-1, 0) - d.shift(emptyMark, 0, emptyY) - pane = append(pane, empty...) - } else { - pane = append(pane, a.teamsPaneRest(d, paneW-1, len(pane))...) + vis := max(room-top, 0) + d.window, d.first, d.last = true, a.tp.paneOffset, a.tp.paneOffset+vis + drawPane := func() []string { + d.targets = d.targets[:mark] + d.cards = nil + pane := a.teamsTop(d, paneW-1) + if !a.teamsAny() { + pane = append(pane, a.teamsAllCards(d, paneW-1, len(pane))...) + emptyMark, emptyY := len(d.targets), len(pane) + empty := a.teamsEmpty(d, paneW-1, 0) + d.shift(emptyMark, 0, emptyY) + pane = append(pane, empty...) + } else { + pane = append(pane, a.teamsPaneRest(d, paneW-1, len(pane))...) + } + return pane } + pane := drawPane() for i := mark; i < len(d.targets); i++ { d.targets[i].line = top + d.targets[i].y } // Physical scrolling retains its offset through redraws. Keyboard walks // reveal only the newly focused target, including controls below a card. - vis := max(room-top, 0) a.tp.paneRows, a.tp.paneRoom, a.tp.paneTop = len(pane), vis, top off := min(a.tp.paneOffset, max(len(pane)-vis, 0)) if !a.tp.paneWheel && vis > 0 { @@ -1023,6 +1037,15 @@ func (a *app) teamsBody(width, room int) []placeRow { break } } + // A keyboard stop or a shrinking overview can reveal a previously hidden + // card. Paint that final viewport in this same frame, before publishing hits. + if off != d.first { + d.first, d.last = off, off+vis + pane = drawPane() + for i := mark; i < len(d.targets); i++ { + d.targets[i].line = top + d.targets[i].y + } + } a.tp.paneOffset = off a.tp.cards = d.cards pane = pane[off:] diff --git a/internal/tui3/teamsperf_test.go b/internal/tui3/teamsperf_test.go new file mode 100644 index 0000000000..aa46aeaba6 --- /dev/null +++ b/internal/tui3/teamsperf_test.go @@ -0,0 +1,238 @@ +package tui3 + +import ( + "fmt" + "reflect" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/tui2/tokens" + "github.com/charmbracelet/x/ansi" +) + +func teamsPerfApp(tb testing.TB, members int) *app { + tb.Helper() + a := newApp(nil, Options{Agent: &fakeAgent{model: "bench/model"}, Workspace: tb.TempDir(), ProfileDir: tb.TempDir()}) + a.width, a.height = 160, 55 + a.pal = newPalette(tokens.TrueColor, false) + a.wall.loaded = true + a.page = pageTeams + a.tp.sel = "perf" + a.tp.previews = map[string]teamsPreview{} + t := team{ID: "perf", Name: "Performance", Manager: "member-0"} + for i := 0; i < members; i++ { + key := fmt.Sprintf("member-%d", i) + t.Members = append(t.Members, teamMember{Key: key, Handle: key, Word: "Check the rendering", File: key}) + a.tp.previews[key] = teamsPreview{count: 2, messages: [2]teamsPreviewMessage{ + {role: "user", text: "Please review this change"}, + {role: "assistant", text: strings.Repeat("## Review\n\nThe **rendering** preserves `layout` and [links](https://example.com).\n\n", 600)}, + }} + } + a.wall.teams = []team{t} + return a +} + +func TestTeamsHoverReusesPreviewAndKeepsGroundFresh(t *testing.T) { + a := teamsPerfApp(t, 120) + room := a.height - placeHeadRows - placeFootRowsFor(pageTeams, a.height) + a.teamsBody(a.width, room) + before := map[teamsPreviewShape]*teamsRenderedPreview{} + for shape, cached := range a.tp.previewRows { + before[shape] = cached + } + if len(before) == 0 || len(before) >= 20 { + t.Fatalf("rendered offscreen previews: %d", len(before)) + } + var target teamsTarget + for _, hit := range a.tp.targets { + if hit.act == teamsActMember && !hit.hidden && hit.arg != "member-0" { + target = hit + break + } + } + if target.arg == "" { + t.Fatal("no visible member to hover") + } + a.teamsHover(target.x0, target.y) + if a.tp.hot != target.ref() || !a.dirty { + t.Fatal("hover did not repaint the member ground") + } + a.teamsBody(a.width, room) + for shape, cached := range before { + if a.tp.previewRows[shape] != cached { + t.Fatal("hover reparsed an unchanged preview") + } + } + // A member below the screen remains a keyboard stop, and navigating to it + // paints its excerpt immediately rather than publishing placeholder rows. + a.tp.cur = teamsRef{act: teamsActMember, id: "perf", arg: "member-119"} + a.tp.paneWheel = false + rows := a.teamsBody(a.width, room) + var words []string + for _, row := range rows { + words = append(words, row.text) + } + if a.tp.paneOffset == 0 || !strings.Contains(plain(strings.Join(words, "\n")), "@member-119") { + t.Fatal("keyboard reveal published a blank offscreen card") + } + for _, hit := range a.tp.targets { + if hit.arg == "member-119" && !hit.hidden { + if got, ok := a.teamsTargetAt(hit.x0, hit.y); !ok || got.ref() != hit.ref() { + t.Fatal("revealed member's click target drifted") + } + return + } + } + t.Fatal("revealed member has no pointer target") +} + +func TestTeamsPreviewMemoTracksContentGeometryInkAndPaths(t *testing.T) { + a := teamsPerfApp(t, 1) + key := "member-0" + width, budget := 70, 6 + a.tp.previews[key] = teamsPreview{count: 2, messages: [2]teamsPreviewMessage{ + {role: "user", text: "Check this please"}, {role: "assistant", text: "The **answer** uses `file.go`."}, + }} + a.pathLinks = true + a.pathSeen = map[string]string{"file.go": ""} + check := func() { + t.Helper() + got := a.teamsConversationPreview(key, width, budget) + want := a.teamsRenderPreview(a.teamsManagerMessages(key), width, budget, a.plainCodePath) + if !reflect.DeepEqual(got, want) { + t.Fatalf("memo differs from fresh render:\n%q\n%q", got, want) + } + } + check() + shape := teamsPreviewShape{key: key, width: width, budget: budget} + first := a.tp.previewRows[shape] + check() + if a.tp.previewRows[shape] != first { + t.Fatal("unchanged preview was rendered twice") + } + p := a.tp.previews[key] + p.messages[1].text = "The **update** uses `file.go`." // same byte length + a.tp.previews[key] = p + check() + if a.tp.previewRows[shape] == first { + t.Fatal("same-length correction retained stale words") + } + first = a.tp.previewRows[shape] + a.pathSeen["file.go"] = a.workspace + "/file.go" + check() + if a.tp.previewRows[shape] == first { + t.Fatal("new path fact retained stale code-span styling") + } + a.pathLinks = false + check() + a.pal = newPalette(tokens.ANSI16, true) + check() + width, budget = 25, 3 + check() + p = a.tp.previews[key] + p.messages[1].interrupted, p.messages[1].clipped = true, true + a.tp.previews[key] = p + check() + a.tp.previews[key] = teamsPreview{missing: true} + if words := plain(strings.Join(a.teamsConversationPreview(key, width, budget), "\n")); words != "Conversation unavailable" { + t.Fatal("deleted conversation retained its preview:", words) + } +} + +func TestTeamsPreviewMemoBoundsShapesAndDropsOnClose(t *testing.T) { + a := teamsPerfApp(t, 1) + for width := 20; width < 20+teamsRenderedPreviewMax+10; width++ { + a.teamsConversationPreview("member-0", width, 3) + } + if len(a.tp.previewRows) != teamsRenderedPreviewMax { + t.Fatalf("unbounded resized previews: %d", len(a.tp.previewRows)) + } + placeTeams{}.close(a) + if len(a.tp.previewRows) != 0 { + t.Fatal("page visit retained rendered previews after close") + } +} + +func TestTeamsViewportMatchesFullCardsAcrossScrollAndResize(t *testing.T) { + a := teamsPerfApp(t, 18) + for _, width := range []int{55, 90, 160} { + a.width = width + for _, off := range []int{0, 25, 60, 9999} { + a.tp.paneOffset, a.tp.paneWheel = off, true + room := 22 + got := a.teamsBody(width, room) + paneW := width - teamsRailCols(width) - 1 + d := &teamsDraw{a: a} + pane := a.teamsTop(d, paneW) + pane = append(pane, a.teamsPaneRest(d, paneW, len(pane))...) + for row := a.tp.paneTop; row < room; row++ { + i := a.tp.paneOffset + row - a.tp.paneTop + want := "" + if i < len(pane) { + want = strings.TrimRight(plain(pane[i]), " ") + } + text := got[row].text + if rail := teamsRailCols(width); rail > 0 { + text = ansi.Cut(text, rail, width) + } + if strings.TrimRight(plain(text), " ") != want { + t.Fatalf("viewport differs at width=%d offset=%d row=%d", width, off, row) + } + } + } + } +} + +func TestTeamsPointerStormStillFoldsBeforeKeyboard(t *testing.T) { + a := teamsPerfApp(t, 120) + now := time.Now() + a.clock = func() time.Time { return now } + a.View() + for i := 0; i < 600; i++ { + a.Update(tea.MouseMotionMsg{X: 35 + i%80, Y: 12}) + a.View() + } + if a.ptr.answered > 2 { + t.Fatalf("pointer storm escaped coalescing: %d answers", a.ptr.answered) + } + a.Update(key("down")) + a.View() + if !a.tp.focus || a.tp.cur == (teamsRef{}) { + t.Fatal("keyboard navigation did not run after the pointer burst") + } +} + +func TestTeamsPreviewMemoFollowsTheFrontStream(t *testing.T) { + a := teamsPerfApp(t, 1) + key := a.frontTabKey() + a.entries = []entry{{kind: entryUser, text: "Review the stream"}, {kind: entryAssistant, text: "The first answer"}} + shape := teamsPreviewShape{key: key, width: 60, budget: 6} + first := a.teamsConversationPreview(key, 60, 6) + a.entries[1].text = "The fresh answer" + second := a.teamsConversationPreview(key, 60, 6) + if reflect.DeepEqual(first, second) || !strings.Contains(plain(strings.Join(second, "\n")), "fresh answer") { + t.Fatal("front stream retained the previous answer") + } + a.entries = append(a.entries, entry{kind: entryUser, text: "Now a new question"}, entry{kind: entryAssistant, text: "A new response"}) + a.teamsConversationPreview(key, 60, 6) + if messages := a.tp.previewRows[shape].messages; messages[0].text != "Now a new question" { + t.Fatal("new human exchange retained the old prompt") + } +} + +func BenchmarkTeamsHover(b *testing.B) { + for _, members := range []int{12, 120, 1000} { + b.Run(fmt.Sprintf("members=%d", members), func(b *testing.B) { + a := teamsPerfApp(b, members) + a.teamsBody(a.width, a.height-placeHeadRows-placeFootRowsFor(pageTeams, a.height)) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + a.tp.hot = teamsRef{act: teamsActMember, id: "perf", arg: fmt.Sprintf("member-%d", i%3)} + a.teamsBody(a.width, a.height-placeHeadRows-placeFootRowsFor(pageTeams, a.height)) + } + }) + } +} diff --git a/internal/tui3/teamspreview.go b/internal/tui3/teamspreview.go index cb0c80e78b..66f08ed8d5 100644 --- a/internal/tui3/teamspreview.go +++ b/internal/tui3/teamspreview.go @@ -1,6 +1,7 @@ package tui3 import ( + "slices" "strings" "github.com/charmbracelet/x/ansi" @@ -9,6 +10,33 @@ import ( "github.com/Agent-Field/codeaf/internal/tui2/tokens" ) +// Keep only small, fitted excerpts, with one bounded set of shapes per page +// visit. Exact message equality also catches same-length stream corrections. +const teamsRenderedPreviewMax = 64 + +type teamsPreviewShape struct { + key string + width, budget int +} + +type teamsRenderedPreview struct { + messages []teamsPreviewMessage + ink string + styler *tokens.Styler + paths map[string]bool + rows []string + used uint64 +} + +func (a *app) teamsPreviewPathsSame(paths map[string]bool) bool { + for span, plain := range paths { + if a.plainCodePath(span) != plain { + return false + } + } + return true +} + // A card shows the latest human exchange, even when team traffic arrives // afterwards. Without a human prompt it falls back to the latest delivery or // assistant update, never inventing a prompt for autonomous work. @@ -128,6 +156,39 @@ func (a *app) teamsConversationPreview(key string, width, budget int) []string { } return []string{a.pal.dim(word)} } + shape := teamsPreviewShape{key: key, width: width, budget: budget} + ink := a.pal.muted("x") + a.pal.accent(a.pal.youGlyph()) + a.pal.ink("...") + a.pal.dim("interrupted") + a.tp.previewClock++ + if cached := a.tp.previewRows[shape]; cached != nil && cached.ink == ink && cached.styler == a.styler() && + slices.Equal(cached.messages, messages) && a.teamsPreviewPathsSame(cached.paths) { + cached.used = a.tp.previewClock + return cached.rows + } + paths := map[string]bool{} + plainCode := func(span string) bool { + plain := a.plainCodePath(span) + paths[span] = plain + return plain + } + rows := a.teamsRenderPreview(messages, width, budget, plainCode) + if a.tp.previewRows == nil { + a.tp.previewRows = map[teamsPreviewShape]*teamsRenderedPreview{} + } + if a.tp.previewRows[shape] == nil && len(a.tp.previewRows) >= teamsRenderedPreviewMax { + var oldest teamsPreviewShape + used := ^uint64(0) + for shape, cached := range a.tp.previewRows { + if cached.used < used { + oldest, used = shape, cached.used + } + } + delete(a.tp.previewRows, oldest) + } + a.tp.previewRows[shape] = &teamsRenderedPreview{messages: messages, ink: ink, styler: a.styler(), paths: paths, rows: rows, used: a.tp.previewClock} + return rows +} + +func (a *app) teamsRenderPreview(messages []teamsPreviewMessage, width, budget int, plainCode func(string) bool) []string { var out []string for i, m := range messages { room := budget - len(out) @@ -150,7 +211,7 @@ func (a *app) teamsConversationPreview(key string, width, budget int) []string { rows = append(rows, a.pal.ink(part)) } } else { - rows = trimBlanks(a.renderMarkdown(m.text, width)) + rows = trimBlanks(renderMarkdownWithCode(a.styler(), m.text, width, plainCode)) if m.interrupted { rows = append(rows, a.pal.dim("interrupted")) } @@ -280,6 +341,9 @@ func (a *app) teamsConversationCard(d *teamsDraw, t team, r teamsCrewRow, title if r.key != "" { d.cards = append(d.cards, teamsCardSpot{team: t.ID, key: r.key, title: title, y: y, width: width}) } + if !d.visible(y, len(lines)+2) { + return make([]string, len(lines)+2) + } heading, border, _ := a.teamsCardHeading(t, r, title, width) return wallCardBuildWithBorder(a.pal, heading, lines, x, y, width, 1, 0, border).rows } From 9f11da76be248fcd43f7698a37fe69e13b22a649 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 21:15:40 -0400 Subject: [PATCH 2/4] test: account for task rail and physically scrolled team headers --- internal/e2e/teamsperf_e2e_test.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/e2e/teamsperf_e2e_test.go b/internal/e2e/teamsperf_e2e_test.go index 888807f6f6..6564919abc 100644 --- a/internal/e2e/teamsperf_e2e_test.go +++ b/internal/e2e/teamsperf_e2e_test.go @@ -64,6 +64,9 @@ func TestTeamsLargeOverviewPointerAndLivePreview(t *testing.T) { t.Fatal(err) } r := start(t, "teams_perf", home, ws, 160, 55, "chat", "--session", manager, "--model", model, "--one-model", "--no-host") + if strings.Contains(r.capture(), "Tasks 0") { + r.keys("M-l") + } r.lit("Reply with exactly these words: Teams live check passed. Do not use tools or delegate.") r.keys("Enter") cleanChatWaitAnswer(t, r, "Teams live check passed", 3*time.Minute) @@ -96,7 +99,7 @@ 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, "+ Add chat", "@member-") + screen = r.waitFor(20*time.Second, "Performance fixture", "@member-") // Open a visible member after scrolling and resizing: hits must follow the // actual card geometry, and chat navigation must survive the pointer burst. for row, line := range strings.Split(screen, "\n") { @@ -106,7 +109,7 @@ func TestTeamsLargeOverviewPointerAndLivePreview(t *testing.T) { col := ansi.StringWidth(line[:strings.Index(line, "@member-")]) + 1 r.mouseClick(col+1, row+1) r.waitFor(20*time.Second, "Please review fixture", "Review") - if strings.Contains(r.capture(), "+ Add chat") { + if strings.Contains(r.capture(), "tab next place") { t.Fatal("member click stayed in the overview") } if evidence := os.Getenv("CODEAF_E2E_EVIDENCE_DIR"); evidence != "" { From cfdc69c631363f3dc3a6702fe06360e3e19aa247 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 21:16:56 -0400 Subject: [PATCH 3/4] test: assert the opened member at the latest reply --- internal/e2e/teamsperf_e2e_test.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/e2e/teamsperf_e2e_test.go b/internal/e2e/teamsperf_e2e_test.go index 6564919abc..b8285c55aa 100644 --- a/internal/e2e/teamsperf_e2e_test.go +++ b/internal/e2e/teamsperf_e2e_test.go @@ -100,6 +100,15 @@ func TestTeamsLargeOverviewPointerAndLivePreview(t *testing.T) { r.waitFor(20*time.Second, "@member-010") r.resize(90, 32) screen = r.waitFor(20*time.Second, "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 { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(evidence, "teams-scrolled-overview.ansi"), frame, 0o600); err != nil { + t.Fatal(err) + } + } // Open a visible member after scrolling and resizing: hits must follow the // actual card geometry, and chat navigation must survive the pointer burst. for row, line := range strings.Split(screen, "\n") { @@ -107,8 +116,9 @@ func TestTeamsLargeOverviewPointerAndLivePreview(t *testing.T) { continue } col := ansi.StringWidth(line[:strings.Index(line, "@member-")]) + 1 + alias := strings.Fields(line[strings.Index(line, "@member-"):])[0] r.mouseClick(col+1, row+1) - r.waitFor(20*time.Second, "Please review fixture", "Review") + r.waitFor(20*time.Second, "to "+alias, "The rendering preserves layout and navigation.") if strings.Contains(r.capture(), "tab next place") { t.Fatal("member click stayed in the overview") } @@ -124,6 +134,7 @@ func TestTeamsLargeOverviewPointerAndLivePreview(t *testing.T) { t.Fatal(err) } } + cleanChatAuditModels(t, home, model, "teams-final") t.Log("120-member overview: live manager preview, pointer burst, pane wheel, resize and member click passed") return } From d08af658c49a063fec325731369537e374286ede Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Wed, 7 Oct 2026 21:18:15 -0400 Subject: [PATCH 4/4] docs: identify the team performance change entry --- ...ams-hover-performance.md => 1794-teams-hover-performance.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/changes/unreleased/{1750-teams-hover-performance.md => 1794-teams-hover-performance.md} (98%) diff --git a/docs/changes/unreleased/1750-teams-hover-performance.md b/docs/changes/unreleased/1794-teams-hover-performance.md similarity index 98% rename from docs/changes/unreleased/1750-teams-hover-performance.md rename to docs/changes/unreleased/1794-teams-hover-performance.md index 7ff1af9cd4..13139cf8a3 100644 --- a/docs/changes/unreleased/1750-teams-hover-performance.md +++ b/docs/changes/unreleased/1794-teams-hover-performance.md @@ -1,7 +1,7 @@ --- kind: fixed title: Keep AI team hover responsive with large conversation previews -pr: 1750 +pr: 1794 surface: [chat] invalidates: - "AI team hover and spinner paints reparsed every member's Markdown response, including cards below the screen. Unchanged fitted excerpts now reuse bounded rendered rows, and offscreen member cards keep navigation targets without formatting their previews or borders."