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
26 changes: 26 additions & 0 deletions PERF.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions docs/changes/unreleased/1794-teams-hover-performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
kind: fixed
title: Keep AI team hover responsive with large conversation previews
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."
- "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.
142 changes: 142 additions & 0 deletions internal/e2e/teamsperf_e2e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
//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")
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)
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, "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") {
if row < 3 || !strings.Contains(line, "@member-") {
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, "to "+alias, "The rendering preserves layout and navigation.")
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 != "" {
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)
}
}
cleanChatAuditModels(t, home, model, "teams-final")
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")
}
1 change: 1 addition & 0 deletions internal/tui3/place_teams.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
22 changes: 17 additions & 5 deletions internal/tui3/teamsoverview.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, "")
}
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions internal/tui3/teamspage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
43 changes: 33 additions & 10 deletions internal/tui3/teamspagedraw.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand All @@ -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:]
Expand Down
Loading
Loading