Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
6c6e8b6
feat: give teams a dedicated overview with interaction navigation
ZeroPoint95 Oct 1, 2026
5aa3fde
feat: simplify the teams overview and clarify selected-team context
ZeroPoint95 Oct 1, 2026
b29b94e
feat(teams): add explicit overlays and membership controls
ZeroPoint95 Oct 2, 2026
80c3522
Implement recursive team disbanding and checked permanent deletion
ZeroPoint95 Oct 2, 2026
b0bee9e
Reserve member removal control space before fitting aliases
ZeroPoint95 Oct 2, 2026
7fe2cf2
fix(teams): preserve disband save results and Home deletion routing
ZeroPoint95 Oct 2, 2026
dcb8ed8
Make saved rows deletable with simple confirmations
ZeroPoint95 Oct 2, 2026
a388fa4
Refresh Sessions totals after deleting saved rows
ZeroPoint95 Oct 2, 2026
321be4f
Simplify deletion dialogs and remove busy confirmation flashes
ZeroPoint95 Oct 2, 2026
a7194d6
Fix final Home deletion and choose managers explicitly in Teams
ZeroPoint95 Oct 2, 2026
23289c5
Preserve team conversations and constrain manager responsibilities
ZeroPoint95 Oct 2, 2026
a849384
Shorten manager deletion refusal wording
ZeroPoint95 Oct 2, 2026
e4da9f9
Add nested All teams overview and organize on Teams
ZeroPoint95 Oct 2, 2026
5ea1ca9
Reveal retained teams through Show closed toggle
ZeroPoint95 Oct 2, 2026
a15c803
Separate Chats overlays from the conversation grid
ZeroPoint95 Oct 2, 2026
5c2bca3
Highlight the grid button while hovering conversation tabs
ZeroPoint95 Oct 2, 2026
4f024a1
Give the optional global manager a dedicated card and lifecycle
ZeroPoint95 Oct 2, 2026
2aee779
feat(teams): expand manager preview and simplify settings
ZeroPoint95 Oct 2, 2026
81b29ef
fix(teams): shorten manager preview and box questions
ZeroPoint95 Oct 2, 2026
bcbba69
fix(teams): synchronize selectors and prioritize recent interactions
ZeroPoint95 Oct 3, 2026
ea20e39
fix(teams): simplify global manager card states
ZeroPoint95 Oct 3, 2026
34c9027
Show all saved conversations and share team creation flows
ZeroPoint95 Oct 3, 2026
06eb5d1
Standardize team menu keyboard hints
ZeroPoint95 Oct 3, 2026
ff31a0c
Polish team menus and show current conversation models on cards
ZeroPoint95 Oct 4, 2026
2409537
Preserve model ID prefixes exactly in team cards
ZeroPoint95 Oct 4, 2026
08df088
Simplify team settings and overview headings
ZeroPoint95 Oct 4, 2026
48357ac
Align team header check with sidebar-only naming
ZeroPoint95 Oct 4, 2026
dc2d40f
Keep retained team names in sidebar only
ZeroPoint95 Oct 4, 2026
0712b3d
Show the latest conversation exchange in team cards
ZeroPoint95 Oct 4, 2026
a14f123
Stack team cards independently and scroll by pointer position
ZeroPoint95 Oct 4, 2026
a3aee81
Merge remote-tracking branch 'origin/dev' into zeropoint95/teams-ux-c…
ZeroPoint95 Oct 4, 2026
17ee10c
Harden Teams and deletion flows during final review
ZeroPoint95 Oct 4, 2026
a12a949
Complete adversarial revisions and race-proof Teams test clocks
ZeroPoint95 Oct 4, 2026
6c2b6e5
Split deletion cleanup phases and preserve recorded completion times
ZeroPoint95 Oct 4, 2026
d91eea6
Drive recent-reader regressions through compact Bubble Tea batches
ZeroPoint95 Oct 4, 2026
f31a319
Record Teams UX cleanup behavior for draft PR 1750
ZeroPoint95 Oct 4, 2026
e985155
Merge branch 'dev' into zeropoint95/teams-ux-cleanup
ZeroPoint95 Oct 5, 2026
15a877c
Merge branch 'dev' into zeropoint95/teams-ux-cleanup
ZeroPoint95 Oct 6, 2026
5a16863
Add a direct clear action to the Chats team chip
ZeroPoint95 Oct 6, 2026
9237068
Refine team chip hover and verify remembered Chats restoration
ZeroPoint95 Oct 6, 2026
fceecca
Keep team-chip hover consistent with cached transcript paint
ZeroPoint95 Oct 6, 2026
f1bca5a
Remove None from the team overlay picker
ZeroPoint95 Oct 6, 2026
950f453
Keep nested team hierarchies readable at every overview width
ZeroPoint95 Oct 6, 2026
fe74739
Animate active conversation cards in Teams
ZeroPoint95 Oct 6, 2026
838cc18
Page Recent interactions backward and forward
ZeroPoint95 Oct 6, 2026
f0ff3fc
Preserve collapsed subteams across overview resizing
ZeroPoint95 Oct 6, 2026
d3cacae
Merge remote-tracking branch 'origin/dev' into zeropoint95/teams-ux-c…
ZeroPoint95 Oct 7, 2026
1d9fefe
Harden teams deletion, membership compatibility and modal routing
ZeroPoint95 Oct 7, 2026
2351871
Keep deletion validation within repository laws
ZeroPoint95 Oct 7, 2026
d068add
Advertise membership compatibility in hosted launch fixture
ZeroPoint95 Oct 7, 2026
326efd5
Release finishing journals before restart and canonicalize memory fix…
ZeroPoint95 Oct 7, 2026
881a05c
Keep stop-key letters in Teams own-answer input
ZeroPoint95 Oct 7, 2026
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
47 changes: 46 additions & 1 deletion PERF.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,29 @@ 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.

## Teams overview reading bounds

Visible teams’ member previews and latest-message times are read once per conversation
key in the same serial batch, so shared membership does not duplicate journal reads.
Member previews locate the latest human exchange off the UI loop by scanning journal
record boundaries backwards. Unchanged size/mtime reuses the cached preview; remote
journals are never opened on this machine. Reads use a **64 KiB** (`teamsPreviewBytes`)
chunk and at most the first **64 KiB** of each record, including oversized JSON content
strings. Finding the prompt can scan more than 64 KiB of a changed journal, but retained
messages stay bounded to **64 KiB total** and **2 messages** (`teamsPreviewMessages`).
The prompt retains up to one quarter of that byte budget, leaving space for the response's
beginning. Front conversations use their displayed messages with the same retained bound.
Manager cards remain capped at **14 rows** (`teamsManagerRows`), with an **8-row** minimum
(`teamsManagerMinRows`). Interaction readings retain at most **200 entries** (`trafficKeep`),
merged by id without consuming the live delivery cursor. The table paints at most **6 body
rows** (`teamsInteractionRows`), reduced to fit the available pane, with a pinned header
and independent paging. Sorting shares one subtree timestamp pass per frame, including
live correction times. Mouse wheels move physical rows in the pane or sidebar under
the pointer. Paint performs no filesystem or network reads.
`TestTeamsPreviewRetainsBoundedTextAndFollowsUpdates`, the long-record exchange regressions,
the overview navigation/geometry regressions and `TestTheFrameNeverReadsTheDisk` defend
these bounds.

## Context recovery bounds

Conversation request admission sums the existing encoded messages and tool schemas;
Expand Down Expand Up @@ -2407,7 +2430,7 @@ include the complete tab identity and picker availability, not just rendered wor


The navigation panel renders a separate transcript separator and, inside a task,
a separate metadata row. Its tab candidate slice is bounded to 32 entries;
a separate metadata row. Its ordinary All tab candidate slice is bounded to 32 entries; a team overlay draws its complete stored membership without applying that presentation cap. Overlay frames build membership tabs from memory and never open agents or transcripts; the membership picker captures its saved-session snapshot off-loop once when opened.
the recency stack and held agents remain uncapped. Membership walks can still
inspect recency keys when candidates are dismissed or missing, but the renderer
no longer builds an unbounded temporary tab list and compares each entry against
Expand Down Expand Up @@ -2773,6 +2796,15 @@ capture retains at most **4 KiB** while continuing to drain. Regression fixtures
cover both engines, direct runtime roots, aliases, broad globs, subprocess
termination, source worktrees, and growth during a snapshot read.

## Saved conversation cards

The Chats grid reads the switcher's world metadata off-loop at most every
`wallCatalogEvery` (**3 seconds**) and caches canonical conversation keys before painting.
Only visible saved cards read transcript tails, one batch at a time, under the
shared `teamsPreviewBytes` (**64 KiB per transcript**) budget. Transcript pieces
are rendered only for visible cards. Hosted catalogs never read local transcript
paths. No saved conversation is resumed to display or select its card.

## Contextual memory bounds

One ordinary post-turn extraction can add one memory claim. It keeps at most eight
Expand Down Expand Up @@ -2804,3 +2836,16 @@ Exact duplicate settlement streams the active owner partition transactionally: i
cost grows with that partition, rather than losing punctuation or older duplicates
behind a retrieval ceiling. The canonical event journal is not periodically pruned;
projection/context work is bounded, while durable history continues to grow.

## Conversation deletion completion

Permanent deletion joins the task run's completion channel with
`conversationDeletionGrace` (**30 seconds**), and keeps the journal lock on timeout.
The cross-process deletion request uses the same grace while claiming the journal.
Ordinary Close returns promptly but releases its journal only after the cancelled run's
last store close, before signalling completion to a caller that may immediately reopen
the conversation. Clearing the run and signalling completion happen together. Cleanup
receipts are atomically replaced and synced before committing deletion. Startup recovery
walks only the two saved-conversation directory levels, without following symlinked buckets.
`internal/session/deletion_revision_test.go` drives completion and timeout with held
channels and a zero test grace rather than sleeping for the production deadline.
4 changes: 4 additions & 0 deletions cmd/codeaf-demo-home/seed.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ func seedDemoHome(dir string, now time.Time) (builtHome, error) {
built.Messages += 2 * len(talk.turns)
}

if err := writeTeams(root, projects, ids, now); err != nil {
return built, err
}

if err := writePlan(projects, ids); err != nil {
return built, err
}
Expand Down
90 changes: 90 additions & 0 deletions cmd/codeaf-demo-home/seed_teams.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package main

import (
"fmt"
"path/filepath"
"time"

"github.com/Agent-Field/agentfield/sdk/go/ai"
teamstore "github.com/Agent-Field/codeaf/internal/teams"
)

// The demo includes shared membership and enough exchanges to exercise the
// overview's independent scrolling. Waking is disabled in this throwaway data
// so opening a fixture never sends its historical requests to a provider.
func writeTeams(root string, projects map[string]*demoProject, ids map[string]string, now time.Time) error {
quiet, cap := false, 5.0
var members []teamstore.Member
for _, talk := range demoConversations {
if talk.archived {
continue
}
p := projects[talk.project]
file := filepath.Join(p.bucket, ids[talk.title], "transcript.jsonl")
handle := teamstore.DeriveHandle(talk.title)
if len(members) == 0 {
handle = "coordinator"
}
members = append(members, teamstore.Member{Key: file, File: file, Where: p.dir, Word: talk.title, Handle: handle, HandleBy: teamstore.HandleByTyped})
if len(members) == 6 {
break
}
}
primary := teamstore.Team{ID: teamstore.NewID(), Name: "Interface cleanup", Members: members,
Manager: members[0].Key, Made: now.Add(-48 * time.Hour), Settings: teamstore.Settings{Wake: &quiet, CapUSDDay: &cap}}
primary.SetHue(teamstore.HueSpec{Hue: 200})
secondary := teamstore.Team{ID: teamstore.NewID(), Name: "Release notes", Members: []teamstore.Member{members[1], members[3]},
Manager: members[3].Key, Made: now.Add(-24 * time.Hour), Settings: teamstore.Settings{Wake: &quiet}}
secondary.SetHue(teamstore.HueSpec{Hue: 80})
previous := teamstore.Team{ID: teamstore.NewID(), Name: "Previous layout pass", Members: members[:1],
Manager: members[0].Key, Made: now.Add(-96 * time.Hour), Settings: teamstore.Settings{Wake: &quiet}}
file := teamstore.File{Teams: []teamstore.Team{primary, secondary, previous}}
if err := file.Close(previous.ID, now.Add(-72*time.Hour), ""); err != nil {
return err
}
if err := teamstore.Save(root, file.Teams); err != nil {
return err
}
for i := 0; i < 24; i++ {
member := members[1+i%(len(members)-1)]
text := fmt.Sprintf("Check the layout at width %d and report any clipped labels or missing actions.", 80+i*4)
at := now.Add(-time.Duration(24-i) * 3 * time.Minute)
id, err := teamstore.AppendTrafficID(root, primary.ID, teamstore.Entry{Kind: teamstore.KindNote, From: teamstore.FromManager, To: member.Handle, Text: text, At: at})
if err != nil {
return err
}
replyText := "The labels fit. The member preview and interaction links remain reachable at this width."
replyID, err := teamstore.AppendTrafficID(root, primary.ID, teamstore.Entry{Kind: teamstore.KindNote, From: member.Handle, To: teamstore.ToManager, Text: replyText, Answers: id, At: at.Add(time.Minute)})
if err != nil {
return err
}
// These journal entries use the same tool receipts and delivered message
// envelope as live sessions, so every demo interaction link can land.
callID := fmt.Sprintf("demo-team-send-%d", i)
for _, line := range []journalLine{
{Type: "message", Role: "assistant", ToolCalls: []ai.ToolCall{call(callID, "team_send", map[string]any{"team": primary.ID, "to": member.Handle, "text": text})}},
answer(callID, "Sent ("+teamstore.ThreadNumber(id)+")."),
{Type: "message", Role: "user", Content: fmt.Sprintf("Team traffic in %q\nfrom @%s %s: %s", primary.Name, member.Handle, teamstore.ThreadNumber(replyID), replyText)},
} {
if err := appendJSONL(members[0].File, line); err != nil {
return err
}
}
postID := fmt.Sprintf("demo-team-post-%d", i)
for _, line := range []journalLine{
{Type: "message", Role: "user", Content: fmt.Sprintf("Team traffic in %q\n◆ from manager %s: %s", primary.Name, teamstore.ThreadNumber(id), text)},
{Type: "message", Role: "assistant", ToolCalls: []ai.ToolCall{call(postID, "team_post", map[string]any{"text": replyText})}},
answer(postID, "Posted as "+teamstore.ThreadNumber(replyID)+"."),
} {
if err := appendJSONL(member.File, line); err != nil {
return err
}
}
}
_, err := teamstore.Raise(root, teamstore.Packet{Team: teamstore.Person, Origin: primary.ID, RaisedBy: "manager", Kind: teamstore.PacketQuestion,
Question: "Which layout should we review first?", Options: []teamstore.Option{
{ID: "wide", Label: "Wide terminal", Consequence: "Review the full member grid and interaction table."},
{ID: "narrow", Label: "Narrow terminal", Consequence: "Review scrolling and access to every member."},
}})
return err
}
54 changes: 54 additions & 0 deletions cmd/codeaf-demo-home/seed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"github.com/Agent-Field/codeaf/internal/session"
"github.com/Agent-Field/codeaf/internal/standing"
"github.com/Agent-Field/codeaf/internal/store"
teamstore "github.com/Agent-Field/codeaf/internal/teams"
)

func TestTheDemoHomeFillsEveryPlace(t *testing.T) {
Expand Down Expand Up @@ -109,6 +110,59 @@ func TestTheDemoHomeFillsEveryPlace(t *testing.T) {
}
}

// The Teams fixture must resolve every conversation and interaction door,
// while keeping historical notes from waking an agent during a review.
teams, err := teamstore.Load(filepath.Join(dir, ".codeaf"))
if err != nil || len(teams.Teams) != 3 {
t.Fatalf("read the three demo teams: %+v, %v", teams, err)
}
if len(teams.ClosedTeams()) != 1 {
t.Fatal("demo must include one closed team so its category can be reviewed")
}
memberships := map[string]int{}
for _, team := range teams.Teams {
if team.Manager == "" || team.Settings.Wake == nil || *team.Settings.Wake {
t.Fatalf("team has no manager or can wake historical work: %+v", team)
}
for _, member := range team.Members {
if !team.Closed() {
memberships[member.Key]++
}
if _, err := os.Stat(member.File); err != nil {
t.Fatalf("team member has no conversation: %v", err)
}
}
if len(team.Members) == 6 {
traffic, err := teamstore.ReadTraffic(filepath.Join(dir, ".codeaf"), team.ID, "", 200)
if err != nil {
t.Fatal(err)
}
var notes []teamstore.Entry
for _, entry := range traffic {
if entry.Kind == teamstore.KindNote {
notes = append(notes, entry)
}
}
if len(notes) != 48 {
t.Fatalf("demo has %d interaction notes, want 48", len(notes))
}
for i := 1; i < len(notes); i += 2 {
if notes[i].Answers != notes[i-1].ID {
t.Fatalf("demo reply is disconnected: %+v", notes[i])
}
}
}
}
shared := 0
for _, count := range memberships {
if count > 1 {
shared++
}
}
if shared != 2 {
t.Fatalf("demo has %d shared conversations, want two", shared)
}

// ── the tasks place: one running row, and landed work behind it ───────────
index, running, landed := 0, 0, 0
for _, project := range world.Projects {
Expand Down
30 changes: 30 additions & 0 deletions cmd/codeaf/chatv3_host.go
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,15 @@ func hostOptions(fleet *engineFleet, welcome remote.Welcome, pick bool) (tui3.Op
options.SubscribeServiceModels = listing.registerServiceNotice
options.ApplyModelSources = shelf.setSources
}
if welcome.TaskDelete {
options.DeleteTask = func(file, id string) error {
if err := client.DeleteTask(file, id); err != nil {
return err
}
world.fetch()
return nil
}
}
if !fleet.canBeside() {
// A DOOR THAT CANNOT DIAL AGAIN REALLY DOES HOLD ONE CONVERSATION AT A
// TIME, and says so ([tui3.Options.SharedAgent]) rather than letting
Expand Down Expand Up @@ -948,6 +957,27 @@ func hostOptions(fleet *engineFleet, welcome remote.Welcome, pick bool) (tui3.Op
// there is exactly one door and no road on which a swap can still happen.
options.Start = fleet.start
options.Open = fleet.open
if welcome.ConversationDelete {
options.DeleteConversation = func(file string, choices map[string]string, affected map[string][]string) error {
if err := client.DeleteConversation(file, choices, affected); err != nil {
return err
}
world.mu.Lock()
defer world.mu.Unlock()
for i := range world.held.Projects {
rows := world.held.Projects[i].Sessions
kept := rows[:0:0]
for _, row := range rows {
if row.Transcript != file {
kept = append(kept, row)
}
}
world.held.Projects[i].Sessions = kept
}
return nil
}
}

return options, settings
}

Expand Down
39 changes: 31 additions & 8 deletions cmd/codeaf/chatv3_host_teams.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,14 @@ type hostTeams struct {
defaults func() (teamstore.Defaults, error)
// applyDefault writes one `teams.` row ([remote.Welcome.TeamSettings]).
// Nil leaves the settings Teams tab read-only.
applyDefault func(key, raw string) (teamstore.Defaults, error)
packets func(scope, stamp string) (remote.PacketsReading, error)
raise func(p teamstore.Packet) (teamstore.Packet, error)
decide func(id, by, decision, reason string) (teamstore.Packet, error)
escalate func(id, by, to, reason string) (teamstore.Packet, error)
spend func(team, day, stamp string) (remote.SpendReading, error)
deleteOne func(team string) (remote.DeleteTeamReply, error)
applyDefault func(key, raw string) (teamstore.Defaults, error)
packets func(scope, stamp string) (remote.PacketsReading, error)
raise func(p teamstore.Packet) (teamstore.Packet, error)
decide func(id, by, decision, reason string) (teamstore.Packet, error)
escalate func(id, by, to, reason string) (teamstore.Packet, error)
spend func(team, day, stamp string) (remote.SpendReading, error)
deleteOne func(team string) (remote.DeleteTeamReply, error)
deleteChecked func(team string, expected []string) (remote.DeleteTeamReply, error)
// The wrap-up's two doors, nil when the engine does not answer them
// ([remote.Welcome.WrapUp]).
wrapUp func(team, text string) error
Expand Down Expand Up @@ -76,8 +77,13 @@ func newHostTeams(far hostFar, delegation, wrapUp, settings bool) *hostTeams {
}
if delegation {
c := far.client
if c.Welcome().ConversationDelete {
h.deleteChecked = func(team string, expected []string) (remote.DeleteTeamReply, error) {
return c.TeamsDelete(team, expected)
}
}
h.defaults, h.packets, h.raise = c.TeamsDefaults, c.TeamsPackets, c.TeamsRaise
h.decide, h.escalate, h.spend, h.deleteOne = c.TeamsDecide, c.TeamsEscalate, c.TeamsSpend, c.TeamsDelete
h.decide, h.escalate, h.spend, h.deleteOne = c.TeamsDecide, c.TeamsEscalate, c.TeamsSpend, func(team string) (remote.DeleteTeamReply, error) { return c.TeamsDelete(team) }
if wrapUp {
h.wrapUp, h.acceptClosing = c.TeamsWrapUp, c.TeamsAcceptClosing
}
Expand All @@ -96,6 +102,9 @@ func hostTeamsSeam(far hostFar, welcome remote.Welcome) tui3.TeamsSeam {
if far.client == nil || !welcome.Teams {
return tui3.TeamsSeam{}
}
if welcome.TeamMembershipVersion != remote.TeamMembershipVersion {
return tui3.TeamsSeam{}
}
return newHostTeams(far, welcome.Delegation, welcome.WrapUp, welcome.TeamSettings).seam()
}

Expand All @@ -112,6 +121,9 @@ func (h *hostTeams) seam() tui3.TeamsSeam {
}
s.Defaults, s.Raise, s.Decide, s.Escalate = h.defaults, h.raise, h.decide, h.escalate
s.Packets, s.Spend, s.Delete = h.readPackets, h.readSpend, h.forget
if h.deleteChecked != nil {
s.DeleteChecked = h.forgetChecked
}
if h.wrapUp != nil && h.acceptClosing != nil {
s.WrapUp, s.AcceptClosing = h.wrapUp, h.closeOnReport
}
Expand Down Expand Up @@ -275,3 +287,14 @@ func cloneTeams(teams []teamstore.Team) []teamstore.Team {
}
return out
}

func (h *hostTeams) forgetChecked(team string, expected []string) ([]string, error) {
reply, err := h.deleteChecked(team, expected)
if err != nil {
return nil, err
}
if _, _, _, err := h.readSince("", h.reservedHues()); err != nil {
return reply.Gone, err
}
return reply.Gone, nil
}
Loading
Loading