From 3b295b2069efd87f63d5548dcd6c30b612000837 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 15:32:10 -0400 Subject: [PATCH 1/9] teams: a closed team's report opens on a plain local launch A plain `codeaf` reaches its teams through the engine on this machine (localDoors), and that seam never had a History door, so a closed team's pane said its report "is not readable over this connection" on the very machine that holds it. The linked-local road now reads the engine profile's packets for History. Over --host nothing changes: no wire door carries a team's history, and the laptop's own files are never read. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/codeaf/chatv3_host_teams_test.go | 18 +++++++++++ cmd/codeaf/chatv3_local.go | 8 +++++ cmd/codeaf/chatv3_local_test.go | 47 ++++++++++++++++++++++++++++ internal/manual/chat/teams-page.md | 15 +++++---- internal/manual/chat_test.go | 1 + internal/tui3/teamseam.go | 1 + internal/tui3/teamspage_test.go | 32 +++++++++++++++++++ 7 files changed, 116 insertions(+), 6 deletions(-) diff --git a/cmd/codeaf/chatv3_host_teams_test.go b/cmd/codeaf/chatv3_host_teams_test.go index 611318ef3..0bed218f6 100644 --- a/cmd/codeaf/chatv3_host_teams_test.go +++ b/cmd/codeaf/chatv3_host_teams_test.go @@ -221,3 +221,21 @@ func TestTheWrapUpDoorsCrossHostOnlyWhenTheEngineSaysSo(t *testing.T) { t.Fatal("an engine without the wrap-up doors was handed them, or lost the others") } } + +// Contract 6.2: The host wire never lends the laptop a closed team's packet files. +func TestHostTeamsKeepsClosedHistoryOffTheWire(t *testing.T) { + loop, err := remote.Loopback(remote.Hello{Version: remote.Version}, remote.Options{Boot: func(remote.Hello) (*remote.Engine, error) { + return &remote.Engine{Agent: &quietAgent{}, ProfileDir: t.TempDir()}, nil + }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = loop.Close() }) + welcome := loop.Client.Welcome() + if !welcome.Teams || !welcome.Delegation || !welcome.WrapUp { + t.Fatalf("the engine welcome has no complete teams road: %+v", welcome) + } + if seam := hostTeamsSeam(hostFar{client: loop.Client}, welcome); seam.History != nil { + t.Fatal("the host seam offered to read closed history from this laptop") + } +} diff --git a/cmd/codeaf/chatv3_local.go b/cmd/codeaf/chatv3_local.go index 404a97b89..a952c83ed 100644 --- a/cmd/codeaf/chatv3_local.go +++ b/cmd/codeaf/chatv3_local.go @@ -33,6 +33,7 @@ import ( "github.com/Agent-Field/codeaf/internal/remote" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/subharness" + teamstore "github.com/Agent-Field/codeaf/internal/teams" "github.com/Agent-Field/codeaf/internal/tui3" codeupdate "github.com/Agent-Field/codeaf/internal/update" ) @@ -361,6 +362,13 @@ func localDoors(options *tui3.Options, welcome remote.Welcome, settings config.C profileDir = settings.ProfileDir } options.ProfileDir = profileDir + if options.Teams.Load != nil { + // The linked-local engine keeps its profile on this machine, so its closed + // teams' packets can be read here without borrowing the --host wire. + options.Teams.History = func(team string) ([]teamstore.Packet, error) { + return teamstore.Packets(profileDir, team) + } + } options.EngineRoad = true options.ReadCredits = v3LocalCreditReader(settings) options.Connections = v3Connections(v3Connect(profileDir)) diff --git a/cmd/codeaf/chatv3_local_test.go b/cmd/codeaf/chatv3_local_test.go index b93d50ed2..6919a4181 100644 --- a/cmd/codeaf/chatv3_local_test.go +++ b/cmd/codeaf/chatv3_local_test.go @@ -29,9 +29,56 @@ import ( "github.com/Agent-Field/codeaf/internal/modelsource/sourcestub" "github.com/Agent-Field/codeaf/internal/remote" "github.com/Agent-Field/codeaf/internal/session" + teamstore "github.com/Agent-Field/codeaf/internal/teams" "github.com/Agent-Field/codeaf/internal/tui3" ) +// Contract 6.1: A plain launch reads a closed team's report from the engine profile on this machine. +func TestPlainLaunchReadsClosedTeamReportFromEngineProfile(t *testing.T) { + engineProfile := t.TempDir() + surfaceProfile := t.TempDir() + t.Setenv("CODEAF_HOME", surfaceProfile) + t.Setenv("CODEAF_PROFILE_DIR", surfaceProfile) + t.Setenv("HOME", t.TempDir()) + t.Setenv(config.APIKeyEnv, "not-a-real-key") + t.Cleanup(func() { stopPoolErrands(surfaceProfile) }) + const harbor = "0a0a0a0a0a0a" + if err := teamstore.Save(engineProfile, []teamstore.Team{{ID: harbor, Name: "harbor", Manager: "hm", + Members: []teamstore.Member{{Key: "hm", Handle: "boss"}}}}); err != nil { + t.Fatal(err) + } + p, err := teamstore.Raise(engineProfile, teamstore.Packet{Team: teamstore.Person, Origin: harbor, + Kind: teamstore.PacketClosing, RaisedBy: teamstore.FromManager, Question: "close harbor?", + Options: []teamstore.Option{{ID: teamstore.OptionClose, Label: "Close", Consequence: "the team closes"}}, + Report: &teamstore.ClosingReport{Done: "the parser"}}) + if err != nil { + t.Fatal(err) + } + p, err = teamstore.Decide(engineProfile, p.ID, teamstore.Person, teamstore.OptionClose, "") + if err != nil { + t.Fatal(err) + } + if closed, err := teamstore.AcceptClosing(engineProfile, p); err != nil || !closed { + t.Fatalf("close on report: %v, %v", closed, err) + } + welcome := remote.Welcome{Version: remote.Version, Workspace: "/srv/app", ProfileDir: engineProfile, + Teams: true, Delegation: true, WrapUp: true} + fleet := onePipeFleet("", hostedClient(t)) + t.Cleanup(fleet.closeAll) + options, settings := hostOptions(fleet, welcome, false) + if options.Teams.Load == nil { + t.Fatal("the engine did not hand teams to the plain launch") + } + localDoors(&options, welcome, settings) + if options.Teams.History == nil { + t.Fatal("the plain launch has no history door") + } + got, err := options.Teams.History(harbor) + if err != nil || len(got) != 1 || got[0].ID != p.ID || got[0].Report == nil || got[0].Report.Done != "the parser" { + t.Fatalf("closed team's report from engine profile: %+v, %v", got, err) + } +} + // consentSource is an OpenAI-shaped fake whose turn always asks for the same // harmless bash command and then finishes after the tool result comes back. func consentSource(t *testing.T, command string) *httptest.Server { diff --git a/internal/manual/chat/teams-page.md b/internal/manual/chat/teams-page.md index 279960b45..5b48c30bc 100644 --- a/internal/manual/chat/teams-page.md +++ b/internal/manual/chat/teams-page.md @@ -313,11 +313,11 @@ waits under `▸ Closed · N`. Over `--host`, against an engine that does not offer the wrap-up, the card says `Wrap up first is not offered over this connection` and offers `Close now` and `Cancel`. -## Closed teams: reopening and deleting +## Closed teams: reopening, reports and deleting -Open `▸ Closed · N` on the rail and choose a team. The pane shows when it was opened and -closed, its closing report when it closed on one (what was done, what was left, where the files -are, what it spent), and its members, each still a door to its conversation. Two buttons: +Open `▸ Closed · N` on the rail and choose a team. On this machine, the pane shows when it was +opened and closed, its `closing report` when it closed on one (`done`, `left`, `files`, and +`spent`), and its members, each still a door to its conversation. Two buttons: - **`Reopen`** (`r`) opens the team again: its members' tabs come back and its manager is brought in front. A team whose parent is closed too offers **`Reopen harbor too`**, because a @@ -339,13 +339,16 @@ The page says what a team is in one sentence and offers two buttons: **`✦ Orga conversations`**, which suggests teams from the conversations you have open, and **`+ New team`**. `o` and `n` press them. -## Over --host +## Over --host: why a closed team's report is not readable Over `--host` the page shows the teams of the machine the conversations run on: their decisions, their spend and their managers. The **Teams** tab of `/settings` edits that machine's defaults, and each value says `from Settings`. An older engine keeps the tab read only and says `changing them is not available over this connection`. A closed team's -report is not read over the connection yet, and the page says so where the report would be. +report is not read over the connection yet, and the page says +`its closing report is kept where the team ran, and is not readable over this connection` +where the report would be. A plain local launch reads the report from this machine's engine +profile and shows it in the closed team's pane. ## Why the page looks the way it does diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 5f4cc9971..9a6db4039 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -48,6 +48,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"how do I close a team", "teams-page"}, {"the pane said the manager was open in another window", "teams-page"}, {"how do I reopen a closed team", "teams-page"}, + {"why does my closed team say its report is not readable", "teams-page"}, {"where do I change one team's settings", "teams-page"}, {"what does the ? 2 mark on a team mean", "teams-page"}, // Nesting on the teams page (teams-page.md). diff --git a/internal/tui3/teamseam.go b/internal/tui3/teamseam.go index 3d6f51e6f..73a75a797 100644 --- a/internal/tui3/teamseam.go +++ b/internal/tui3/teamseam.go @@ -101,6 +101,7 @@ type TeamsSeam struct { // Two more doors, OPTIONAL: a seam without them is a seam, and the page says // what it cannot show rather than reading this machine's files. Over --host // no wire answers them yet, so cmd/codeaf's hostTeams leaves them nil. + // The linked-local road sets History because its engine profile is here. // History is team's packets, decided ones included, oldest first: the // closed view reads its closing report from it (teamstore.Packets). diff --git a/internal/tui3/teamspage_test.go b/internal/tui3/teamspage_test.go index 9b8a74120..8b8670376 100644 --- a/internal/tui3/teamspage_test.go +++ b/internal/tui3/teamspage_test.go @@ -426,6 +426,38 @@ func TestTeamsClosedFoldReopensWithItsParent(t *testing.T) { } } +// Contract 6.1: A linked local team with a history door draws its closing report in the closed pane. +func TestLinkedLocalClosedTeamDrawsItsReport(t *testing.T) { + a, harbor, _ := teamsPlaceLabIDs(t) + p := teamstore.Packet{ID: "closing-report", Team: teamstore.Person, Origin: harbor, + Kind: teamstore.PacketClosing, Report: &teamstore.ClosingReport{ + Done: "the parser", Left: "the docs", Files: []string{"parser.go"}, SpendUSD: 1.5, + }} + if err := a.teamEdit(func(f *teamstore.File) error { return f.Close(harbor, a.now(), p.ID) }); err != nil { + t.Fatal(err) + } + door := localTeams(a.profileDir, &a.teamsDisk.watch) + door.History = func(team string) ([]teamstore.Packet, error) { + if team == harbor { + return []teamstore.Packet{p}, nil + } + return nil, nil + } + a.teamsDisk.door = door + drive(t, a, runCmd(a.teamsRead(true))...) + drive(t, a, runCmd(a.teamsDo(teamsTargetOf(t, a, teamsActClosedFold, "")))...) + drive(t, a, runCmd(a.teamsSelect(harbor))...) + text := teamsFrameText(a) + for _, want := range []string{"closing report", "done", "the parser", "left", "the docs", "files", "parser.go", "spent", "$1.50"} { + if !strings.Contains(text, want) { + t.Fatalf("the local closed pane lost %q:\n%s", want, text) + } + } + if strings.Contains(text, "not readable over this connection") { + t.Fatalf("the local pane claimed its report could not be read:\n%s", text) + } +} + // ── members ───────────────────────────────────────────────────────────────── // A MEMBER THIS WINDOW DOES NOT HOLD IS RESUMED BEHIND, in its own tab, and From d5982251f0aebe173691e6202a95393ba743f922 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 15:58:44 -0400 Subject: [PATCH 2/9] teams: a cap under a cent is spelled truly, and its raise raises A $0.001 cap read `harbor reached its $0.00 cap today` and offered `Raise to $0`, whose stored ceiling was 0 and lifted nothing; the packet also rounded a sub-cent spend to 0, and a sub-team's share of such a cap came out as no cap at all. The team settings card said `$0.0010 a day` beside it. Every cap figure now goes through one spelling in internal/teams (Money: `$5`, `$5.50`, `$0.001`), the raise is always twice the ceiling and strictly above it (RaiseTo), and measured spend keeps sub-cent precision (RoundMoney). Whole and cent caps keep their words; the card now says `$5` where it said `$5.00`, as the packet does. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../manual/chat/team-questions-and-caps.md | 7 ++ internal/manual/chat_test.go | 1 + internal/session/team_cap.go | 22 ++-- internal/session/team_cap_precision_test.go | 75 +++++++++++++ internal/session/team_wrapup.go | 2 +- internal/teams/money.go | 105 ++++++++++++++++++ internal/teams/money_test.go | 23 ++++ internal/teams/teamsettings.go | 5 +- internal/tui3/teammove.go | 13 +-- internal/tui3/teamsheet.go | 2 +- internal/tui3/teamspage_test.go | 45 +++++++- internal/tui3/teamspagedraw.go | 6 +- 12 files changed, 278 insertions(+), 28 deletions(-) create mode 100644 internal/session/team_cap_precision_test.go create mode 100644 internal/teams/money.go create mode 100644 internal/teams/money_test.go diff --git a/internal/manual/chat/team-questions-and-caps.md b/internal/manual/chat/team-questions-and-caps.md index fdc2607ce..a191f144d 100644 --- a/internal/manual/chat/team-questions-and-caps.md +++ b/internal/manual/chat/team-questions-and-caps.md @@ -66,6 +66,13 @@ When the pool reaches its cap: A manager can never raise a cap: money is yours. Every held wake is one line in the traffic, `held @web: harbor reached its $5 cap today`. +## Can a team cap be less than a cent + +Yes. A cap is spelled as you set it everywhere it appears, on the card, the teams page and the +team's settings: `$5`, `$5.50`, and under a cent `$0.001`, never rounded to `$0.00`. **Raise +to** always offers twice the ceiling the team reached, and names exactly that figure: a +`$0.001` cap offers `Raise to $0.002`. + ## Two windows ask once when a team reaches its cap You are asked once for that team, that day, and that ceiling. A second codeaf window, or a diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 9a6db4039..09aabc1ca 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -49,6 +49,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"the pane said the manager was open in another window", "teams-page"}, {"how do I reopen a closed team", "teams-page"}, {"why does my closed team say its report is not readable", "teams-page"}, + {"can a team cap be less than a cent", "team-questions-and-caps"}, {"where do I change one team's settings", "teams-page"}, {"what does the ? 2 mark on a team mean", "teams-page"}, // Nesting on the teams page (teams-page.md). diff --git a/internal/session/team_cap.go b/internal/session/team_cap.go index efd144211..e2b61e529 100644 --- a/internal/session/team_cap.go +++ b/internal/session/team_cap.go @@ -37,7 +37,6 @@ package session import ( "fmt" - "math" "sync" "github.com/Agent-Field/codeaf/internal/teams" @@ -151,7 +150,7 @@ func (a *Agent) poolHold(profile string, owner teams.Team, cap float64) string { return "" } held := fmt.Sprintf("%s reached its %s cap today (spent %s); the person has been asked whether to raise it, and nothing new starts until they answer", - owner.Name, teamMoney(ceiling), teamMoney(spent)) + owner.Name, teamMoney(ceiling), teamSpendMoney(spent)) if found && (latest.Waiting() || latest.Cap.CapUSD >= ceiling) { if !latest.Waiting() { held = fmt.Sprintf("%s reached its %s cap today and the person chose to stop it for today", owner.Name, teamMoney(ceiling)) @@ -184,7 +183,7 @@ func latestCapPacket(profile, owner, day string) (teams.Packet, bool) { // capPacket is the packet a pool at its ceiling raises to the person. func capPacket(owner teams.Team, day string, ceiling, spent float64) teams.Packet { - raiseTo := math.Round(ceiling*2*100) / 100 + raiseTo := teams.RaiseTo(ceiling) return teams.Packet{ Team: teams.Person, Origin: owner.ID, Kind: teams.PacketCap, RaisedBy: teams.FromSystem, Question: fmt.Sprintf("%s reached its %s cap today", owner.Name, teamMoney(ceiling)), @@ -196,14 +195,15 @@ func capPacket(owner teams.Team, day string, ceiling, spent float64) teams.Packe }, Recommendation: &teams.Recommendation{Option: teams.OptionStopToday, Reason: "the cap is the limit you set; raise it only if today's work is worth more to you"}, - Cap: &teams.CapFacts{Team: owner.ID, Day: day, CapUSD: ceiling, SpentUSD: math.Round(spent*100) / 100, RaiseTo: raiseTo}, + Cap: &teams.CapFacts{Team: owner.ID, Day: day, CapUSD: ceiling, SpentUSD: teams.RoundMoney(spent), RaiseTo: raiseTo}, } } -// teamMoney is dollars as the person reads them: $5, $5.50. -func teamMoney(usd float64) string { - if usd == math.Trunc(usd) { - return fmt.Sprintf("$%.0f", usd) - } - return fmt.Sprintf("$%.2f", usd) -} +// teamMoney is dollars as the person reads them: $5, $5.50, $0.001. It is +// teams' one spelling of a cap, so the refusal here, the packet and every +// screen that draws the same cap say the same figure ([teams.Money]). +func teamMoney(usd float64) string { return teams.Money(usd) } + +// teamSpendMoney is a MEASURED spend as the person reads it: kept to the cent, +// or finer under a cent ([teams.RoundMoney]), then spelled as a cap is. +func teamSpendMoney(usd float64) string { return teams.Money(teams.RoundMoney(usd)) } diff --git a/internal/session/team_cap_precision_test.go b/internal/session/team_cap_precision_test.go new file mode 100644 index 000000000..6ed30cb6b --- /dev/null +++ b/internal/session/team_cap_precision_test.go @@ -0,0 +1,75 @@ +package session + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/teams" +) + +// Contract 4.1 and 4.2: Every raise names a larger cap and keeps positive sub-cent spend. +func TestTeamCapPacketKeepsPreciseFigures(t *testing.T) { + for _, tc := range []struct { + cap, raise float64 + capWord, raiseWord string + }{ + {0.00001, 0.00002, "$0.00001", "$0.00002"}, + {0.0004, 0.0008, "$0.0004", "$0.0008"}, + {0.001, 0.002, "$0.001", "$0.002"}, + {0.004, 0.008, "$0.004", "$0.008"}, + {0.005, 0.01, "$0.005", "$0.01"}, + {0.009, 0.018, "$0.009", "$0.018"}, + {0.01, 0.02, "$0.01", "$0.02"}, + {0.015, 0.03, "$0.015", "$0.03"}, + {1, 2, "$1", "$2"}, + {5, 10, "$5", "$10"}, + {7.5, 15, "$7.50", "$15"}, + {1234.5, 2469, "$1,234.50", "$2,469"}, + } { + p := capPacket(teams.Team{ID: "aaaaaaaaaaaa", Name: "harbor"}, "2026-09-25", tc.cap, tc.cap) + if p.Cap == nil || p.Cap.RaiseTo <= tc.cap || p.Cap.RaiseTo != tc.raise || p.Cap.SpentUSD <= 0 { + t.Errorf("cap %.8f: facts %+v", tc.cap, p.Cap) + continue + } + if want := "harbor reached its " + tc.capWord + " cap today"; p.Question != want { + t.Errorf("cap %.8f: question %q, want %q", tc.cap, p.Question, want) + } + if want := "Raise to " + tc.raiseWord; p.Options[0].Label != want { + t.Errorf("cap %.8f: label %q, want %q", tc.cap, p.Options[0].Label, want) + } + if want := "harbor and its sub-teams go on until " + tc.raiseWord + " today"; p.Options[0].Consequence != want { + t.Errorf("cap %.8f: consequence %q, want %q", tc.cap, p.Options[0].Consequence, want) + } + } +} + +// Contract 4.2 and 4.3: A decided sub-cent raise admits new work until its new ceiling. +func TestTeamSubCentRaiseAdmitsWorkUntilNewCeiling(t *testing.T) { + fixture := newTeamFixture(t, true) + cap := 0.001 + if err := teams.Update(fixture.profile, func(f *teams.File) error { + return f.SetSettings(fixture.teamID, func(s *teams.Settings) { s.CapUSDDay = &cap }) + }); err != nil { + t.Fatal(err) + } + spend := &capSpend{usd: cap, stamp: "one"} + stubCapSpend(t, spend) + manager := teamAgent(t, fixture, fixture.manager, nil, nil) + manager.teamBoundary() + roles := manager.teamRoles() + if held := manager.teamCapHold(fixture.profile, roles); !strings.Contains(held, "$0.001 cap") || !strings.Contains(held, "spent $0.001") { + t.Fatalf("at the first ceiling: %q", held) + } + p := onlyPacket(t, fixture.profile, teams.Person) + if _, err := teams.Decide(fixture.profile, p.ID, teams.Person, teams.OptionRaiseCap, ""); err != nil { + t.Fatal(err) + } + spend.usd, spend.stamp = 0.0015, "two" + if held := manager.teamCapHold(fixture.profile, roles); held != "" { + t.Fatalf("below the raised ceiling: %q", held) + } + spend.usd, spend.stamp = 0.002, "three" + if held := manager.teamCapHold(fixture.profile, roles); !strings.Contains(held, "$0.002 cap") { + t.Fatalf("at the raised ceiling: %q", held) + } +} diff --git a/internal/session/team_wrapup.go b/internal/session/team_wrapup.go index 4ef73adee..008e4a1dd 100644 --- a/internal/session/team_wrapup.go +++ b/internal/session/team_wrapup.go @@ -230,7 +230,7 @@ func (a *Agent) teamWrapUpDue(profile string, now time.Time) { case now.Sub(w.started) >= wrapBound(w): why = fmt.Sprintf("the wrap-up ran out of time (%s) before the manager brought its report", wrapBound(w).Round(time.Minute)) case spent-w.spentAt >= wrapUpSpendUSD: - why = fmt.Sprintf("the wrap-up spent %s, its limit, before the manager brought its report", teamMoney(spent-w.spentAt)) + why = fmt.Sprintf("the wrap-up spent %s, its limit, before the manager brought its report", teamSpendMoney(spent-w.spentAt)) default: continue } diff --git a/internal/teams/money.go b/internal/teams/money.go new file mode 100644 index 000000000..41bf54219 --- /dev/null +++ b/internal/teams/money.go @@ -0,0 +1,105 @@ +package teams + +import ( + "math" + "strconv" + "strings" +) + +// ── A CAP'S MONEY, SPELLED ONE WAY EVERYWHERE ─────────────────────────────── +// +// A team's cap is a figure a person chose, and it is said in four places: the +// session's refusal and cap packet (`harbor reached its $5 cap today`, `Raise +// to $10`), the teams page's header, the packet's card, and the team's settings +// card. They used to spell it three ways, and below a cent two of them lied: +// `$0.001` read `$0.00` in the packet and `Raise to $0` offered a ceiling that +// lifted nothing. So the spelling and the rounding live here, once, and every +// one of those places calls them. + +// moneyMicro is the finest step a figure under a cent keeps: a millionth of a +// dollar, which holds every cap a person can sensibly type and drops the noise +// a float sum leaves in the last digits. +const moneyMicro = 1e6 + +// RoundMoney is a MEASURED figure (a pool's spend, a share of a cap) rounded +// the way it is kept and said: to the cent from a cent up, and to a millionth +// of a dollar below it, so a positive figure under a cent is never rounded away +// to nothing. +func RoundMoney(usd float64) float64 { + if usd >= 0.01 || usd <= 0 { + return math.Round(usd*100) / 100 + } + if r := microRound(usd); r > 0 { + return r + } + return usd +} + +// microRound is usd to the millionth of a dollar, which drops the noise a +// float sum leaves in its last digits and keeps every figure a person types. +func microRound(usd float64) float64 { return math.Round(usd*moneyMicro) / moneyMicro } + +// Money is a figure as a person reads it, and it is the figure itself: `$5` +// for whole dollars, `$5.50` for whole cents, `$1,234.50` in the thousands, +// and otherwise the shortest spelling that is still the number, `$0.001`, +// `$0.015`. A positive figure is never spelled `$0.00`. A measured spend goes +// through [RoundMoney] first; a cap is spelled as it was chosen. +func Money(usd float64) string { + r := microRound(usd) + if r == 0 && usd > 0 { + r = usd + } + cents := math.Round(r * 100) + var plain string + switch { + case math.Abs(cents-r*100) > 1e-6: + plain = strconv.FormatFloat(r, 'f', -1, 64) + case math.Mod(cents, 100) == 0: + plain = strconv.FormatFloat(cents/100, 'f', 0, 64) + default: + plain = strconv.FormatFloat(cents/100, 'f', 2, 64) + } + whole, frac, _ := strings.Cut(plain, ".") + if frac != "" { + frac = "." + frac + } + return "$" + groupThousands(whole) + frac +} + +// RaiseTo is the ceiling a cap packet offers for a pool at ceiling: twice it, +// to the millionth, and ALWAYS larger than the ceiling it raises, because a +// raise to the same figure (a sub-cent cap rounded to the cent was `Raise to +// $0`) is a button that lifts nothing. +func RaiseTo(ceiling float64) float64 { + if r := microRound(ceiling * 2); r > ceiling { + return r + } + return ceiling * 2 +} + +// groupThousands puts a comma between each group of three digits. +func groupThousands(digits string) string { + neg := strings.HasPrefix(digits, "-") + digits = strings.TrimPrefix(digits, "-") + if len(digits) <= 3 { + if neg { + return "-" + digits + } + return digits + } + var b strings.Builder + if neg { + b.WriteByte('-') + } + head := len(digits) % 3 + if head > 0 { + b.WriteString(digits[:head]) + } + for i := head; i < len(digits); i += 3 { + if b.Len() > 0 && !(neg && b.Len() == 1) { + b.WriteByte(',') + } + b.WriteString(digits[i : i+3]) + } + return b.String() +} diff --git a/internal/teams/money_test.go b/internal/teams/money_test.go new file mode 100644 index 000000000..c67a26f1f --- /dev/null +++ b/internal/teams/money_test.go @@ -0,0 +1,23 @@ +package teams + +import "testing" + +// Contract 4.2 and 4.3: A sub-team share keeps a positive sub-cent cap. +func TestSubTeamShareKeepsSubCentCap(t *testing.T) { + f := tree() + for _, tc := range []struct{ cap, share, want float64 }{ + {0.001, 0.5, 0.0005}, + {0.009, 0.5, 0.0045}, + {5, 0.5, 2.5}, + } { + if err := f.SetSettings("aaaaaaaaaaaa", func(s *Settings) { + s.CapUSDDay = &tc.cap + s.SubShare = &tc.share + }); err != nil { + t.Fatal(err) + } + if got := f.SubTeamCap("aaaaaaaaaaaa", defaults); got != tc.want { + t.Errorf("cap %v share %v: sub-team got %v, want %v", tc.cap, tc.share, got, tc.want) + } + } +} diff --git a/internal/teams/teamsettings.go b/internal/teams/teamsettings.go index 1ce77917d..3457f867e 100644 --- a/internal/teams/teamsettings.go +++ b/internal/teams/teamsettings.go @@ -283,7 +283,8 @@ func (f *File) CanNest(parent string, d Defaults) bool { } // SubTeamCap is the cap a new sub-team under parent is made with: the -// parent's effective cap times its effective share, rounded to the cent. A +// parent's effective cap times its effective share, rounded by [RoundMoney] +// (to the cent, or finer under a cent, so a sub-cent share is not zero). A // parent with no cap gives none (0), and the sub-team then shares whatever // pool is above it. The caller writes the answer on the new team // ([File.SetSettings]), so a later change to the share moves no team that @@ -293,5 +294,5 @@ func (f *File) SubTeamCap(parent string, d Defaults) float64 { if e.CapUSDDay <= 0 { return 0 } - return math.Round(e.CapUSDDay*e.SubShare*100) / 100 + return RoundMoney(e.CapUSDDay * e.SubShare) } diff --git a/internal/tui3/teammove.go b/internal/tui3/teammove.go index 22f967322..26792a847 100644 --- a/internal/tui3/teammove.go +++ b/internal/tui3/teammove.go @@ -807,11 +807,8 @@ func (a *app) teamsCanNest(parent string) (bool, string) { } // teamsMoney is a cap as a sentence says it: `$3` for whole dollars, `$2.50` -// otherwise. A cap is a round figure a person chose, and `$3.00/day` reads as -// a bill. -func teamsMoney(usd float64) string { - if usd >= 1 && usd == float64(int64(usd)) { - return "$" + itoa(int(usd)) - } - return dollars(usd) -} +// otherwise, `$0.001` under a cent. A cap is a round figure a person chose, and +// `$3.00/day` reads as a bill. It is teams' own spelling ([teamstore.Money]), +// the one the session's cap packet and refusal use, so the screen and the +// packet never disagree about the same figure. +func teamsMoney(usd float64) string { return teamstore.Money(usd) } diff --git a/internal/tui3/teamsheet.go b/internal/tui3/teamsheet.go index ba4926647..935654848 100644 --- a/internal/tui3/teamsheet.go +++ b/internal/tui3/teamsheet.go @@ -187,7 +187,7 @@ func (a *app) teamSheetRows(t team) []teamSheetRow { } cap := "no cap" if e.CapUSDDay > 0 { - cap = dollars(e.CapUSDDay) + " a day" + cap = teamsMoney(e.CapUSDDay) + " a day" } depth := itoa(e.DepthLimit) + " levels" if e.DepthLimit == 1 { diff --git a/internal/tui3/teamspage_test.go b/internal/tui3/teamspage_test.go index 8b8670376..eb48f2c1a 100644 --- a/internal/tui3/teamspage_test.go +++ b/internal/tui3/teamspage_test.go @@ -7,6 +7,7 @@ import ( tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/config" teamstore "github.com/Agent-Field/codeaf/internal/teams" ) @@ -310,7 +311,7 @@ func TestTeamsCardShowsProvenanceAndResets(t *testing.T) { t.Fatal("the card did not open") } text := teamsFrameText(a) - for _, want := range []string{"from Settings", "from harbor", "$5.00 a day"} { + for _, want := range []string{"from Settings", "from harbor", "$5 a day"} { if !strings.Contains(text, want) { t.Fatalf("the card lost %q:\n%s", want, text) } @@ -458,6 +459,46 @@ func TestLinkedLocalClosedTeamDrawsItsReport(t *testing.T) { } } +// Contract 4.3: The header, packet card, team card and Settings row agree on a sub-cent cap. +func TestTeamSurfacesSpellSubCentCapTheSameWay(t *testing.T) { + a, harbor, _ := teamsPlaceLabIDs(t) + cap := 0.001 + if err := a.teamEdit(func(f *teamstore.File) error { + return f.SetSettings(harbor, func(s *teamstore.Settings) { s.CapUSDDay = &cap }) + }); err != nil { + t.Fatal(err) + } + a.tp.defaultsOK = true + a.tp.spend = map[string]teamstore.Spend{harbor: {USD: cap}} + team, ok := a.teamByID(harbor) + if !ok { + t.Fatal("no team") + } + if words := a.teamsSpendWords(team); !strings.Contains(words, "$0.001 of $0.001 today") { + t.Fatalf("header cap: %q", words) + } + p := teamstore.Packet{ID: "cap", Team: teamstore.Person, Origin: harbor, Kind: teamstore.PacketCap, + Question: "harbor reached its $0.001 cap today", Cap: &teamstore.CapFacts{Team: harbor, CapUSD: cap, SpentUSD: cap}, + Options: []teamstore.Option{{ID: teamstore.OptionRaiseCap, Label: "Raise to $0.002", Consequence: "continue"}}} + card := plain(strings.Join(a.teamsCard(&teamsDraw{a: a}, p, 80, 0), "\n")) + if !strings.Contains(card, "spent $0.001 of $0.001 today") { + t.Fatalf("cap packet card: %s", card) + } + var settingsCard string + for _, row := range a.teamSheetRows(team) { + if row.code == tsCap { + settingsCard = row.value + } + } + if settingsCard != "$0.001 a day" { + t.Fatalf("team settings card: %q", settingsCard) + } + a.sheet.farTeams = &teamstore.Defaults{CapUSDDay: cap} + if settings, ok := a.sheet.farTeamValue(config.KeyTeamsCapUSDDay); !ok || settings != "$0.001" { + t.Fatalf("Settings Teams row: %q, %v", settings, ok) + } +} + // ── members ───────────────────────────────────────────────────────────────── // A MEMBER THIS WINDOW DOES NOT HOLD IS RESUMED BEHIND, in its own tab, and @@ -661,7 +702,7 @@ func TestTeamsCapPacketSaysItsFiguresAndRaisingWritesNoSetting(t *testing.T) { } drive(t, a, runCmd(a.teamsRead(false))...) text := teamsFrameText(a) - for _, want := range []string{"spent $5.20 of $5.00 today", "Raise to $10", "Stop for today"} { + for _, want := range []string{"spent $5.20 of $5 today", "Raise to $10", "Stop for today"} { if !strings.Contains(text, want) { t.Fatalf("the cap card lost %q:\n%s", want, text) } diff --git a/internal/tui3/teamspagedraw.go b/internal/tui3/teamspagedraw.go index aa4514a65..0a1ee9c43 100644 --- a/internal/tui3/teamspagedraw.go +++ b/internal/tui3/teamspagedraw.go @@ -409,9 +409,9 @@ func (a *app) teamsSpendWords(t team) string { if s.USD <= 0 { return "" } - return dollars(s.USD) + " today" + return teamsMoney(teamstore.RoundMoney(s.USD)) + " today" } - words := dollars(s.USD) + " of " + teamsMoney(e.CapUSDDay) + " today" + words := teamsMoney(teamstore.RoundMoney(s.USD)) + " of " + teamsMoney(e.CapUSDDay) + " today" if owner != t.ID { if o, ok := a.teamByID(owner); ok { name := o.Name @@ -744,7 +744,7 @@ func (a *app) teamsCard(d *teamsDraw, p teamstore.Packet, width, y int) []string if t, ok := a.teamByID(c.Team); ok { pool = t.Name } - said := "spent " + dollars(c.SpentUSD) + " of " + dollars(c.CapUSD) + " today " + a.teamsDot() + " " + pool + "'s cap" + said := "spent " + teamsMoney(c.SpentUSD) + " of " + teamsMoney(c.CapUSD) + " today " + a.teamsDot() + " " + pool + "'s cap" out = append(out, " "+pal.dim(fit(said, max(width-4, 8)))) } if r := p.Report; r != nil { From c568ce99dc06347c1440c544a934c3e98ca28a21 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 16:10:27 -0400 Subject: [PATCH 3/9] tui3: a team edit is said done only once the store took it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Made beta · 1` was drawn at the keystroke, so a write the store refused (a lock another codeaf held, a file that would not read, a far machine whose teams kept changing) still said the team was made, and the refusal went into a note in the conversation behind the wall. Each team edit now carries a number, the write reports which edits it took and which it refused and why, and every notice that says an edit happened waits for its own edit's answer: the wall's `Made`, Organize's `Organized`, and the teams page's close and move words. A refusal takes the notice's place in the warning ink with the store's reason; the edit stays in the window and Undo is still offered. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../manual/chat/conversations-and-teams.md | 10 ++ internal/manual/chat_test.go | 1 + internal/tui3/teamclose.go | 9 +- internal/tui3/teammove.go | 14 +- internal/tui3/teamorganize.go | 18 +- internal/tui3/teamorganize_test.go | 2 + internal/tui3/teams.go | 2 + internal/tui3/teamseam.go | 37 ++++- internal/tui3/teamsheet.go | 6 +- internal/tui3/teamspagehost.go | 3 + internal/tui3/teamwritesaid.go | 100 ++++++++++++ internal/tui3/teamwritesaid_test.go | 154 ++++++++++++++++++ internal/tui3/wall.go | 6 +- internal/tui3/wallbar.go | 16 +- internal/tui3/wallclick_test.go | 2 + internal/tui3/wallcontract.go | 5 + 16 files changed, 365 insertions(+), 20 deletions(-) create mode 100644 internal/tui3/teamwritesaid.go create mode 100644 internal/tui3/teamwritesaid_test.go diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index b6060ab31..85230e3a5 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -336,6 +336,16 @@ names, members and colours, and it is renamed to `spaces.json.migrated`. A teams cannot be read is moved aside as `teams.json.unreadable-` rather than written over, so nothing you made is lost. +## The wall said my team was not saved + +A change to your teams shows in this window at once and is saved a moment later. What says +it happened waits for the save: `Made harbor · 2` on the Teams row, `Organized · 1 new team`, +and on the teams page `harbor is closed` or a move's words. When the save is refused (another +codeaf holding the file, a file that would not read, a far machine whose teams kept changing), +those words never show. The row says `harbor was not saved · ` instead, and the +teams page says `the close of harbor was not saved` or `the move was not saved`. The change is +still in this window, and Undo is still offered where it was. + ## Every key in the conversations view `?` (or `Help ?`) opens a sheet of all of these, and every row on it is a button that does diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 09aabc1ca..573b01359 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -50,6 +50,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"how do I reopen a closed team", "teams-page"}, {"why does my closed team say its report is not readable", "teams-page"}, {"can a team cap be less than a cent", "team-questions-and-caps"}, + {"the wall said my team was not saved", "conversations-and-teams"}, {"where do I change one team's settings", "teams-page"}, {"what does the ? 2 mark on a team mean", "teams-page"}, // Nesting on the teams page (teams-page.md). diff --git a/internal/tui3/teamclose.go b/internal/tui3/teamclose.go index 58ee63b2f..261c341cd 100644 --- a/internal/tui3/teamclose.go +++ b/internal/tui3/teamclose.go @@ -42,6 +42,9 @@ type teamsUndo struct { name string shut []string at time.Time + // said ties `harbor is closed` to the write that carried the close + // (teamwritesaid.go); a close the report's own door made is said already. + said teamWriteSaid } // teamsCloseKeys is every conversation a close of team id stops and whose tab @@ -149,7 +152,9 @@ func (a *app) teamsCloseNow(id, report string) tea.Cmd { a.touch() return nil } - return a.teamsAfterClose(t, shut, now, true) + cmd := a.teamsAfterClose(t, shut, now, true) + a.tp.undo.said = a.teamWriteWatch(nil) + return cmd } // teamsStopMembers is the interface's half of every close (DESIGN.md 8.5): @@ -209,7 +214,7 @@ func (a *app) teamsAfterClose(t team, shut []string, now time.Time, tell bool) t // teamsUndoing reports whether Undo is still offered for the last close. func (a *app) teamsUndoing() bool { u := a.tp.undo - return u.team != "" && a.now().Sub(u.at) < teamsUndoFor + return u.team != "" && u.said.said() && a.now().Sub(u.at) < teamsUndoFor } // teamsUndoClose takes the last close back: the team reopened, and the tabs it diff --git a/internal/tui3/teammove.go b/internal/tui3/teammove.go index 26792a847..acdac55a1 100644 --- a/internal/tui3/teammove.go +++ b/internal/tui3/teammove.go @@ -100,6 +100,10 @@ type teamMoveUndo struct { word string from int at time.Time + // said ties the move's words to the write that carried it + // (teamwritesaid.go): Undo and the words wait for it, and a refusal + // replaces the words. + said teamWriteSaid } // teamMoveRow is one row of the picker: a parent (teamMoveTop for the top @@ -635,7 +639,7 @@ func (a *app) teamMoveApply(ids []string, parent string, from int) tea.Cmd { return nil } a.tmove.pend = teamMovePend{} - a.tmove.undo = teamMoveUndo{back: back, homes: homes, word: word, from: from, at: a.now()} + a.tmove.undo = teamMoveUndo{back: back, homes: homes, word: word, from: from, at: a.now(), said: a.teamWriteWatch(nil)} a.tp.msg = "" a.tp.top = teamsTopCache{} if a.tp.cur.act == teamsActMoveYes || a.tp.cur.act == teamsActMoveNo { @@ -690,7 +694,7 @@ func (a *app) teamMoveConfirm() tea.Cmd { // teamMoveUndoing reports whether Undo is offered for the last move. func (a *app) teamMoveUndoing() bool { u := a.tmove.undo - return len(u.back) > 0 && a.now().Sub(u.at) < teamsUndoFor + return len(u.back) > 0 && u.said.said() && a.now().Sub(u.at) < teamsUndoFor } // teamMoveUndo puts the last move back: every moved team under its parent @@ -778,7 +782,11 @@ func (a *app) teamsMoveRows(d *teamsDraw, width, y int) []string { return out } if a.teamMoveUndoing() && a.tmove.undo.from == teamMoveFromPage { - word := " " + pal.dim(a.tmove.undo.word) + " " + ink := pal.dim + if a.tmove.undo.said.why != "" { + ink = pal.warn + } + word := " " + ink(a.tmove.undo.word) + " " s, _ := d.button("Undo", teamsTarget{act: teamsActUndo, x0: ansi.StringWidth(word), y: y, hint: "Put it back where it was" + hintSegment + "u"}, pal.ink) return []string{word + s} diff --git a/internal/tui3/teamorganize.go b/internal/tui3/teamorganize.go index 5d002a861..4eeef2bd8 100644 --- a/internal/tui3/teamorganize.go +++ b/internal/tui3/teamorganize.go @@ -85,6 +85,10 @@ type wallOrganize struct { // and added what the Teams row says while Undo is offered. undo []team doneAt time.Time + // said ties `Organized` to the write that carried the Apply + // (teamwritesaid.go): nothing is said until it is back, and a refusal is + // said in its place. + said teamWriteSaid // undoMade and undoJoins are what the last Apply did, the teams it made by // id and the members it added, so Undo can take exactly those back. undoMade []string @@ -718,6 +722,7 @@ func (a *app) wallOrganizeApply() { if err != nil { a.note("the teams are kept for this window, but " + err.Error()) } + o.said = a.teamWriteWatch(err) for _, id := range closes { if a.wall.activeID == id { a.wall.activeID = "" @@ -890,7 +895,18 @@ func wallOrgMarks(pal palette) (spark, check string) { // has changed. It is drawn nowhere else. func wallOrganizeButton(pal palette, g wallGlyphs, v wallView, y int) wallOrgPiece { o := v.org - if !o.doneAt.IsZero() && v.now.Sub(o.doneAt) < wallOrganizedFor && v.now.Sub(o.doneAt) >= 0 { + if o.said.said() && o.said.why != "" && teamSaidWithin(o.doneAt, v.now, wallOrganizedFor) { + // A REFUSED APPLY SAYS SO where `Organized` would have been, and keeps + // its Undo: the teams it made are still in this window, and taking them + // back is what a person who reads this may want. + word := teamNotSaved("", o.said.why) + " " + undo := wallButton{act: wallActOrgUndo, label: "Undo"} + hot := v.hover == wallHitRef{kind: wallHitAction, arg: int(wallActOrgUndo)} + ww, bw := ansi.StringWidth(word), wallButtonW(undo) + return wallOrgPiece{s: pal.warn(word) + wallButtonPaint(pal, undo, hot), w: ww + bw, + hits: []wallHit{{x0: ww, y0: y, x1: ww + bw, y1: y + 1, kind: wallHitAction, arg: int(wallActOrgUndo)}}} + } + if o.said.said() && teamSaidWithin(o.doneAt, v.now, wallOrganizedFor) { var said []string if o.made > 0 { said = append(said, strconv.Itoa(o.made)+" new "+wallPlural(o.made, "team")) diff --git a/internal/tui3/teamorganize_test.go b/internal/tui3/teamorganize_test.go index 29eddb4c6..182df14e8 100644 --- a/internal/tui3/teamorganize_test.go +++ b/internal/tui3/teamorganize_test.go @@ -216,6 +216,8 @@ func TestOrganizeApplyThenUndoRestoresTheExactList(t *testing.T) { if hue := a.wall.teams[1].HueSpec(); hue == a.wall.teams[0].HueSpec() { t.Fatal("the new team took an existing team's colour") } + // `Organized` is said once the store took the Apply (teamwritesaid.go). + teamsFlush(t, a) frame := orgFrame(a) if !strings.Contains(frame, "Organized · 1 new team ") || !strings.Contains(frame, " Undo ") { t.Fatalf("the Teams row does not offer Undo:\n%s", frame) diff --git a/internal/tui3/teams.go b/internal/tui3/teams.go index 14ba7f8f7..02709ff2c 100644 --- a/internal/tui3/teams.go +++ b/internal/tui3/teams.go @@ -350,7 +350,9 @@ func (a *app) teamEdit(change func(f *teamstore.File) error) error { // A Traffic read already out may carry the file from before this write; // counting the edit keeps it from being put back (teamtraffic.go). a.traffic.edits++ + a.teamsDisk.seq++ a.teamsDisk.queue = append(a.teamsDisk.queue, change) + a.teamsDisk.queueSeq = append(a.teamsDisk.queueSeq, a.teamsDisk.seq) return nil } diff --git a/internal/tui3/teamseam.go b/internal/tui3/teamseam.go index 73a75a797..613478b4f 100644 --- a/internal/tui3/teamseam.go +++ b/internal/tui3/teamseam.go @@ -240,8 +240,13 @@ type teamsDisk struct { // watch is the local seam's stat-before-read memory of the logs. watch teamstore.Watch // queue is every edit made to what this window holds and not yet handed - // to the store, in the order they were made. - queue []func(*teamstore.File) error + // to the store, in the order they were made, and queueSeq each one's + // number: seq counts every edit this window has made, so a notice that + // says an edit happened can ask whether the store took THAT edit + // ([app.teamsWriteSettled]). + queue []func(*teamstore.File) error + queueSeq []int + seq int // fetch says an opening found nothing held ([TeamsSeam.Load]'s known // false) and a read is wanted; fetching says it is out. fetch, fetching bool @@ -323,6 +328,11 @@ type teamsWrote struct { // was the opening's read rather than a write. covers int read bool + // seqs is the number of every edit this write carried, and refused the + // ones the store did not take, with why: an edit the file refused alone, + // or every edit when the whole write was refused. + seqs []int + refused map[int]error } // teamsWrite hands the queued edits to the store in one command on the door @@ -350,19 +360,23 @@ func (a *app) teamsWrite() tea.Cmd { })) } if len(a.teamsDisk.queue) > 0 { - changes := a.teamsDisk.queue - a.teamsDisk.queue = nil + changes, seqs := a.teamsDisk.queue, a.teamsDisk.queueSeq + a.teamsDisk.queue, a.teamsDisk.queueSeq = nil, nil seam, words, reserved, covers := a.teamsSeam(), a.teamTabWords(), teamReservedHues(a.pal), a.traffic.edits cmds = append(cmds, a.offLoop(func() func(bool) tea.Cmd { var refused error + var refusedBy map[int]error teams, stamp, err := seam.Update(func(f *teamstore.File) error { - refused = nil - for _, change := range changes { + refused, refusedBy = nil, map[int]error{} + for i, change := range changes { mine := &teamstore.File{Version: f.Version, Teams: teamsClone(f.Teams)} if err := change(mine); err != nil { if refused == nil { refused = err } + if i < len(seqs) { + refusedBy[seqs[i]] = err + } continue } f.Teams = mine.Teams @@ -371,11 +385,17 @@ func (a *app) teamsWrite() tea.Cmd { f.Colour(reserved) return nil }) - if err == nil { + if err != nil { + // THE WHOLE WRITE WAS REFUSED, so no edit in it was taken. + refusedBy = map[int]error{} + for _, seq := range seqs { + refusedBy[seq] = err + } + } else { err = refused } return func(bool) tea.Cmd { - a.teamsTake(teamsWrote{teams: teams, stamp: stamp, err: err, covers: covers}) + a.teamsTake(teamsWrote{teams: teams, stamp: stamp, err: err, covers: covers, seqs: seqs, refused: refusedBy}) return nil } })) @@ -396,6 +416,7 @@ func (a *app) teamsTake(w teamsWrote) { a.teamsDisk.fetching = false } else { a.traffic.wrote = w.covers + a.teamsWriteSettled(w) } if w.err != nil { if !w.read { diff --git a/internal/tui3/teamsheet.go b/internal/tui3/teamsheet.go index 935654848..ce826ca9e 100644 --- a/internal/tui3/teamsheet.go +++ b/internal/tui3/teamsheet.go @@ -415,7 +415,11 @@ func (a *app) teamSheetInsideLines(t team, inner, labelW int) []wallCardLine { no, hn, _ := a.teamSheetButton("Cancel", "esc", tsMoveNo, labelW+wy+1, false) out = append(out, wallCardLine{s: strings.Repeat(" ", labelW) + yes + " " + no, hits: []wallHit{hy, hn}, bleed: true}) } else if a.teamMoveUndoing() && a.tmove.undo.from == teamMoveFromCard { - said := pal.dim(a.tmove.undo.word) + " " + ink := pal.dim + if a.tmove.undo.said.why != "" { + ink = pal.warn + } + said := ink(a.tmove.undo.word) + " " x := labelW + 1 + ansi.StringWidth(a.tmove.undo.word) + 1 undo, hu, _ := a.teamSheetButton("Undo", "u", tsMoveUndo, x, false) out = append(out, wallCardLine{s: strings.Repeat(" ", labelW+1) + said + undo, hits: []wallHit{hu}, bleed: true}) diff --git a/internal/tui3/teamspagehost.go b/internal/tui3/teamspagehost.go index 09cb78a95..077b2c8be 100644 --- a/internal/tui3/teamspagehost.go +++ b/internal/tui3/teamspagehost.go @@ -167,6 +167,9 @@ func (a *app) teamsUndoRow(d *teamsDraw, width, y int) []string { } pal := a.pal word := " " + pal.dim(a.tp.undo.name+" is closed") + " " + if why := a.tp.undo.said.why; why != "" { + word = " " + pal.warn(teamNotSaved("the close of "+a.tp.undo.name, why)) + " " + } s, _ := d.button("Undo", teamsTarget{act: teamsActUndo, x0: ansi.StringWidth(word), y: y, hint: "Reopen " + a.tp.undo.name + " and its tabs" + hintSegment + "u"}, pal.ink) return []string{word + s} diff --git a/internal/tui3/teamwritesaid.go b/internal/tui3/teamwritesaid.go new file mode 100644 index 000000000..5c8755fed --- /dev/null +++ b/internal/tui3/teamwritesaid.go @@ -0,0 +1,100 @@ +package tui3 + +import "time" + +// ── A TEAM EDIT IS SAID DONE ONLY WHEN THE STORE TOOK IT ──────────────────── +// +// Every team edit is made to what this window holds at once and written to the +// store a moment later, off the loop ([app.teamEdit], [app.teamsWrite]). The +// notices that say an edit happened (`Made beta · 1` on the wall, `Organized · +// 1 new team` beside Organize, `harbor is closed` and a move's words on the +// teams page) used to be drawn at the keystroke, so a write the store refused +// (a lock another process held, a file that would not read, a far machine whose +// teams kept changing) still said it was done, and the refusal went into a note +// in the conversation behind the wall, where nobody was looking. THE NOTICE NOW +// WAITS FOR THE WRITE THAT CARRIED ITS EDIT, and a refusal takes the notice's +// place, in the warning ink, with the store's reason. The edit itself stays in +// this window, as it always did. + +// teamWriteSaid ties one notice to the edit it reports: the edit's number +// ([teamsDisk.seq]), whether its write is still out, and why the store refused +// it, "" when it took it. +type teamWriteSaid struct { + seq int + pending bool + why string +} + +// teamWriteWatch is the notice tie for the edit [app.teamEdit] just queued, or, +// when err says the edit never reached the queue, that refusal at once. +func (a *app) teamWriteWatch(err error) teamWriteSaid { + if err != nil { + return teamWriteSaid{why: err.Error()} + } + return teamWriteSaid{seq: a.teamsDisk.seq, pending: true} +} + +// said reports whether the notice may be drawn at all: its write is back. +func (s teamWriteSaid) said() bool { return !s.pending } + +// settle takes one write's answer, and reports whether it answered this +// notice's edit. +func (s *teamWriteSaid) settle(w teamsWrote) bool { + if !s.pending { + return false + } + for _, seq := range w.seqs { + if seq != s.seq { + continue + } + s.pending = false + if err := w.refused[seq]; err != nil { + s.why = err.Error() + } + return true + } + return false +} + +// teamsWriteSettled hands one write's answer to every notice waiting on it. +// A notice's clock starts when its write comes back, not when it was made, so +// a slow write over a connection still shows its notice for the whole time. +func (a *app) teamsWriteSettled(w teamsWrote) { + now := a.now() + if a.wall.madeSaid.settle(w) { + a.wall.madeAt = now + } + if a.wall.org.said.settle(w) { + a.wall.org.doneAt = now + } + if a.tp.undo.said.settle(w) { + a.tp.undo.at = now + a.tp.top = teamsTopCache{} + } + if a.tmove.undo.said.settle(w) { + a.tmove.undo.at = now + if why := a.tmove.undo.said.why; why != "" { + a.tmove.undo.word = teamNotSaved("the move", why) + } + a.tp.top = teamsTopCache{} + } + a.touch() +} + +// teamNotSaved is a refusal in the words that take its notice's place. +func teamNotSaved(name, why string) string { + s := "not saved" + if name != "" { + s = name + " was not saved" + } + if why != "" { + s += " · " + why + } + return s +} + +// teamSaidWithin reports whether a settled notice made at at is still inside +// its time d at now. +func teamSaidWithin(at, now time.Time, d time.Duration) bool { + return !at.IsZero() && now.Sub(at) >= 0 && now.Sub(at) < d +} diff --git a/internal/tui3/teamwritesaid_test.go b/internal/tui3/teamwritesaid_test.go new file mode 100644 index 000000000..d15e56981 --- /dev/null +++ b/internal/tui3/teamwritesaid_test.go @@ -0,0 +1,154 @@ +package tui3 + +import ( + "errors" + "strings" + "testing" + + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +// refusingSeam is this machine's teams seam with its write swapped for one +// that hands every change the file as fake holds it and answers err, or, when +// err is nil, whatever the changes made of it. +func refusingSeam(a *app, err error) TeamsSeam { + seam := localTeams(a.profileDir, &a.teamsDisk.watch) + seam.Update = func(change func(*teamstore.File) error) ([]teamstore.Team, string, error) { + if err != nil { + return nil, "", err + } + f := &teamstore.File{} + if cerr := change(f); cerr != nil { + return nil, "", cerr + } + return f.Teams, "stamp", nil + } + return seam +} + +// wallMakeFromFront opens the wall, picks the first tile and makes a team +// called name from it, as the card's Create does, without running the write. +func wallMakeFromFront(t *testing.T, a *app, name string) { + t.Helper() + _ = a.openWall() + tiles := a.wallShown(a.now()) + if len(tiles) == 0 { + t.Fatal("no tiles") + } + a.wall.marked = map[string]bool{tiles[0].tab.key: true} + a.wall.name = name + _ = a.wallMakeTeam(a.wallShown(a.now())) +} + +// Contract 2.1 and 2.2: a team the store refused to save is never said to be +// made. Before the write comes back the row says nothing; after a refusal it +// says the team was not saved, in place of `Made`. +func TestAWallTeamTheStoreRefusedIsNeverSaidToBeMade(t *testing.T) { + a, _, _ := tabApp(t) + a.teamsDisk.door = refusingSeam(a, teamstore.ErrBusy) + wallMakeFromFront(t, a, "beta") + if frame := wallPlainFrame(a.wallFrame(a.width, a.height)); strings.Contains(frame, "Made beta") { + t.Fatalf("the wall said the team was made before the store took it:\n%s", frame) + } + drive(t, a, runCmd(a.teamsWrite())...) + frame := wallPlainFrame(a.wallFrame(a.width, a.height)) + if strings.Contains(frame, "Made beta") { + t.Fatalf("the wall said a refused team was made:\n%s", frame) + } + if !strings.Contains(frame, "beta was not saved") { + t.Fatalf("the wall did not say the team was not saved:\n%s", frame) + } + // Contract 2.4: the team stays in this window. + if teamNamed(a.wall.teams, "beta") < 0 { + t.Fatal("the refused team left the window") + } +} + +// Contract 2.1: a team the store took says `Made` once the write is back. +func TestAWallTeamSaysMadeOnlyOnceTheStoreTookIt(t *testing.T) { + a, _, _ := tabApp(t) + a.teamsDisk.door = refusingSeam(a, nil) + wallMakeFromFront(t, a, "gamma") + if frame := wallPlainFrame(a.wallFrame(a.width, a.height)); strings.Contains(frame, "Made gamma") { + t.Fatalf("the wall said the team was made before the store took it:\n%s", frame) + } + drive(t, a, runCmd(a.teamsWrite())...) + if frame := wallPlainFrame(a.wallFrame(a.width, a.height)); !strings.Contains(frame, "Made gamma · 1") { + t.Fatalf("the wall did not say the team was made:\n%s", frame) + } +} + +// Contract 2.3: an edit the store took is not blamed for a sibling edit the +// store refused in the same write. +func TestATakenTeamIsNotBlamedForASiblingEditsRefusal(t *testing.T) { + a, _, _ := tabApp(t) + a.teamsDisk.door = refusingSeam(a, nil) + wallMakeFromFront(t, a, "delta") + // This edit holds in the window (which is at the current version) and is + // refused by the store's file (a fake at version 0). + if err := a.teamEdit(func(f *teamstore.File) error { + if f.Version == 0 { + return errors.New("the file moved") + } + return nil + }); err != nil { + t.Fatal(err) + } + drive(t, a, runCmd(a.teamsWrite())...) + frame := wallPlainFrame(a.wallFrame(a.width, a.height)) + if !strings.Contains(frame, "Made delta · 1") || strings.Contains(frame, "delta was not saved") { + t.Fatalf("the made team was blamed for another edit's refusal:\n%s", frame) + } +} + +// Contract 2.1 and 2.2: Organize's `Organized` is said only once the store took +// it, and a refused Apply says it was not saved instead. +func TestARefusedOrganizeIsNeverSaidToBeOrganized(t *testing.T) { + for _, refuse := range []bool{false, true} { + a, _, _ := tabApp(t) + var err error + if refuse { + err = teamstore.ErrBusy + } + a.teamsDisk.door = refusingSeam(a, err) + _ = a.openWall() + tiles := a.wallShown(a.now()) + o := &a.wall.org + o.on, o.props = true, []orgProp{{name: "sorted", keys: []string{tiles[0].tab.key}, names: []string{tiles[0].name}, take: true}} + a.wallOrganizeApply() + if frame := wallPlainFrame(a.wallFrame(a.width, a.height)); strings.Contains(frame, "Organized ·") { + t.Fatalf("refuse=%v: Organized was said before the store took it:\n%s", refuse, frame) + } + drive(t, a, runCmd(a.teamsWrite())...) + frame := wallPlainFrame(a.wallFrame(a.width, a.height)) + switch { + case refuse && (strings.Contains(frame, "Organized ·") || !strings.Contains(frame, "not saved")): + t.Fatalf("a refused Organize said it was done, or not that it was refused:\n%s", frame) + case !refuse && !strings.Contains(frame, "Organized · 1 new team"): + t.Fatalf("a taken Organize did not say so:\n%s", frame) + } + } +} + +// Contract 2.1 and 2.2: the teams page says a team is closed only once the +// store took the close, and says the close was not saved when it refused. +func TestTheTeamsPageSaysAClosedTeamOnlyOnceTheStoreTookIt(t *testing.T) { + for _, refuse := range []bool{false, true} { + a, harbor, _ := teamsPlaceLabIDs(t) + if refuse { + a.teamsDisk.door = refusingSeam(a, teamstore.ErrBusy) + } + _ = a.teamsCloseNow(harbor, "") + if text := teamsFrameText(a); strings.Contains(text, "harbor is closed") { + t.Fatalf("refuse=%v: the page said harbor is closed before the store took it:\n%s", refuse, text) + } + drive(t, a, runCmd(a.teamsWrite())...) + text := teamsFrameText(a) + switch { + case refuse && (strings.Contains(text, "harbor is closed") || !strings.Contains(text, "the close of harbor was not saved")): + t.Fatalf("a refused close said it closed, or not that it was refused:\n%s", text) + case !refuse && !strings.Contains(text, "harbor is closed"): + t.Fatalf("a taken close was not said:\n%s", text) + } + } +} diff --git a/internal/tui3/wall.go b/internal/tui3/wall.go index 630ac00b7..b61151c44 100644 --- a/internal/tui3/wall.go +++ b/internal/tui3/wall.go @@ -235,6 +235,7 @@ func (a *app) wallFrame(width, height int) []string { made: a.wall.made, madeN: a.wall.madeN, madeAt: a.wall.madeAt, + madeSaid: a.wall.madeSaid, hover: a.wall.hover, choices: a.wall.choices, choice: a.wall.choice, @@ -796,12 +797,15 @@ func (a *app) wallMakeTeam(tiles []wallTile) tea.Cmd { if err != nil { a.note("the team is kept for this window, but " + err.Error()) } + // `Made` WAITS FOR THE STORE: the row says it once the write that carried + // this team is back, and says it was not saved if that write was refused. + a.wall.madeSaid = a.teamWriteWatch(err) // THE VIEW STAYS WHERE IT WAS. A person making a team is usually sorting // several at once, and a wall that jumped into the new one would hide the // conversations they were about to sort next. The chip row names the team // and its chip is one press away. a.wall.marked = map[string]bool{} - a.wall.made, a.wall.madeN, a.wall.madeAt = made.Name, len(made.Members), time.Now() + a.wall.made, a.wall.madeN, a.wall.madeAt = made.Name, len(made.Members), a.now() return nil } diff --git a/internal/tui3/wallbar.go b/internal/tui3/wallbar.go index 727500018..8a67eb8a3 100644 --- a/internal/tui3/wallbar.go +++ b/internal/tui3/wallbar.go @@ -755,10 +755,18 @@ func wallTeamsSegments(pal palette, g wallGlyphs, v wallView, k wallKeys, y int, *hits = append(*hits, wallHit{x0: *x, y0: y, x1: *x + len(add), y1: y + 1, kind: wallHitAddTeam}) put(s, len(add)) } - // A team just made says so for a moment, in the row it now sits in. - if v.made != "" && !v.madeAt.IsZero() && v.now.Sub(v.madeAt) < wallMadeFor { - word := " Made " + v.made + " " + g.sep + " " + strconv.Itoa(v.madeN) - if fitsAt(ansi.StringWidth(word)) { + // A team just made says so for a moment, in the row it now sits in, once + // the store took it; a team the store refused says that instead. + if v.made != "" && v.madeSaid.said() && teamSaidWithin(v.madeAt, v.now, wallMadeFor) { + if v.madeSaid.why != "" { + word := " " + teamNotSaved(v.made, v.madeSaid.why) + if !fitsAt(ansi.StringWidth(word)) { + word = " " + teamNotSaved(v.made, "") + } + if fitsAt(ansi.StringWidth(word)) { + put(pal.warn(word), ansi.StringWidth(word)) + } + } else if word := " Made " + v.made + " " + g.sep + " " + strconv.Itoa(v.madeN); fitsAt(ansi.StringWidth(word)) { put(pal.dim(word), ansi.StringWidth(word)) } } diff --git a/internal/tui3/wallclick_test.go b/internal/tui3/wallclick_test.go index 9f7c61317..a58de9f56 100644 --- a/internal/tui3/wallclick_test.go +++ b/internal/tui3/wallclick_test.go @@ -92,6 +92,8 @@ func TestWallClickPicksTilesAndMakesATeam(t *testing.T) { if len(a.wall.marked) != 0 || a.wall.made != offered { t.Fatalf("after create: marked=%v made=%q", a.wall.marked, a.wall.made) } + // `Made` is said once the store took the team (teamwritesaid.go). + teamsFlush(t, a) if !strings.Contains(ansi.Strip(a.wallFrame(a.width, a.height)[a.wall.headRows+1]), "Made "+offered) { t.Fatal("the chips row does not say the team was made") } diff --git a/internal/tui3/wallcontract.go b/internal/tui3/wallcontract.go index 948e0e6c9..544060214 100644 --- a/internal/tui3/wallcontract.go +++ b/internal/tui3/wallcontract.go @@ -149,6 +149,9 @@ type wallView struct { made string madeN int madeAt time.Time + // madeSaid says whether the store took the team: `Made` waits for it, and + // a refusal is said in its place (teamwritesaid.go). + madeSaid teamWriteSaid // pointerOn says pointerY holds the row the pointer is on, in the painter's // own rows (the head's rows taken off). Some targets share a ref on two // rows, a waiting tile's Answer and its row's Answer, or a picked tile's ☐ @@ -380,6 +383,8 @@ type wallState struct { made string madeN int madeAt time.Time + // madeSaid ties `Made` to the write that carried it (teamwritesaid.go). + madeSaid teamWriteSaid // The motion and the pointer's memory (wall.go). revealAt is when the // opening's row-by-row reveal began, zero once it is done; zoomAt and From 95b0728c1fc5d8c3de8f0ae8c268d6150344be52 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 16:13:22 -0400 Subject: [PATCH 4/9] tui3: a team that is wrapping up says how long it has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wrap-up clock is kept on the team in teams.json and survives a restart, but nothing drew it, so a person who asked a team to wrap up could not see whether it had twelve minutes left or none. The teams page's header and the manager's side column now say `wrapping up · 12m left`, `under a minute left`, then `out of time`, from the team the window holds (started plus bound, never a store read on the paint clock), and both caches are keyed by the words so they move on the clock alone. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../manual/chat/team-questions-and-caps.md | 9 ++ internal/manual/chat_test.go | 1 + internal/tui3/sidetraffic.go | 8 ++ internal/tui3/teamcrew.go | 6 ++ internal/tui3/teamspagedraw.go | 3 + internal/tui3/teamspagehost.go | 2 +- internal/tui3/teamwrapclock.go | 42 ++++++++ internal/tui3/teamwrapclock_test.go | 97 +++++++++++++++++++ 8 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 internal/tui3/teamwrapclock.go create mode 100644 internal/tui3/teamwrapclock_test.go diff --git a/internal/manual/chat/team-questions-and-caps.md b/internal/manual/chat/team-questions-and-caps.md index a191f144d..0e903e560 100644 --- a/internal/manual/chat/team-questions-and-caps.md +++ b/internal/manual/chat/team-questions-and-caps.md @@ -95,6 +95,15 @@ The wrap-up has 15 minutes and $2 of team spend. When it runs out of either befo manager reports, codeaf brings you the report itself, marked `wrap-up incomplete`, with **Close now** and **Keep going**. +## How long does my team have left to wrap up + +While a team is wrapping up, the teams page's header for it and the Traffic column beside its +manager say how long it has: `wrapping up · 12m left`, then `wrapping up · under a minute +left`, and `wrapping up · out of time` once the 15 minutes are gone and the report has not +come yet. The words go when the report arrives. The time is counted from when the wrap-up +began, kept with the team, so it reads the same after a restart. The team's chip on the tab +strip does not show it. + ## What if the wrap-up report could not be sent, the decisions file was busy A wrap-up that runs out of its 15 minutes or its $2 before the manager reports is diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 573b01359..30affb00a 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -51,6 +51,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"why does my closed team say its report is not readable", "teams-page"}, {"can a team cap be less than a cent", "team-questions-and-caps"}, {"the wall said my team was not saved", "conversations-and-teams"}, + {"how long does my team have left to wrap up", "team-questions-and-caps"}, {"where do I change one team's settings", "teams-page"}, {"what does the ? 2 mark on a team mean", "teams-page"}, // Nesting on the teams page (teams-page.md). diff --git a/internal/tui3/sidetraffic.go b/internal/tui3/sidetraffic.go index 99211b92b..4e6c6f47d 100644 --- a/internal/tui3/sidetraffic.go +++ b/internal/tui3/sidetraffic.go @@ -45,6 +45,8 @@ type sideTrafficCacheKey struct { kind, rows, width, height, hotDoor, moved int ascii, held bool minute int64 + // wrap is the team's wrap-up words (teamwrapclock.go). + wrap string } // sideTrafficCache is the Traffic view's rows as last laid. @@ -94,12 +96,18 @@ func (a *app) sideTrafficView(height int) ([]railLine, int) { team: t.ID, handle: handle, last: last, divider: a.side.divider[t.ID], hot: hot, focus: focus, front: a.frontTabKey(), kind: kind, rows: len(rows), width: width, height: height, hotDoor: hotDoor, moved: a.side.moved, ascii: a.pal.ascii, held: a.railHold, minute: a.now().Unix() / 60, + wrap: a.teamWrapWords(t, a.now()), } a.trafficMarkSeen(t) if c := &a.side.traffic; c.lines != nil && c.key == key { return c.lines, -1 } s := &sideSheet{a: a, t: t, width: width, hot: hot, hotDoor: hotDoor, divider: key.divider} + // A TEAM THAT IS WRAPPING UP SAYS HOW LONG IT HAS, first, above its + // Traffic (teamwrapclock.go). + if key.wrap != "" { + s.lines = append(s.lines, railLine{text: a.pal.warn(ansi.Truncate(key.wrap, width, a.linearMark("…", "~"))), entry: -1}) + } if kind == sideKindManager { s.threads(kind, handle) } else { diff --git a/internal/tui3/teamcrew.go b/internal/tui3/teamcrew.go index ec485f80f..4269c4f29 100644 --- a/internal/tui3/teamcrew.go +++ b/internal/tui3/teamcrew.go @@ -236,6 +236,7 @@ func (a *app) teamsHeader(d *teamsDraw, t team, width, y int) string { dropSpend = 900 dropChip = 800 // minus the chip's place, so the last chip goes first dropBoss = 100 + dropWrap = 200 ) if !t.Closed() { crew := a.teamsCrew(t) @@ -294,6 +295,11 @@ func (a *app) teamsHeader(d *teamsDraw, t team, width, y int) string { if spend != "" { pieces = append(pieces, teamsHeadPiece{s: pal.dim(spend), w: ansi.StringWidth(spend), drop: dropSpend}) } + // A WRAP-UP'S TIME LEFT outlasts the spend and the members on a narrow + // line: it is the one fact here with a deadline behind it (teamwrapclock.go). + if wrap := a.teamWrapWords(t, a.now()); wrap != "" { + pieces = append(pieces, teamsHeadPiece{s: pal.warn(wrap), w: ansi.StringWidth(wrap), drop: dropWrap}) + } // WHAT FITS. The name and the buttons are the floor; the pieces are added // back from the one a narrow line keeps longest. bw := 0 diff --git a/internal/tui3/teamspagedraw.go b/internal/tui3/teamspagedraw.go index 0a1ee9c43..7f24e30d2 100644 --- a/internal/tui3/teamspagedraw.go +++ b/internal/tui3/teamspagedraw.go @@ -337,6 +337,9 @@ type teamsTopKey struct { undoing bool moving string dragging bool + // wrap is the selected team's wrap-up words, which move with the clock + // on a boundary of their own rather than the minute's. + wrap string } // teamsTopSig is a digest of the members' states this window can see change diff --git a/internal/tui3/teamspagehost.go b/internal/tui3/teamspagehost.go index 077b2c8be..d23630ff5 100644 --- a/internal/tui3/teamspagehost.go +++ b/internal/tui3/teamspagehost.go @@ -115,7 +115,7 @@ func (a *app) teamsHostTop(width int) []string { cur: a.tp.cur, hot: a.tp.hot, focus: a.tp.focus, sig: a.teamsTopSig(t), minute: a.now().Unix() / 60, answering: a.tp.answering, answer: string(a.tp.answer.value), expand: a.tp.expand, ascii: a.pal.ascii, linear: a.linear, undoing: a.teamsUndoing(), - moving: a.teamMoveSig(), dragging: a.tdrag.on, + moving: a.teamMoveSig(), dragging: a.tdrag.on, wrap: a.teamWrapWords(t, a.now()), } if c := &a.tp.top; c.ok && c.key == key { return c.rows diff --git a/internal/tui3/teamwrapclock.go b/internal/tui3/teamwrapclock.go new file mode 100644 index 000000000..f3dce67a6 --- /dev/null +++ b/internal/tui3/teamwrapclock.go @@ -0,0 +1,42 @@ +package tui3 + +import "time" + +// ── A TEAM THAT IS WRAPPING UP SAYS HOW LONG IT HAS ───────────────────────── +// +// `Wrap up first` gives the manager a bounded time to bring its closing report +// (internal/session's team_wrapup.go), and the clock is kept ON THE TEAM in +// teams.json (teams' Wrap: when it began and how long it was given), so it +// survives a restart. Nothing drew it: a person who asked a team to wrap up +// could not see whether it had twelve minutes left or none. The teams page's +// header and the manager's side column now say it. +// +// IT IS READ FROM THE TEAM THIS WINDOW HOLDS, never from the store on the paint +// clock: the frame law holds here as everywhere. The time left is the start +// plus the bound less now, so a window opened after a restart says the same as +// the one that was closed. Both places are cached by the words, so they move +// on the clock alone. + +// teamWrapWords is what a team's wrap-up says at now: `wrapping up · 12m +// left`, `wrapping up · under a minute left`, `wrapping up · out of time`, and +// `wrapping up` alone for a clock from a file written before the bound was +// kept. "" for a team with no wrap-up, or a closed one: the emptiness law. +func (a *app) teamWrapWords(t team, now time.Time) string { + if t.Wrap == nil || t.Closed() || t.Wrap.Started.IsZero() { + return "" + } + words := "wrapping up" + if t.Wrap.Bound <= 0 { + return words + } + sep := " " + a.teamsDot() + " " + left := t.Wrap.Started.Add(t.Wrap.Bound).Sub(now) + switch { + case left <= 0: + return words + sep + "out of time" + case left < time.Minute: + return words + sep + "under a minute left" + } + minutes := int((left + time.Minute - 1) / time.Minute) + return words + sep + itoa(minutes) + "m left" +} diff --git a/internal/tui3/teamwrapclock_test.go b/internal/tui3/teamwrapclock_test.go new file mode 100644 index 000000000..8ae93f8f4 --- /dev/null +++ b/internal/tui3/teamwrapclock_test.go @@ -0,0 +1,97 @@ +package tui3 + +import ( + "strings" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +// setWrap puts a wrap-up that began at started with bound on team id, the way +// the manager's session writes it to teams.json. +func setWrap(t *testing.T, a *app, id string, started time.Time, bound time.Duration) { + t.Helper() + if err := a.teamEdit(func(f *teamstore.File) error { + i := teamIndex(f.Teams, id) + if i < 0 { + return nil + } + f.Teams[i].Wrap = &teamstore.Wrap{Started: started, Bound: bound} + return nil + }); err != nil { + t.Fatal(err) + } +} + +// Contract 5.1, 5.4 and 5.5: the teams page's header says how long a team that +// is wrapping up has left, moves on the clock alone, and says nothing for a +// team with no wrap-up. +func TestTheTeamsHeaderSaysHowLongAWrapUpHasLeft(t *testing.T) { + a, harbor, orbit := teamsPlaceLabIDs(t) + base := time.Date(2026, 9, 25, 15, 0, 0, 0, time.UTC) + now := base.Add(3 * time.Minute) + a.clock = func() time.Time { return now } + setWrap(t, a, harbor, base, 15*time.Minute) + drive(t, a, runCmd(a.teamsSelect(harbor))...) + if text := teamsFrameText(a); !strings.Contains(text, "wrapping up · 12m left") { + t.Fatalf("the header does not say the wrap-up's time left:\n%s", text) + } + now = base.Add(14*time.Minute + 30*time.Second) + if text := teamsFrameText(a); !strings.Contains(text, "wrapping up · under a minute left") { + t.Fatalf("the header did not move with the clock:\n%s", text) + } + now = base.Add(16 * time.Minute) + if text := teamsFrameText(a); !strings.Contains(text, "wrapping up · out of time") { + t.Fatalf("the header does not say the wrap-up is out of time:\n%s", text) + } + drive(t, a, runCmd(a.teamsSelect(orbit))...) + if text := teamsFrameText(a); strings.Contains(text, "wrapping up") { + t.Fatalf("a team with no wrap-up says it is wrapping up:\n%s", text) + } +} + +// Contract 5.3: a wrap-up from a file written before the bound was stored says +// it is wrapping up and names no time. +func TestAWrapUpWithNoBoundNamesNoTime(t *testing.T) { + a, harbor, _ := teamsPlaceLabIDs(t) + setWrap(t, a, harbor, a.now().Add(-time.Minute), 0) + drive(t, a, runCmd(a.teamsSelect(harbor))...) + text := teamsFrameText(a) + if !strings.Contains(text, "wrapping up") || strings.Contains(text, "left") || strings.Contains(text, "out of time") { + t.Fatalf("a boundless wrap-up named a time:\n%s", text) + } +} + +// Contract 5.2: the manager's side column says the same words as the header. +func TestTheManagersColumnSaysHowLongAWrapUpHasLeft(t *testing.T) { + a := managerColumnApp(t) + team, kind, _ := a.sideTeam() + if kind != sideKindManager { + t.Fatal("the manager is not in front") + } + base := time.Date(2026, 9, 25, 15, 0, 0, 0, time.UTC) + now := base.Add(3 * time.Minute) + a.clock = func() time.Time { return now } + draw := func() string { + lines, _ := a.sideTrafficView(30) + var rows []string + for _, l := range lines { + rows = append(rows, l.text) + } + return ansi.Strip(strings.Join(rows, "\n")) + } + if got := draw(); strings.Contains(got, "wrapping up") { + t.Fatalf("a team with no wrap-up says it is wrapping up:\n%s", got) + } + setWrap(t, a, team.ID, base, 15*time.Minute) + if got := draw(); !strings.Contains(got, "wrapping up · 12m left") { + t.Fatalf("the manager's column does not say the wrap-up's time left:\n%s", got) + } + now = base.Add(5 * time.Minute) + if got := draw(); !strings.Contains(got, "wrapping up · 10m left") { + t.Fatalf("the manager's column did not move with the clock:\n%s", got) + } +} From 8e629ff821c5eecbec67c3cda3b4b8ea7b9806cc Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 16:24:44 -0400 Subject: [PATCH 5/9] tui3: a tile draws what its conversation draws, never the wake note A turn woken by team traffic starts on a note the session writes for the model ("Your team's replies started this turn; the person did not speak. Act on them: ..."). The live conversation never draws it, but the wall's tiles drew its first line, and so did a reopened manager conversation when the note carried no team lines. The wake sentences now live in one place in the session (TeamWakeNote reads the constants the composers write), and one aside classifier decides for the reopened view and the tiles: a team delivery draws as its lines, a team wake with nothing in it draws nothing, and every other aside draws as before. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../manual/chat/conversations-and-teams.md | 4 +- internal/session/team_wakewatch.go | 4 +- internal/session/teamwake_note.go | 37 ++++++++ internal/session/teamwake_note_test.go | 27 ++++++ internal/tui3/replay.go | 10 ++- internal/tui3/teamcard.go | 39 +++++++++ internal/tui3/wallmini.go | 33 +++++++ internal/tui3/walltail.go | 15 ++++ internal/tui3/wallwake_test.go | 85 +++++++++++++++++++ 9 files changed, 249 insertions(+), 5 deletions(-) create mode 100644 internal/session/teamwake_note.go create mode 100644 internal/session/teamwake_note_test.go create mode 100644 internal/tui3/wallwake_test.go diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index 85230e3a5..bc95f29ad 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -69,7 +69,9 @@ A tile reads top to bottom: `writing` or `? waiting on you · 3m`, or when it last moved, like `updated 5m ago` - **the conversation's own newest lines**, drawn the way the conversation draws them, fading with age so the newest are where your eye lands; lines that just arrived are lifted for a - moment and then settle + moment and then settle. A turn a team started shows what the team sent (`◆ manager → + @api do` and its words) and the reply, never the note codeaf wrote to start the turn, the + same as the conversation itself - a small **activity line** on the bottom border while there is activity to show A conversation this window can only show as a snapshot (over a shared connection only the one diff --git a/internal/session/team_wakewatch.go b/internal/session/team_wakewatch.go index 00ac96046..ac2c1e03d 100644 --- a/internal/session/team_wakewatch.go +++ b/internal/session/team_wakewatch.go @@ -319,7 +319,7 @@ func (a *Agent) teamWakeMember(profile string, roles []teamRole, now time.Time) // handed the directive over already; there is nothing left to wake on. return } - text := "Your manager started this turn (a directive, or the answer to your question); the person did not speak.\n\n" + news + text := teamWakeMemberLead + "\n\n" + news woke, reason := a.teamWakeWith(text) if woke { a.teamWakeCount(now) @@ -385,7 +385,7 @@ func (a *Agent) teamWakeManager(profile string, roles []teamRole, batch map[stri if len(groups) == 0 { return } - text := "Your team's replies started this turn; the person did not speak. Act on them: hand out what comes next, or tell the person where the work stands.\n\n" + + text := teamWakeManagerLead + "\n\n" + strings.Join(groups, "\n\n") woke, reason := a.teamWakeWith(text) if woke { diff --git a/internal/session/teamwake_note.go b/internal/session/teamwake_note.go new file mode 100644 index 000000000..050ee1617 --- /dev/null +++ b/internal/session/teamwake_note.go @@ -0,0 +1,37 @@ +package session + +import "strings" + +// ── THE SENTENCE A TEAM WAKE OPENS WITH, WRITTEN ONCE ─────────────────────── +// +// A turn woken by team traffic starts on a note the session writes +// (team_wakewatch.go): a sentence telling the model that nobody typed this, +// then what arrived. The sentence is for the model. The live conversation +// never draws it (tui3's followup.go), and a surface that draws a transcript +// asks [TeamWakeNote] to leave it out the same way, so the wall's tiles and a +// reopened conversation show what the live one shows. The composers use these +// constants, so the reader can never drift from what was written. +const ( + // teamWakeMemberLead opens the note that wakes a member on its manager's + // directive or answer. + teamWakeMemberLead = "Your manager started this turn (a directive, or the answer to your question); the person did not speak." + // teamWakeManagerLead opens the note that wakes a manager on its team's + // replies. + teamWakeManagerLead = "Your team's replies started this turn; the person did not speak. Act on them: hand out what comes next, or tell the person where the work stands." + // teamWakeEarlierLead is how the member's note opened in builds of the + // team delegation work before it landed, and journals written by them + // still hold it. + teamWakeEarlierLead = "Your manager's directive started this turn;" +) + +// TeamWakeNote reports whether text is a note the session wrote to wake a +// turn on team traffic. +func TeamWakeNote(text string) bool { + text = strings.TrimSpace(text) + for _, lead := range []string{teamWakeMemberLead, teamWakeManagerLead, teamWakeEarlierLead} { + if strings.HasPrefix(text, lead) { + return true + } + } + return false +} diff --git a/internal/session/teamwake_note_test.go b/internal/session/teamwake_note_test.go new file mode 100644 index 000000000..71d60baec --- /dev/null +++ b/internal/session/teamwake_note_test.go @@ -0,0 +1,27 @@ +package session + +import "testing" + +// Contract 3.3: one classifier knows a team wake note by the very sentences the +// session composes it from, and nothing else. +func TestTeamWakeNoteKnowsTheWakeSentencesAndNothingElse(t *testing.T) { + for _, text := range []string{ + teamWakeMemberLead + "\n\nTeam traffic in \"harbor\" (@api), from your manager:\ndirective from manager: status?", + teamWakeManagerLead + "\n\nWhat your members in \"harbor\" did:\n@api finished", + " " + teamWakeManagerLead, + } { + if !TeamWakeNote(text) { + t.Errorf("a wake note was not known: %q", text) + } + } + for _, text := range []string{ + "", + "Task 3 finished: the parser is fixed.", + "Team traffic in \"harbor\" (@api), from your manager:\ndirective from manager: status?", + "Your team is great; the person did not speak.", + } { + if TeamWakeNote(text) { + t.Errorf("a note that is not a wake was taken for one: %q", text) + } + } +} diff --git a/internal/tui3/replay.go b/internal/tui3/replay.go index c2c49c50f..d11383a02 100644 --- a/internal/tui3/replay.go +++ b/internal/tui3/replay.go @@ -773,10 +773,16 @@ func (a *app) replayBlocks(entries []session.DisplayEntry, shape replayShape) ([ continue } // A LINE THE TEAM SENT IS A CARD, headed by who said it to whom - // (teamcard.go), and never the person's `›`. - if len(e.Team) > 0 || strings.HasPrefix(text, teamAsideLead) { + // (teamcard.go), and never the person's `›`. A TEAM WAKE WITH + // NOTHING DELIVERED IN IT IS NOT DRAWN: it is the sentence that told + // the model nobody typed this turn, which the live conversation + // never draws either (followup.go). + switch asideShapeOf(e) { + case asideTeam: blocks = append(blocks, entry{kind: entryTeam, text: text, team: e.Team, turn: turn}) continue + case asideHidden: + continue } // A LINE THE SESSION WROTE GOES IN THE SESSION'S OWN LANE — the dim // "· " row this surface says everything of its own in ([feed.note]) — diff --git a/internal/tui3/teamcard.go b/internal/tui3/teamcard.go index ae0d27473..ca789d53d 100644 --- a/internal/tui3/teamcard.go +++ b/internal/tui3/teamcard.go @@ -211,3 +211,42 @@ func (a *app) teamCardRows(e entry, width int) []string { } return out } + +// asideShape is how a surface draws one session aside, decided once for the +// reopened conversation (replay.go) and the wall's tiles (wallmini.go, +// walltail.go), so a tile shows what the conversation shows. +type asideShape uint8 + +const ( + // asideLine is every other aside: its first line, in the session's lane. + asideLine asideShape = iota + // asideTeam is a team delivery: the lines as cards, never the sentence + // the session put above them for the model. + asideTeam + // asideHidden is a team wake with nothing delivered in it: the note that + // started a turn nobody typed, which the live conversation never draws + // (followup.go), so no surface draws it. + asideHidden +) + +// asideShapeOf is [asideShape] for one aside's entry. +func asideShapeOf(e session.DisplayEntry) asideShape { + text := strings.TrimSpace(e.Text) + switch { + case len(e.Team) > 0 || strings.HasPrefix(text, teamAsideLead): + return asideTeam + case session.TeamWakeNote(text): + return asideHidden + } + return asideLine +} + +// teamCardsOf is a team delivery's lines as cards: the session's own parse +// when the entry carries it, the note's text read back otherwise. +func teamCardsOf(e session.DisplayEntry, mark string) []teamCard { + cards, ok := teamLineCards(e.Team, e.Text, mark) + if !ok { + cards, _ = teamAsideCards(e.Text, mark) + } + return cards +} diff --git a/internal/tui3/wallmini.go b/internal/tui3/wallmini.go index c3fc3d196..e074e464c 100644 --- a/internal/tui3/wallmini.go +++ b/internal/tui3/wallmini.go @@ -121,6 +121,18 @@ func (a *app) wallDraw(entries []session.DisplayEntry, width int) []string { case "tool": last := i == len(entries)-1 || entries[i+1].Role != "tool" rows = a.wallToolRows(e, last, width) + case "aside": + // AN ASIDE IS DRAWN AS THE CONVERSATION DRAWS IT (teamcard.go's + // [asideShapeOf]): a team delivery as its lines, a team wake with + // nothing in it not at all, anything else as its first line. + switch asideShapeOf(e) { + case asideTeam: + rows = a.wallTeamRows(e, width) + case asideLine: + if line := strings.TrimSpace(firstLine(e.Text)); line != "" { + rows = []string{a.pal.italic(a.pal.dim(ansi.Truncate(line, width, "…")))} + } + } default: if line := strings.TrimSpace(firstLine(e.Text)); line != "" { rows = []string{a.pal.italic(a.pal.dim(ansi.Truncate(line, width, "…")))} @@ -187,6 +199,27 @@ func (a *app) wallReplyRows(text string, width int) []string { return renderMarkdownWithCode(a.styler(), text, width, nil) } +// wallTeamRows is a team delivery as the conversation's card draws it +// (teamcard.go): each line headed by who said it to whom, its words under a +// bar, fit to the tile. It is the card's words without the card's memory of the +// conversation in front, which a tile of another conversation must not read. +func (a *app) wallTeamRows(e session.DisplayEntry, width int) []string { + pal := a.pal + arrow, bar := a.linearMark("→", "->"), a.linearMark("│", "|") + var out []string + for _, c := range teamCardsOf(e, a.teamManagerMark()) { + head := pal.muted(c.from) + pal.dim(" "+arrow+" ") + pal.muted(c.to) + if c.tag != "" { + head += " " + pal.dim(c.tag) + } + out = append(out, ansi.Truncate(head, width, "…")) + if text := strings.TrimSpace(firstLine(c.text)); text != "" { + out = append(out, pal.dim(bar+" ")+pal.ink(ansi.Truncate(text, max(width-2, 1), "…"))) + } + } + return out +} + // wallToolRows is one call on the rail: its name in muted, its target after // it, a shell command highlighted exactly as the transcript highlights it. func (a *app) wallToolRows(e session.DisplayEntry, last bool, width int) []string { diff --git a/internal/tui3/walltail.go b/internal/tui3/walltail.go index 332bbb6b4..a90bfc5b5 100644 --- a/internal/tui3/walltail.go +++ b/internal/tui3/walltail.go @@ -136,6 +136,21 @@ func wallEntryLines(e session.DisplayEntry) []wallLine { } return out case "note", "aside": + // A team delivery is its lines and a team wake with nothing in it is + // nothing, as the conversation draws them (teamcard.go's + // [asideShapeOf]). + if e.Role == "aside" { + switch asideShapeOf(e) { + case asideHidden: + return nil + case asideTeam: + var out []wallLine + for _, c := range teamCardsOf(e, teamManagerGlyph) { + out = append(out, wallLine{kind: wallNote, text: c.from + " → " + c.to + " " + wallFirstLine(c.text)}) + } + return out + } + } // A compaction summary is pages long and a task's note a paragraph; the // tile says that one happened, in its first line. if text := wallFirstLine(e.Text); text != "" { diff --git a/internal/tui3/wallwake_test.go b/internal/tui3/wallwake_test.go new file mode 100644 index 000000000..bcd13c261 --- /dev/null +++ b/internal/tui3/wallwake_test.go @@ -0,0 +1,85 @@ +package tui3 + +import ( + "strings" + "testing" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// managerWake is the note the session wakes a manager with, whole, as a +// transcript holds it when it carries no team lines. +const managerWake = "Your team's replies started this turn; the person did not speak. Act on them: hand out what comes next, or tell the person where the work stands.\n\nWhat your members in \"harbor\" did:\n@api finished" + +// memberWake is the note the session wakes a member with, carrying the +// manager's directive as a delivery. +const memberWake = "Your manager started this turn (a directive, or the answer to your question); the person did not speak.\n\nTeam traffic in \"harbor\" (@api), from your manager:\ndirective from manager: check the parser" + +func wallTileText(a *app, entries []session.DisplayEntry) string { + return ansi.Strip(strings.Join(a.wallDraw(entries, 60), "\n")) +} + +// Contract 3.1 and 3.2: a tile never draws a wake note's sentence. A manager's +// wake with no team lines draws nothing; the person's words and the reply stay. +func TestATileNeverDrawsAManagersWakeSentence(t *testing.T) { + a, _, _ := tabApp(t) + got := wallTileText(a, []session.DisplayEntry{ + {Role: "user", Text: "sort out the parser"}, + {Role: "aside", Text: managerWake}, + {Role: "assistant", Text: "api is done; nothing else is pending."}, + }) + if strings.Contains(got, "started this turn") || strings.Contains(got, "did not speak") { + t.Fatalf("the tile drew the wake sentence:\n%s", got) + } + if !strings.Contains(got, "sort out the parser") || !strings.Contains(got, "nothing else is pending") { + t.Fatalf("the tile lost the person's words or the reply:\n%s", got) + } +} + +// Contract 3.2: a member's wake that carries its manager's directive draws the +// directive, as the conversation's card does, and not the sentence above it. +func TestATileDrawsAMembersWakeAsItsDirective(t *testing.T) { + a, _, _ := tabApp(t) + lines := []session.TeamLine{{Team: "harbor", From: "manager", Kind: "directive", Text: "check the parser"}} + got := wallTileText(a, []session.DisplayEntry{{Role: "aside", Text: memberWake, Team: lines}}) + if strings.Contains(got, "started this turn") { + t.Fatalf("the tile drew the wake sentence:\n%s", got) + } + if !strings.Contains(got, "check the parser") || !strings.Contains(got, "manager") { + t.Fatalf("the tile did not draw the manager's directive:\n%s", got) + } +} + +// Contract 3.4: every other session aside draws as it did, its first line. +func TestATileStillDrawsATaskLanding(t *testing.T) { + a, _, _ := tabApp(t) + got := wallTileText(a, []session.DisplayEntry{{Role: "aside", Text: "Task 3 finished: the parser is fixed.\nmore"}}) + if !strings.Contains(got, "Task 3 finished") { + t.Fatalf("the tile lost a task landing:\n%s", got) + } +} + +// Contract 3.3: a reopened conversation draws a manager's wake note the way the +// live one does: not at all. A task landing still draws its line. +func TestAReopenedConversationDoesNotDrawAManagersWakeSentence(t *testing.T) { + a, _, _ := tabApp(t) + blocks, _ := a.replayBlocks([]session.DisplayEntry{ + {Role: "user", Text: "sort out the parser"}, + {Role: "aside", Text: managerWake}, + {Role: "assistant", Text: "api is done."}, + {Role: "aside", Text: "Task 3 finished: the parser is fixed."}, + }, chatReplay(0)) + var all []string + for _, b := range blocks { + all = append(all, b.text) + } + got := strings.Join(all, "\n") + if strings.Contains(got, "started this turn") { + t.Fatalf("the reopened conversation drew the wake sentence:\n%s", got) + } + if !strings.Contains(got, "Task 3 finished") { + t.Fatalf("the reopened conversation lost a task landing:\n%s", got) + } +} From 8b4679d52ffe436790998ba15905f158183478fa Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 16:32:19 -0400 Subject: [PATCH 6/9] tui3: a wall tile says what its conversation spent #1429 promised each tile would show what it spent, and none did. A tile's state line now ends in the figure the conversation's own status line shows, in its spelling: for the conversation in front, spendShown itself; for one held behind it, the same rule (spendOf, now shared) over its books, read off the loop with its transcript, and its tree on the ledger, read by one command at most every five seconds through the status line's own two doors. Nothing spent draws nothing, the figure gives way whole to the state words on a narrow tile, and a frame never asks an agent or reads the ledger. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../manual/chat/conversations-and-teams.md | 5 +- internal/manual/chat_test.go | 1 + internal/tui3/app.go | 4 + internal/tui3/treespend.go | 22 ++- internal/tui3/wallcontract.go | 26 ++- internal/tui3/wallspend.go | 113 +++++++++++ internal/tui3/wallspend_test.go | 182 ++++++++++++++++++ internal/tui3/walltail.go | 8 +- internal/tui3/wallview.go | 14 ++ 9 files changed, 361 insertions(+), 14 deletions(-) create mode 100644 internal/tui3/wallspend.go create mode 100644 internal/tui3/wallspend_test.go diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index bc95f29ad..b5452ade6 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -66,7 +66,10 @@ A tile reads top to bottom: - **the title** on the top border, the brightest thing in the tile, after the dots of the teams it is in (up to three, then `+N`) - **one dim line** saying what the conversation is doing now, like `running bash · 2m`, - `writing` or `? waiting on you · 3m`, or when it last moved, like `updated 5m ago` + `writing` or `? waiting on you · 3m`, or when it last moved, like `updated 5m ago`, and at + its right end **what the conversation has spent**, like `$0.42`: the same figure its own + status line shows, the work it started included. A conversation that has spent nothing + shows no figure, and on a narrow tile the figure gives way before the words do - **the conversation's own newest lines**, drawn the way the conversation draws them, fading with age so the newest are where your eye lands; lines that just arrived are lifted for a moment and then settle. A turn a team started shows what the team sent (`◆ manager → diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 30affb00a..f6938ce93 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -52,6 +52,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"can a team cap be less than a cent", "team-questions-and-caps"}, {"the wall said my team was not saved", "conversations-and-teams"}, {"how long does my team have left to wrap up", "team-questions-and-caps"}, + {"does the conversations view show what each conversation spent", "conversations-and-teams"}, {"where do I change one team's settings", "teams-page"}, {"what does the ? 2 mark on a team mean", "teams-page"}, // Nesting on the teams page (teams-page.md). diff --git a/internal/tui3/app.go b/internal/tui3/app.go index a876de230..6b488fb97 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -3822,6 +3822,10 @@ func (a *app) route(msg tea.Msg) (tea.Model, tea.Cmd) { a.wallTakeRead(msg) return a, nil + case wallTreeMsg: + a.wallTakeTree(msg) + return a, nil + case wallTickMsg: return a, a.wallTick() diff --git a/internal/tui3/treespend.go b/internal/tui3/treespend.go index 882aad0d4..59f08e911 100644 --- a/internal/tui3/treespend.go +++ b/internal/tui3/treespend.go @@ -130,11 +130,17 @@ func (a *app) treeLines() ([]session.UsageLine, bool) { // whose ledger was moved — has books the file cannot account for, and a figure // that dropped when a person opened yesterday's work would be worse than the // figure that was too small. -func (a *app) spendShown() float64 { - if total := a.tree.Folded(); total > a.cost { - return total +func (a *app) spendShown() float64 { return spendOf(a.cost, a.tree.Folded()) } + +// spendOf is [app.spendShown]'s rule on its own, the larger of a +// conversation's books and its tree on the ledger, so the wall's tile for a +// conversation this window holds behind the front says the figure its status +// line would (wallspend.go), by the same arithmetic and not a copy of it. +func spendOf(books, tree float64) float64 { + if tree > books { + return tree } - return a.cost + return books } // spendSplit is what /cost prints under the total: what the conversation itself @@ -164,8 +170,12 @@ func (a *app) spendSplit() (conversation, tasks float64, ok bool) { // this is. A session with no journal — and the legacy flat layout, where the // folder is the workspace and not an id — answers with something no ledger line // names, which sums to nothing and leaves [app.spendShown] on the books alone. -func (a *app) selfSessionID() string { - file := strings.TrimSpace(a.file) +func (a *app) selfSessionID() string { return sessionIDOf(a.file) } + +// sessionIDOf is the conversation id a journal at file is kept under: the name +// of its folder. "" for no file. +func sessionIDOf(file string) string { + file = strings.TrimSpace(file) if file == "" { return "" } diff --git a/internal/tui3/wallcontract.go b/internal/tui3/wallcontract.go index 544060214..d58116536 100644 --- a/internal/tui3/wallcontract.go +++ b/internal/tui3/wallcontract.go @@ -93,6 +93,10 @@ type wallTile struct { // teams is the id of every team this conversation is in. A conversation // may be in several. teams []string + // spent is what the conversation has spent, the figure its own status line + // would show (treespend.go's [spendOf]); 0 is nothing known, drawn as + // nothing. + spent float64 } // wallTailCap is the most logical lines a reading keeps per conversation. @@ -350,6 +354,12 @@ type wallState struct { hits []wallHit // tails is the reading cache, by chatTab.key (walltail.go owns it). tails map[string]*wallTail + // treeAsking says a ledger reading for the held tiles is out, treeAt when + // the last one left, and treeCache the wall's own tail-reading cache of the + // ledger file, used only by that one reading (wallspend.go). + treeAsking bool + treeAt time.Time + treeCache *session.UsageCache // teams is the loaded set and activeID the id of the one the strip is // narrowed to, "" for none (teams.go owns both). teams []team @@ -428,12 +438,16 @@ type wallState struct { // wallTail is one conversation's cached reading (walltail.go fills it). type wallTail struct { - lines []wallLine - count int // transcript entries seen at the last reading - textLen int // total text length seen, so a growing last entry counts as activity - seen time.Time - fresh int - freshAt time.Time + // books is what the conversation's own books said at the last reading, + // and tree what its tree on the ledger came to at the last ledger reading + // (wallspend.go). Both only ever grow. + books, tree float64 + lines []wallLine + count int // transcript entries seen at the last reading + textLen int // total text length seen, so a growing last entry counts as activity + seen time.Time + fresh int + freshAt time.Time // spark is a ring of per-second activity, newest at sparkAt. spark [wallSparkLen]uint8 sparkAt time.Time diff --git a/internal/tui3/wallspend.go b/internal/tui3/wallspend.go new file mode 100644 index 000000000..1f10f32e0 --- /dev/null +++ b/internal/tui3/wallspend.go @@ -0,0 +1,113 @@ +package tui3 + +import ( + "strings" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// ── A TILE SAYS WHAT ITS CONVERSATION SPENT ───────────────────────────────── +// +// The wall promised what each conversation spent and drew nothing. A tile now +// ends its state line in the figure that conversation's own status line shows: +// for the conversation in front, [app.spendShown] itself; for one this window +// holds behind it, the same rule ([spendOf]) over that conversation's books and +// its tree on the ledger. Nothing spent, or nothing known, draws nothing. +// +// THE FRAME READS ONLY THE CACHE. The books are asked in the command that +// already reads the transcript ([app.wallReadCmd]); the ledger is read by one +// command of its own, at most every [wallTreeEvery] while the wall is up and a +// held tile exists, through the two doors [app.treeLines] uses (the seam, or +// the file through a cache the wall owns), and summed per conversation with +// [session.UsageTree]. A frame never asks an agent, reads a file or crosses a +// wire (framedisk_law_test.go). + +// wallTreeEvery is the least time between two ledger readings for the held +// tiles. A task's spend moves on the ledger as it is made, and a tile is a +// glance: five seconds behind is still the right figure to decide on. +const wallTreeEvery = 5 * time.Second + +// wallTreeMsg is one ledger reading: each held conversation's tree, by key. +type wallTreeMsg struct { + trees map[string]float64 +} + +// wallSpent is tile key's figure, from memory only. +func (a *app) wallSpent(key string, tail *wallTail) float64 { + if key == a.frontTabKey() { + return a.spendShown() + } + if tail == nil { + return 0 + } + return spendOf(tail.books, tail.tree) +} + +// wallTreeCmd reads the ledger once, off the loop, for every conversation this +// window holds behind the front, and is nil when there is nothing to read or a +// reading is out or was made less than [wallTreeEvery] ago. +func (a *app) wallTreeCmd() tea.Cmd { + if !a.wall.on || a.wall.treeAsking || a.shared { + return nil + } + now := a.now() + if !a.wall.treeAt.IsZero() && now.Sub(a.wall.treeAt) < wallTreeEvery { + return nil + } + ids := map[string]string{} + for key, held := range a.behind { + if held == nil || held.conv.Agent == nil { + continue + } + if id := sessionIDOf(held.conv.SessionFile); id != "" { + ids[key] = id + } + } + ledger, path := a.ledger, strings.TrimSpace(a.usageLedger) + if len(ids) == 0 || (ledger == nil && path == "") { + return nil + } + if a.wall.treeCache == nil { + a.wall.treeCache = &session.UsageCache{} + } + cache, from := a.wall.treeCache, session.LastDays(now, spendWindowDays).From + a.wall.treeAsking, a.wall.treeAt = true, now + return func() tea.Msg { + var lines []session.UsageLine + known := true + if ledger != nil { + lines, _, known = ledger(from) + } else { + cache.Path = path + // A torn last line costs that line and never the figure, as it does + // for the status line ([app.treeLines]). + lines, _ = cache.Read(time.Time{}) + } + trees := map[string]float64{} + if known { + for key, id := range ids { + trees[key] = session.UsageTree(lines, id).Folded() + } + } + return wallTreeMsg{trees: trees} + } +} + +// wallTakeTree folds one ledger reading into the tiles' cache, on the loop. A +// figure only ever grows, as the status line's does. +func (a *app) wallTakeTree(msg wallTreeMsg) { + a.wall.treeAsking = false + for key, tree := range msg.trees { + tail := a.wall.tails[key] + if tail == nil { + continue + } + if tree > tail.tree { + tail.tree = tree + a.touch() + } + } +} diff --git a/internal/tui3/wallspend_test.go b/internal/tui3/wallspend_test.go new file mode 100644 index 000000000..58fee8ef5 --- /dev/null +++ b/internal/tui3/wallspend_test.go @@ -0,0 +1,182 @@ +package tui3 + +import ( + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/charmbracelet/x/ansi" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// heldKeyOf is the key this window holds agent behind the front under. +func heldKeyOf(t *testing.T, a *app, agent Agent) string { + t.Helper() + for key, held := range a.behind { + if held != nil && held.conv.Agent == agent { + return key + } + } + t.Fatal("the agent is not held") + return "" +} + +// wallTileRowsFor is tile key's rows in the wall's frame, plain. +func wallTileRowsFor(t *testing.T, a *app, key string) string { + t.Helper() + frame := a.wallFrame(a.width, a.height) + for i, tile := range a.wallShown(a.now()) { + if tile.tab.key != key { + continue + } + // A tile is answered by several targets (its border, its body, its + // controls); its rectangle is all of them together. + x0, y0, x1, y1 := -1, -1, -1, -1 + for _, hit := range a.wall.hits { + if hit.arg != i || (hit.kind != wallHitTile && hit.kind != wallHitOpen) { + continue + } + if x0 < 0 || hit.x0 < x0 { + x0 = hit.x0 + } + if y0 < 0 || hit.y0 < y0 { + y0 = hit.y0 + } + x1, y1 = max(x1, hit.x1), max(y1, hit.y1) + } + if y0 >= 0 { + var rows []string + for y := y0; y < y1 && y < len(frame); y++ { + rows = append(rows, ansi.Strip(ansi.Cut(frame[y], x0, x1))) + } + return strings.Join(rows, "\n") + } + } + t.Fatalf("no tile for %q on the wall", key) + return "" +} + +// Contract 1.1 and 1.2: the tile in front says what the conversation spent, +// the status line's very figure and spelling. +func TestTheFrontTileSaysTheStatusLinesSpend(t *testing.T) { + a, _, _ := tabApp(t) + a.take(session.Usage{CostUSD: 0.0042}) + _ = a.openWall() + a.wall.revealAt = time.Time{} // the opening's motion, which any key cuts short + want := dollars(a.spendShown()) + if got := wallTileRowsFor(t, a, a.frontTabKey()); !strings.Contains(got, want) { + t.Fatalf("the front tile does not say %s:\n%s", want, got) + } +} + +// Contract 1.3: a held conversation's tile says what its own books say, read +// off the loop with its transcript. +func TestAHeldTileSaysWhatItsConversationSpent(t *testing.T) { + a, older, _ := tabApp(t) + older.usage = session.Usage{CostUSD: 1.2} + key := heldKeyOf(t, a, older) + _ = a.openWall() + drive(t, a, runCmd(a.wallReadCmd(key))...) + if got := wallTileRowsFor(t, a, key); !strings.Contains(got, "$1.20") { + t.Fatalf("the held tile does not say $1.20:\n%s", got) + } +} + +// Contract 1.4: a conversation that spent nothing draws no money at all. +func TestATileThatSpentNothingDrawsNoMoney(t *testing.T) { + a, older, _ := tabApp(t) + key := heldKeyOf(t, a, older) + _ = a.openWall() + drive(t, a, runCmd(a.wallReadCmd(key))...) + for _, k := range []string{key, a.frontTabKey()} { + if got := wallTileRowsFor(t, a, k); strings.Contains(got, "$") { + t.Fatalf("a tile that spent nothing drew money:\n%s", got) + } + } +} + +// Contract 1.5 and 1.6: the state words win. The figure sits at the right end +// of the state line with room before it, is dropped whole when it does not fit, +// and stands alone on an idle tile. +func TestTheSpendGivesWayToTheStateWords(t *testing.T) { + a, _, _ := tabApp(t) + now := time.Now() + v := wallView{now: now, spin: 0} + g := wallGlyphsFor(a.pal.ascii) + working := wallTile{signal: tabWorking, live: true, doing: "running bash", moved: now.Add(-2 * time.Minute), spent: 0.0042} + wide := ansi.Strip(wallMetaLine(a.pal, g, v, working, 40)) + if !strings.HasSuffix(wide, "$0.0042") || !strings.Contains(wide, "running bash") || !strings.Contains(wide, " $0.0042") { + t.Fatalf("a wide state line: %q", wide) + } + words := ansi.StringWidth(ansi.Strip(wallMetaLine(a.pal, g, v, wallTile{signal: tabWorking, live: true, doing: "running bash", moved: now.Add(-2 * time.Minute)}, 40))) + narrow := ansi.Strip(wallMetaLine(a.pal, g, v, working, words+3)) + if strings.Contains(narrow, "$") || !strings.Contains(narrow, "running bash") { + t.Fatalf("a narrow state line cut the words for the money: %q", narrow) + } + idle := ansi.Strip(wallMetaLine(a.pal, g, v, wallTile{live: true, spent: 0.5}, 30)) + if strings.TrimSpace(idle) != "$0.50" || ansi.StringWidth(idle) > 30 { + t.Fatalf("an idle tile's state line: %q", idle) + } +} + +// usageCounter is an agent whose books count how often they are asked. +type usageCounter struct { + *fakeAgent + asked *atomic.Int32 +} + +func (u usageCounter) Usage() session.Usage { u.asked.Add(1); return u.fakeAgent.Usage() } + +// Contract 1.3: a frame never asks a conversation what it spent; only the read +// the wall makes off the loop does. +func TestAWallFrameNeverAsksWhatAConversationSpent(t *testing.T) { + a, older, _ := tabApp(t) + key := heldKeyOf(t, a, older) + var asked atomic.Int32 + a.behind[key].conv.Agent = usageCounter{fakeAgent: older, asked: &asked} + _ = a.openWall() + drive(t, a, runCmd(a.wallReadCmd(key))...) + before := asked.Load() + for i := 0; i < 5; i++ { + _ = a.wallFrame(a.width, a.height) + _ = a.wallShown(a.now()) + } + if asked.Load() != before { + t.Fatalf("a frame asked the books %d times", asked.Load()-before) + } +} + +// Contract 1.3: a held conversation whose work has spent more than its books +// know shows the ledger's figure, as its status line would, read off the loop. +func TestAHeldTileSaysWhatItsWorkSpentOnTheLedger(t *testing.T) { + a, older, _ := tabApp(t) + older.usage = session.Usage{CostUSD: 0.5} + key := heldKeyOf(t, a, older) + const id = "aaaa1111bbbb2222" + a.behind[key].conv.SessionFile = "/tmp/lab/" + id + "/transcript.jsonl" + asked := 0 + a.ledger = func(time.Time) ([]session.UsageLine, bool, bool) { + asked++ + return []session.UsageLine{ + {At: time.Now(), Session: id, Calls: 1, USD: 0.5}, + {At: time.Now(), Session: "task-node", Root: id, Calls: 1, USD: 1.75}, + }, true, true + } + // The opening asks for the transcripts and, once, for the ledger. + open := a.openWall() + for i := 0; i < 3; i++ { + _ = a.wallFrame(a.width, a.height) + } + if asked != 0 { + t.Fatalf("a frame read the ledger %d times", asked) + } + drive(t, a, runCmd(open)...) + if asked != 1 { + t.Fatalf("the opening read the ledger %d times", asked) + } + if got := wallTileRowsFor(t, a, key); !strings.Contains(got, "$2.25") { + t.Fatalf("the held tile does not say its tree's $2.25:\n%s", got) + } +} diff --git a/internal/tui3/walltail.go b/internal/tui3/walltail.go index a90bfc5b5..8c13604ac 100644 --- a/internal/tui3/walltail.go +++ b/internal/tui3/walltail.go @@ -57,6 +57,9 @@ type wallReadMsg struct { entries []session.DisplayEntry at time.Time live bool + // books is what the conversation's books said on the same trip + // ([Agent.Usage]), which takes the same lock the transcript does. + books float64 } // wallTickMsg is the wall's clock. @@ -348,7 +351,7 @@ func (a *app) wallReadCmd(keys ...string) tea.Cmd { key := key cmds = append(cmds, func() tea.Msg { at := time.Now() - return wallReadMsg{key: key, entries: agent.Transcript(), at: at, live: live} + return wallReadMsg{key: key, entries: agent.Transcript(), at: at, live: live, books: agent.Usage().CostUSD} }) } switch len(cmds) { @@ -385,6 +388,7 @@ func (a *app) wallTakeRead(msg wallReadMsg) { if !tail.seen.IsZero() && !msg.at.After(tail.seen) { return } + tail.books = max(tail.books, msg.books) tail.take(msg.entries, msg.at, msg.live) } @@ -434,6 +438,7 @@ func (a *app) wallTick() tea.Cmd { if a.wallSpinning() { next = tea.Batch(next, a.wake()) } + next = tea.Batch(next, a.wallTreeCmd()) if read := a.wallReadCmd(keys...); read != nil { return tea.Batch(read, next) } @@ -509,6 +514,7 @@ func (a *app) wallTiles(now time.Time) []wallTile { if tile.signal == tabNeedsPerson { tile.question = a.wallQuestion(tab, tail) } + tile.spent = a.wallSpent(tab.key, tail) tiles = append(tiles, tile) } return tiles diff --git a/internal/tui3/wallview.go b/internal/tui3/wallview.go index 7bf804edd..7f00a964a 100644 --- a/internal/tui3/wallview.go +++ b/internal/tui3/wallview.go @@ -860,6 +860,20 @@ func wallMetaLine(pal palette, g wallGlyphs, v wallView, t wallTile, w int) stri if ansi.StringWidth(s) > w { s = ansi.Truncate(s, w, g.more) } + // WHAT IT SPENT ENDS THE LINE, in the status line's own spelling, and + // GIVES WAY WHOLE to the state words when both do not fit with two cells + // between them: what the conversation is doing is the line's reason. + if t.spent > 0 { + money := dollars(t.spent) + sw, mw := ansi.StringWidth(s), ansi.StringWidth(money) + gap := 2 + if sw == 0 { + gap = 0 + } + if sw+gap+mw <= w { + s += strings.Repeat(" ", w-sw-mw) + pal.dim(money) + } + } return s } From fb754e613db14a870c09b383bdbf31cec9dbaba6 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 17:21:13 -0400 Subject: [PATCH 7/9] changes: the teams surface fixes (#1516) Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/changes/unreleased/1516-teams-surface.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 docs/changes/unreleased/1516-teams-surface.md diff --git a/docs/changes/unreleased/1516-teams-surface.md b/docs/changes/unreleased/1516-teams-surface.md new file mode 100644 index 000000000..3f405dde1 --- /dev/null +++ b/docs/changes/unreleased/1516-teams-surface.md @@ -0,0 +1,13 @@ +--- +kind: fixed +title: wall tiles say what each conversation spent, and the teams surface stops saying things that are not so +pr: 1516 +surface: [chat, engine] +invalidates: + - "A wall tile drew no spend, although #1429 promised it. A tile's state line now ends in the figure that conversation's own status line shows (the work it started included), and draws nothing when it spent nothing." + - "A team write the store refused (a lock, an unreadable file, a far machine that kept changing) still drew `Made beta · 1`, `Organized · …` or `harbor is closed`. Those notices now wait for the write that carried their edit, and a refusal takes their place: `beta was not saved · `." + - "Wall tiles, and a reopened manager conversation, drew the wake sentence (`Your team's replies started this turn; the person did not speak. …`) that the live conversation never draws. Neither draws it now; a team delivery draws as its lines." + - "A team cap under a cent read `$0.00` and offered `Raise to $0`, whose stored ceiling lifted nothing, and the settings card said `$0.0010 a day`. Every place now spells a cap one way (`$5`, `$5.50`, `$0.001`), and Raise always offers twice the ceiling and names it exactly. The card spells a whole-dollar cap `$5`, where it said `$5.00`." + - "A team that was wrapping up showed no deadline anywhere. The teams page header and the manager's side column now say `wrapping up · 12m left`, then `under a minute left`, then `out of time`." + - "On a plain local launch, a closed team's pane said its report `is not readable over this connection`. The report now opens there; only over --host does that sentence remain." +--- From 34507fa3b0d2c6ab6d6191ee79ca8f43d999a716 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 17:21:23 -0400 Subject: [PATCH 8/9] changes: keep the entry's title under a hundred characters Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/changes/unreleased/1516-teams-surface.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes/unreleased/1516-teams-surface.md b/docs/changes/unreleased/1516-teams-surface.md index 3f405dde1..fe0d7d327 100644 --- a/docs/changes/unreleased/1516-teams-surface.md +++ b/docs/changes/unreleased/1516-teams-surface.md @@ -1,6 +1,6 @@ --- kind: fixed -title: wall tiles say what each conversation spent, and the teams surface stops saying things that are not so +title: wall tiles say what each conversation spent, and the teams surface stops saying untrue things pr: 1516 surface: [chat, engine] invalidates: From 1d239ab6a3a8c778f893138b5f27592f801a2b5b Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Fri, 25 Sep 2026 17:33:20 -0400 Subject: [PATCH 9/9] tui3: compare Organize's undone teams as instants, as the file keeps them Once Organized waits for the Apply's write, the window holds the teams read back from the file, whose times carry UTC's location rather than the clock's; on a machine whose clock is UTC the two print alike and are not DeepEqual. The test now compares the times as instants. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/tui3/teamorganize_test.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/tui3/teamorganize_test.go b/internal/tui3/teamorganize_test.go index 182df14e8..fff36a58b 100644 --- a/internal/tui3/teamorganize_test.go +++ b/internal/tui3/teamorganize_test.go @@ -227,7 +227,18 @@ func TestOrganizeApplyThenUndoRestoresTheExactList(t *testing.T) { t.Fatalf("the Undo target lies on %q", got) } _, _ = a.wallPress(undo.x0+1, undo.y0) - if !reflect.DeepEqual(a.wall.teams, prior) { + // The same teams, with the times compared as instants: once the Apply's + // write is back the window holds times read from the file, whose location + // is UTC's and not the clock's (teamwritesaid.go makes `Organized` wait + // for that write). + instants := func(ts []team) []team { + out := teamsClone(ts) + for i := range out { + out[i].Made, out[i].ClosedAt = out[i].Made.UTC(), out[i].ClosedAt.UTC() + } + return out + } + if !reflect.DeepEqual(instants(a.wall.teams), instants(prior)) { t.Fatalf("Undo left %+v\nwant %+v", a.wall.teams, prior) } teamsFlush(t, a)