diff --git a/docs/changelog.md b/docs/changelog.md index a759391..c039793 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,10 +3,32 @@ ## Unreleased Teams are a thing you build and send work to, not a panel that fills in when a -run happens to assemble some. +run happens to assemble some — and the Live view shows them working. ### Added +- **The Live view is a floor, not a log.** Its centre is a 3D scene (three.js, + loaded only where WebGL exists; a flat map otherwise or on request): each team + at a table on its rug, the manager at the head with the team's board on the + wall behind them, members with monitors that light up when they work, tickets + on the table threaded to whoever holds them; contract conduits with packets + flowing between tables, amber when a consumer waits; green or red rims for + gates; several people work at once, each unmistakably (lit monitor, rising + code, a pool of light, the pop-out saying what they just said); the + pipeline's own phase agents stand on a stage under a screen naming the + current phase. The floor moves with the run — a dispatch arc when a manager sends + someone onto a ticket, a spark when a ticket changes hands, a drop when one + appears, a burst when one finishes or fails, idle people glancing at whoever + is working — and every change slides into a **feed** at the edge. Click a + person or a ticket for its **dossier** (status, manager or reports, tickets, + recent lines; every name a link), and the camera glides to it. Orbit, zoom, + follow the worker, spin. The phase track is a journey the run walks, and the + five side tabs are one activity column with the log by default. +- **Seats the pipeline fills.** When a pipeline needs a seat a team did not name + (a test phase, no tester), the composition, the preselect preview, the run + setup panel, the charter log and the floor all say who takes it and where + they came from (`seats` with `source`, `gaps` in words). + - **Send a request to teams.** The Teams page has a *Send a request* section: type a request and see the teams it selects and why, who staffs and manages each, what the selection does to the run, and the pipeline the composer would diff --git a/docs/squads.md b/docs/squads.md index a647ef5..2e8f934 100644 --- a/docs/squads.md +++ b/docs/squads.md @@ -781,6 +781,20 @@ that decision: it does not ask the manager agent to invent a second team. Only a library with nothing to say (no team matched, nothing pinned) hands the question to the model. +### When the pipeline needs a seat the team does not have + +A team is written once and a pipeline is chosen per run, so the two need not +agree: the pipeline has a test phase and the team names no tester. The seat is +**filled from the pipeline** — the test phase's agent, or the execute loop's +worker or reviewer for those seats — and the fact is said everywhere the team +is shown: the composition (`seats`, with a `source` of `team`, `pipeline` or +`default`, and `gaps` in words), the run setup panel, the Teams page preview, +the charter log line *team backend-go names no tester — the pipeline's +go-tester takes its tester seat*, and the Live floor, where the borrowed tester +sits at the team's table drawn as borrowed. Routing still prefers a specialist +for the language of a task's own files over any seat; the seat is the answer +when the files say nothing. Name the seat on the team to make it the team's own. + ### The composer knows the teams The dynamic pipeline composer used to decide phases and loop roles with no idea diff --git a/docs/studio.md b/docs/studio.md index f672be6..5511040 100644 --- a/docs/studio.md +++ b/docs/studio.md @@ -65,7 +65,7 @@ the binary. `make web-check` runs the SPA's lint, typecheck, tests and build. | Route | Page | What it does | |---|---|---| -| `/` | **Live** | SSE-streamed run: phases, `@agent` activity, token stream, event log, HITL modal | +| `/` | **Live** | The run as a place: the **team floor** (each team at its table with its manager, members, monitors and tickets; contract conduits between tables; a spark when a manager moves a ticket), the **phase journey** above it, a ticker of what is happening right now, and one **activity** column beside it — the log by default, with tasks, fixes, files and the result as filters | | `/board` | **Board** | Kanban — add, edit, delete, move, delegate, drag mid-run | | `/review` | **Review** | Pending changes from `permission: review`, as diffs, with per-file apply/reject | | `/runs` | **Runs** | Run history, and a per-run **trace** with per-phase wall time and token/cost attribution | @@ -84,6 +84,59 @@ stream health, and an error boundary keeps one broken panel from blanking the ap --- +## The Live floor + +The centre of the Live view is a 3D floor, drawn with three.js: one round table +per team on its own rug, the manager at the head with the team's board on the +wall behind them (a column per state, a tile per ticket, the counts above), the +members around the table, each with a monitor that lights up when the log says +that agent is working and a pop-out over their head naming the ticket. Tickets +lie on the table, colored by state, and a thread runs from each one to the +monitor of whoever holds it, with beads travelling along it while they type. +The frozen contract runs between tables as conduits with packets flowing from +the provider to the consumer; a consumer waiting on a clause it has not been +given turns its conduit amber. A gate paints the table's rim green or red. + +Whoever is working is unmistakable: their monitor lights up, lines of code +rise off the screen, a pool of light and a breathing ring mark the seat, and +the pop-out over their head says which ticket and what they just said. Several +people can be at it at once — one per ticket in flight — and idle people glance +at whoever is. The pipeline's own people — the planner, splitter, architect, +explorer and the rest, who sit at no table — stand on a **stage** at the left +under a screen naming the phase the run is in and what is being said; the one +speaking is lit, the rest wait in the wings until their phase. + +The floor moves when the run does. A manager sending someone onto a ticket is +an arc from the head seat with the ticket's name; a ticket moving from one +person to another is a spark across the table with the reason; a ticket that +appears drops onto the table, one that finishes or fails bursts; idle people +glance at whoever is working. Every change also slides in as a card in the +**feed** at the right edge — *T4 appeared on Backend*, *go-worker started on +T2*, *T2 done ✓*, *Backend: gate green* — for twenty seconds, and each card is a +link. + +Everything is clickable. A person opens their **dossier**: seat and team, who +manages them (or, for a manager, who they manage and what they do), whether they +are working right now and on what, the last thing they said, the tickets they +hold and have touched, and their recent lines from the log. A ticket opens its +own: state, holder, everyone who has touched it, any handoff, and *open in +Tasks*. Every name in a dossier is a link to the next one, so the floor can be +explored by clicking around; the camera glides to whatever is selected. Drag to +orbit, wheel to zoom, right-drag to pan, click a table to frame it, **follow** +to keep whoever is working in the middle, **spin** for a slow tour, Esc or a +click on empty floor to clear. + +A run with no org chart shows the **pipeline crew** — the planner, splitter, +worker, reviewer and tester the composition chose — at one table, and a single +library team staffing a run shows as that team, with its manager, board and +gate. A seat the team left empty and the pipeline fills (a tester on a team that +names none) sits at the table too, drawn as borrowed and labelled *from the +pipeline*. + +Where WebGL is unavailable, or on request (the **3D / map** toggle), the same +floor is drawn as a flat map with the same dossier and feed. Both honor +`prefers-reduced-motion`. + ## The review workflow Set `permission: review` and agent writes stop being writes — they become proposals. diff --git a/pkg/composer/composer.go b/pkg/composer/composer.go index 652885e..51d718c 100644 --- a/pkg/composer/composer.go +++ b/pkg/composer/composer.go @@ -119,6 +119,11 @@ type TeamChoice struct { Reason string `json:"reason,omitempty"` Pinned bool `json:"pinned,omitempty"` Score int `json:"score,omitempty"` + // Seats is how the four seats are actually staffed on this run once the + // pipeline fills what the team left empty — see FillSeats. Gaps are the + // borrowed working seats, in words. + Seats []SeatFill `json:"seats,omitempty"` + Gaps []string `json:"gaps,omitempty"` } // Team modes — how the chosen teams shape the run. @@ -238,6 +243,12 @@ func (c *Composition) Normalize() { t.Agents = cleanList(t.Agents) t.Skills = cleanList(t.Skills) t.Reason = strings.TrimSpace(t.Reason) + for i := range t.Seats { + t.Seats[i].Role = strings.ToLower(strings.TrimSpace(t.Seats[i].Role)) + t.Seats[i].Agent = strings.ToLower(strings.TrimSpace(t.Seats[i].Agent)) + t.Seats[i].Source = strings.ToLower(strings.TrimSpace(t.Seats[i].Source)) + } + t.Gaps = cleanListPreserveCase(t.Gaps) teams = append(teams, t) } c.Teams = teams diff --git a/pkg/composer/seats.go b/pkg/composer/seats.go new file mode 100644 index 0000000..d5dc780 --- /dev/null +++ b/pkg/composer/seats.go @@ -0,0 +1,112 @@ +package composer + +import "strings" + +// ── Filling a team's seats from the pipeline ───────────────────────────── +// +// A team is written once and a pipeline is chosen per run, so the two do not +// have to agree: the pipeline has a test phase and the team names no tester; +// the team is a worker and a manager and nothing else. Routing already copes +// — a seat the team leaves empty falls to the run's default for that role — +// but silently, and a user who built a team without a tester and then watched +// "the tester" reject its work had no way to know who that was or why. +// +// FillSeats is the same fallback, written down: for each of the four seats, +// who will actually sit in it on this run and where they came from. It is +// what the composition, the run setup panel, the Teams page and the floor all +// show, so the answer is the same everywhere. + +// Seat sources. +const ( + // SeatFromTeam is the team's own choice. + SeatFromTeam = "team" + // SeatFromPipeline is the pipeline's agent for that role — the execute + // loop's worker or reviewer, or the test phase's agent. + SeatFromPipeline = "pipeline" + // SeatFromDefault is the harness default when neither named one. + SeatFromDefault = "default" +) + +// SeatFill is one seat, staffed. +type SeatFill struct { + Role string `json:"role"` + Agent string `json:"agent"` + Source string `json:"source"` +} + +// Borrowed reports whether this seat is filled from outside the team. +func (s SeatFill) Borrowed() bool { return s.Source != SeatFromTeam } + +// SeatDefaults are the pipeline's agents for the three working seats. +type SeatDefaults struct { + Worker string + Reviewer string + Tester string +} + +// FillSeats staffs the four seats of a team. +// +// managerDefault says the manager given is the run default rather than the +// team's own (see agents.ResolveManager); it is reported as such rather than +// as a pipeline pick, because a manager is not a pipeline role. +func FillSeats(worker, reviewer, tester, manager string, managerDefault bool, d SeatDefaults) []SeatFill { + pick := func(role, own, pipe, fallback string) SeatFill { + if own = strings.TrimSpace(own); own != "" { + return SeatFill{Role: role, Agent: own, Source: SeatFromTeam} + } + if pipe = strings.TrimSpace(pipe); pipe != "" { + return SeatFill{Role: role, Agent: pipe, Source: SeatFromPipeline} + } + return SeatFill{Role: role, Agent: fallback, Source: SeatFromDefault} + } + out := []SeatFill{ + pick("worker", worker, d.Worker, "worker"), + pick("reviewer", reviewer, d.Reviewer, "reviewer"), + pick("tester", tester, d.Tester, "tester"), + } + m := SeatFill{Role: "manager", Agent: strings.TrimSpace(manager), Source: SeatFromTeam} + if m.Agent == "" { + m.Agent = "triage" + } + if managerDefault { + m.Source = SeatFromDefault + } + return append(out, m) +} + +// Gaps says, in words, which seats a team did not fill itself — one line per +// borrowed working seat, in the form the charter phase emits and the panels +// show. The manager is left out: every team has one by construction. +func Gaps(teamID string, seats []SeatFill) []string { + var out []string + for _, s := range seats { + if s.Role == "manager" || !s.Borrowed() { + continue + } + from := "the pipeline's" + if s.Source == SeatFromDefault { + from = "the default" + } + out = append(out, "team "+teamID+" names no "+s.Role+" — "+from+" "+s.Agent+" takes its "+s.Role+" seat") + } + return out +} + +// TesterAgent finds the agent bound to the test phase of a composition, or "". +func (c Composition) TesterAgent() string { + for _, p := range c.Phases { + if p.ID == "test" && p.Enabled && p.When != "never" { + return p.Agent + } + } + return "" +} + +// SeatDefaultsOf reads the pipeline seats a composition settled on. +func (c Composition) SeatDefaultsOf() SeatDefaults { + return SeatDefaults{ + Worker: c.Execute.DefaultRole, + Reviewer: c.Execute.Reviewer, + Tester: c.TesterAgent(), + } +} diff --git a/pkg/composer/seats_test.go b/pkg/composer/seats_test.go new file mode 100644 index 0000000..f0deae1 --- /dev/null +++ b/pkg/composer/seats_test.go @@ -0,0 +1,58 @@ +package composer + +import ( + "strings" + "testing" +) + +// A team is written once and a pipeline is chosen per run: the pipeline has a +// test phase and the team names no tester. The seat is filled from the +// pipeline, and the gap is said in words. +func TestFillSeatsBorrowsWhatTheTeamLeftEmpty(t *testing.T) { + d := SeatDefaults{Worker: "worker", Reviewer: "reviewer", Tester: "go-tester"} + seats := FillSeats("go-worker", "", "", "backend-triage", false, d) + want := []SeatFill{ + {Role: "worker", Agent: "go-worker", Source: SeatFromTeam}, + {Role: "reviewer", Agent: "reviewer", Source: SeatFromPipeline}, + {Role: "tester", Agent: "go-tester", Source: SeatFromPipeline}, + {Role: "manager", Agent: "backend-triage", Source: SeatFromTeam}, + } + if len(seats) != len(want) { + t.Fatalf("seats=%+v", seats) + } + for i := range want { + if seats[i] != want[i] { + t.Fatalf("seat %d = %+v, want %+v", i, seats[i], want[i]) + } + } + gaps := Gaps("backend-go", seats) + if len(gaps) != 2 || !strings.Contains(gaps[1], "names no tester") || !strings.Contains(gaps[1], "go-tester takes its tester seat") { + t.Fatalf("gaps=%v", gaps) + } + + // No pipeline pick either: the harness default, reported as such. + seats = FillSeats("", "", "", "", true, SeatDefaults{}) + if seats[2] != (SeatFill{Role: "tester", Agent: "tester", Source: SeatFromDefault}) { + t.Fatalf("default tester=%+v", seats[2]) + } + if seats[3] != (SeatFill{Role: "manager", Agent: "triage", Source: SeatFromDefault}) { + t.Fatalf("default manager=%+v", seats[3]) + } + if got := Gaps("x", seats); len(got) != 3 || !strings.Contains(got[0], "the default worker") { + t.Fatalf("gaps=%v", got) + } +} + +func TestSeatDefaultsComeFromTheCompositionsBoundRoles(t *testing.T) { + c := Composition{ + Execute: ExecuteChoice{DefaultRole: "react-worker", Reviewer: "react-reviewer"}, + Phases: []PhaseChoice{{ID: "execute", Agent: "react-worker", Enabled: true}, {ID: "test", Agent: "react-tester", Enabled: true}}, + } + if d := c.SeatDefaultsOf(); d != (SeatDefaults{Worker: "react-worker", Reviewer: "react-reviewer", Tester: "react-tester"}) { + t.Fatalf("defaults=%+v", d) + } + c.Phases[1].Enabled = false + if c.TesterAgent() != "" { + t.Fatalf("a disabled test phase lends no tester") + } +} diff --git a/pkg/orchestrator/compose_teams.go b/pkg/orchestrator/compose_teams.go index a7b02a4..780c4b1 100644 --- a/pkg/orchestrator/compose_teams.go +++ b/pkg/orchestrator/compose_teams.go @@ -240,10 +240,26 @@ func composeTeams(comp *composer.Composition, td teamDecision) { return } comp.Teams, comp.TeamMode, comp.TeamNote = td.Choices, td.Mode, td.Note - if td.Mode != composer.TeamModeSingle || len(td.Choices) == 0 { + if td.Mode == composer.TeamModeSingle && len(td.Choices) > 0 { + adoptTeamStaffing(comp, td.Choices[0]) + } + fillTeamSeats(comp) +} + +// fillTeamSeats writes, on every team, who actually sits in each seat once +// the pipeline fills what the team left empty — and the gaps in words. Done +// after adoption and the critical-phase repair so the pipeline roles it reads +// are the ones the run will bind. +func fillTeamSeats(comp *composer.Composition) { + if comp == nil { return } - adoptTeamStaffing(comp, td.Choices[0]) + d := comp.SeatDefaultsOf() + for i := range comp.Teams { + t := &comp.Teams[i] + t.Seats = composer.FillSeats(t.Worker, t.Reviewer, t.Tester, t.Manager, t.ManagerDefault, d) + t.Gaps = composer.Gaps(t.ID, t.Seats) + } } func adoptTeamStaffing(comp *composer.Composition, t composer.TeamChoice) { @@ -348,12 +364,15 @@ func teamHandoff(comp *composer.Composition) { case composer.TeamModeSingle: t := comp.Teams[0] var staff []string - for _, s := range []struct{ role, id string }{ - {"worker", t.Worker}, {"reviewer", t.Reviewer}, {"tester", t.Tester}, {"manager", t.Manager}, - } { - if s.id != "" { - staff = append(staff, s.role+"="+s.id) + for _, s := range t.Seats { + if s.Agent == "" { + continue + } + entry := s.Role + "=" + s.Agent + if s.Borrowed() && s.Role != "manager" { + entry += " (" + s.Source + ")" } + staff = append(staff, entry) } line := "Team " + t.ID + " staffs this run" if len(staff) > 0 { @@ -396,6 +415,13 @@ func teamSeats(comp *composer.Composition, skills map[string]bool) { want := filterKnownSkills(t.Skills, skills) ids := []string{t.Worker, t.Reviewer, t.Tester, t.Manager} ids = append(ids, t.Agents...) + // A borrowed seat is on the team for this run: whoever the pipeline + // lends as tester loads the team's skills like the team's own would. + for _, seat := range t.Seats { + if seat.Borrowed() && seat.Role != "manager" { + ids = append(ids, seat.Agent) + } + } for _, id := range ids { id = strings.ToLower(strings.TrimSpace(id)) if id == "" { @@ -495,6 +521,9 @@ func teamsMarkdown(c composer.Composition) string { b.WriteString(" — " + t.Reason) } b.WriteString("\n") + for _, g := range t.Gaps { + b.WriteString(" - " + g + "\n") + } } b.WriteString("\n") return b.String() diff --git a/pkg/orchestrator/compose_teams_test.go b/pkg/orchestrator/compose_teams_test.go index 1071208..3dd9316 100644 --- a/pkg/orchestrator/compose_teams_test.go +++ b/pkg/orchestrator/compose_teams_test.go @@ -399,3 +399,58 @@ func TestPreviewWithPinsAppliesTheFactoryChecks(t *testing.T) { t.Fatalf("preview leaked pins into the config: %v", o.cfg.Teams) } } + +// A team without a tester on a pipeline with a test phase: the composition +// says who tests its work and where they came from, and the charter phase +// says the same thing out loud. +func TestATeamWithoutATesterBorrowsThePipelines(t *testing.T) { + root := fullstackRoot(t) + teamsDir := filepath.Join(root, ".slmcode", "blocks", "teams") + if err := os.MkdirAll(teamsDir, 0o750); err != nil { + t.Fatal(err) + } + // Overrides the builtin: same id, no tester, no reviewer. + team := "api_version: blocks/v1\nkind: team\nid: backend-go\nname: Backend\nspec:\n id: backend-go\n" + + " owns: [cmd/**, go.mod]\n worker: go-worker\n match:\n files: [go.mod]\n extensions: [.go]\n" + if err := os.WriteFile(filepath.Join(teamsDir, "backend-go.yaml"), []byte(team), 0o600); err != nil { + t.Fatal(err) + } + o, err := New(config.Default(root)) + if err != nil { + t.Fatal(err) + } + comp := o.PreviewComposition("add a Go API endpoint and the React page that calls it") + tc, ok := comp.TeamChoiceFor("backend-go") + if !ok { + t.Fatalf("teams=%+v", comp.Teams) + } + bySeat := map[string]composer.SeatFill{} + for _, s := range tc.Seats { + bySeat[s.Role] = s + } + if bySeat["worker"] != (composer.SeatFill{Role: "worker", Agent: "go-worker", Source: composer.SeatFromTeam}) { + t.Fatalf("worker=%+v", bySeat["worker"]) + } + tester := bySeat["tester"] + if tester.Source != composer.SeatFromPipeline || tester.Agent == "" { + t.Fatalf("the tester should be borrowed from the pipeline's test phase: %+v (phases=%+v)", tester, comp.Phases) + } + if len(tc.Gaps) == 0 || !strings.Contains(strings.Join(tc.Gaps, "\n"), "names no tester") { + t.Fatalf("gaps=%v", tc.Gaps) + } + // The borrowed tester is on the roster with the team's skills. + roles := map[string]bool{} + for _, m := range comp.Team { + roles[m.Role] = true + } + if !roles[tester.Agent] { + t.Fatalf("borrowed tester %s not on the roster: %+v", tester.Agent, comp.Team) + } + + // The charter phase reports the same gap from the pipeline it is bound to. + p := squads.Plan{Squads: []squads.Squad{{ID: "backend-go", Worker: "go-worker", Owns: []string{"cmd/**"}}}} + gaps := o.seatGaps(&p) + if len(gaps) != 2 || !strings.Contains(gaps[1], "team backend-go names no tester") { + t.Fatalf("charter gaps=%v", gaps) + } +} diff --git a/pkg/orchestrator/composer.go b/pkg/orchestrator/composer.go index f7b6abe..6cef0d0 100644 --- a/pkg/orchestrator/composer.go +++ b/pkg/orchestrator/composer.go @@ -316,6 +316,10 @@ func (o *Orchestrator) prepareDynamicComposition(comp *composer.Composition, que lang = detectProjectLang(o.cfg.Root) } ensureCompositionHandoff(comp, query, inventory, lang, workerHint, testerHint) + // The seats are settled only now: the language hint and the critical-phase + // repair above may have bound execute/test, and a team's empty tester seat + // is filled by whatever the test phase ended up with. + fillTeamSeats(comp) teamHandoff(comp) if o != nil { skills := o.availableSkillNames() diff --git a/pkg/orchestrator/teams.go b/pkg/orchestrator/teams.go index c50d2ea..e4504b4 100644 --- a/pkg/orchestrator/teams.go +++ b/pkg/orchestrator/teams.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/UnicoLab/slmcode/pkg/blocks" + "github.com/UnicoLab/slmcode/pkg/composer" "github.com/UnicoLab/slmcode/pkg/plan" "github.com/UnicoLab/slmcode/pkg/squads" "github.com/UnicoLab/slmcode/pkg/teams" @@ -117,6 +118,12 @@ func (o *Orchestrator) teamsFromLibrary(ctx context.Context, query string, inven o.emitWarn("charter", note, "") } } + // A seat the team left empty is filled from the pipeline the run is on — + // said here, once per gap, so "who tested the backend's work" has an + // answer in the log and on the floor rather than only in the routing code. + for _, gap := range o.seatGaps(&p) { + o.emit("charter", gap, "") + } // Report the evidence before the contract call, so a preselection the user // disagrees with is visible even if the model call then fails. @@ -390,3 +397,24 @@ func resolveInterfaces(p *squads.Plan, in []squads.Interface) (kept []squads.Int sort.Strings(dropped) return kept, dropped, implied } + +// seatGaps lists, for every squad, the working seats it did not fill and who +// the pipeline lends for them — the same rule the composition applies +// (composer.FillSeats), read from the pipeline this run is bound to. +func (o *Orchestrator) seatGaps(p *squads.Plan) []string { + if o == nil || p == nil { + return nil + } + pipe := o.Pipeline() + d := composer.SeatDefaults{ + Worker: pipe.Execute.DefaultRole, + Reviewer: pipe.Execute.Reviewer, + Tester: pipe.PhaseAgent("test", plan.RoleTester), + } + var out []string + for _, s := range p.Squads { + manager, isDefault := o.effectiveManager(s.Manager) + out = append(out, composer.Gaps(s.ID, composer.FillSeats(s.Worker, s.Reviewer, s.Tester, manager, isDefault, d))...) + } + return out +} diff --git a/pkg/server/team_activity_test.go b/pkg/server/team_activity_test.go index 5ca6963..1638877 100644 --- a/pkg/server/team_activity_test.go +++ b/pkg/server/team_activity_test.go @@ -320,6 +320,15 @@ func TestPreselectReportsModeAndStaffing(t *testing.T) { if first["manager"] != "triage" || first["manager_default"] != true { t.Fatalf("staffing=%v", first) } + // Every seat is accounted for, with its source: the builtin names all + // three working seats itself, so nothing is borrowed. + seats, _ := first["seats"].([]interface{}) + if len(seats) != 4 { + t.Fatalf("seats=%v", first["seats"]) + } + if gaps, _ := first["gaps"].([]interface{}); len(gaps) != 0 { + t.Fatalf("a fully staffed builtin has no gaps: %v", gaps) + } // A pin is additive: the workspace's own markers still put the other half // on the run, and the pinned team leads. diff --git a/pkg/server/teams.go b/pkg/server/teams.go index 23ad2df..ff66e44 100644 --- a/pkg/server/teams.go +++ b/pkg/server/teams.go @@ -10,6 +10,7 @@ import ( "github.com/UnicoLab/slmcode/pkg/agents" "github.com/UnicoLab/slmcode/pkg/blocks" + "github.com/UnicoLab/slmcode/pkg/composer" "github.com/UnicoLab/slmcode/pkg/config" "github.com/UnicoLab/slmcode/pkg/orchestrator" "github.com/UnicoLab/slmcode/pkg/plan" @@ -312,13 +313,19 @@ func (s *Server) preselectView(reg *blocks.Registry, query string, pinned []stri // same answer the composition carries (composer.TeamChoice), so the page // and the run setup panel never disagree about a team's manager. managers := s.managerSet() + defaults := s.pipelineSeatDefaults() staffed := make([]map[string]interface{}, 0, len(p.Squads)) for _, sq := range p.Squads { manager, isDefault := effectiveManager(sq.Manager, managers) + seats := composer.FillSeats(sq.Worker, sq.Reviewer, sq.Tester, manager, isDefault, defaults) staffed = append(staffed, map[string]interface{}{ "id": sq.ID, "name": sq.Name, "worker": sq.Worker, "reviewer": sq.Reviewer, "tester": sq.Tester, "manager": manager, "manager_default": isDefault, "agents": sq.Agents, "skills": sq.Skills, "owns": sq.Owns, "acceptance": sq.Acceptance, + // Who actually sits where once the pipeline fills the empty seats, + // and the gaps in words — the answer the composition gives too. + "seats": seats, + "gaps": composer.Gaps(sq.ID, seats), }) } mode, note := teamModeNote(sel, cfg.Squads) @@ -743,3 +750,17 @@ func limitPaths(in []string, n int) []string { } return append(append([]string{}, in[:n]...), fmt.Sprintf("+%d more", len(in)-n)) } + +// pipelineSeatDefaults reads the seats the active pipeline lends a team. +func (s *Server) pipelineSeatDefaults() composer.SeatDefaults { + o := s.orch() + if o == nil { + return composer.SeatDefaults{Worker: plan.RoleWorker, Reviewer: plan.RoleReviewer, Tester: plan.RoleTester} + } + pipe := o.Pipeline() + return composer.SeatDefaults{ + Worker: pipe.Execute.DefaultRole, + Reviewer: pipe.Execute.Reviewer, + Tester: pipe.PhaseAgent("test", plan.RoleTester), + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 7ca6785..0bb599f 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -11,11 +11,14 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@react-three/drei": "^10.7.8", + "@react-three/fiber": "^9.7.0", "clsx": "^2.1.1", "lucide-react": "^0.438.0", - "react": "^19.2.8", - "react-dom": "^19.2.8", - "react-router-dom": "^6.26.0" + "react": "19.2.8", + "react-dom": "19.2.8", + "react-router-dom": "^6.26.0", + "three": "^0.186.0" }, "devDependencies": { "@eslint/js": "^9.9.0", @@ -24,6 +27,7 @@ "@testing-library/user-event": "^14.5.2", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.5", + "@types/three": "^0.185.4", "@vitejs/plugin-react": "^6.1.0", "@vitest/coverage-v8": "^4.1.11", "autoprefixer": "^10.4.20", @@ -313,7 +317,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -530,6 +533,12 @@ "node": ">=20.19.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", @@ -874,6 +883,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "license": "Apache-2.0" + }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -922,6 +949,94 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@react-three/drei": { + "version": "10.7.8", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.8.tgz", + "integrity": "sha512-rJXyuzLm2Xq0kafHuR47ajDGbOe/pEhzIr4m8E8zwzQs0iNjloFDqBwRhrXmP/w+onLeYyN3EYPFW/cwWK/4yA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^3.1.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.8.3", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.4", + "tunnel-rat": "^0.1.2", + "use-sync-external-store": "^1.4.0", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" + }, + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19", + "react-dom": "^19", + "three": ">=0.159" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/@react-three/fiber": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.7.0.tgz", + "integrity": "sha512-EWm9FwcaOZQu/ExFW5rggoCMM1NJet5YbxVxKaOE+KSncrjU0Wx7017qSyGFvupviK89nMYGCWU3BIK4dI1clw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" + }, + "peerDependencies": { + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, "node_modules/@remix-run/router": { "version": "1.23.3", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", @@ -1290,6 +1405,12 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -1316,6 +1437,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -1330,11 +1457,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1350,6 +1482,41 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.185.4", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.4.tgz", + "integrity": "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", @@ -1645,6 +1812,24 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.0.tgz", @@ -2157,6 +2342,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.11.12", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", @@ -2174,7 +2379,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" @@ -2251,6 +2455,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -2321,6 +2549,19 @@ "node": ">= 6" } }, + "node_modules/camera-controls": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", + "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.5.1" + }, + "peerDependencies": { + "three": ">=0.126.1" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001806", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", @@ -2460,11 +2701,28 @@ "dev": true, "license": "MIT" }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -2513,7 +2771,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -2684,6 +2941,15 @@ "node": ">=6" } }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2716,6 +2982,12 @@ "license": "MIT", "peer": true }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3265,6 +3537,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -3538,6 +3816,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -3662,6 +3946,12 @@ "hermes-estree": "0.25.1" } }, + "node_modules/hls.js": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.7.2.tgz", + "integrity": "sha512-CW/pPvSOFIRsosbwxrYaE9ERmpTo5fbTqL7wCvuCFlqW1Bmb1K5fXsY7yiH95rmlr4rVuA7UXHbM/cIXQ6AXwg==", + "license": "Apache-2.0" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -3682,6 +3972,26 @@ "dev": true, "license": "MIT" }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3692,6 +4002,12 @@ "node": ">= 4" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -4027,6 +4343,12 @@ "dev": true, "license": "MIT" }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -4183,7 +4505,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -4225,6 +4546,18 @@ "node": ">=8" } }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -4423,6 +4756,15 @@ "node": ">= 0.8.0" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -4757,6 +5099,16 @@ "lz-string": "bin/bin.js" } }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4835,6 +5187,21 @@ "node": ">= 8" } }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5162,7 +5529,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5395,6 +5761,12 @@ "dev": true, "license": "MIT" }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5435,6 +5807,16 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5527,6 +5909,21 @@ "react-dom": ">=16.8" } }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/read-cache": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", @@ -5612,7 +6009,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5856,7 +6252,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -5869,7 +6264,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5975,6 +6369,32 @@ "dev": true, "license": "MIT" }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "license": "MIT" + }, "node_modules/std-env": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", @@ -6146,6 +6566,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -6214,6 +6643,44 @@ "node": ">=0.8" } }, + "node_modules/three": { + "version": "0.186.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.186.0.tgz", + "integrity": "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ==", + "license": "MIT" + }, + "node_modules/three-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.11.tgz", + "integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -6348,6 +6815,36 @@ "node": ">=20" } }, + "node_modules/troika-three-text": { + "version": "0.52.5", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.5.tgz", + "integrity": "sha512-Ry3jRhic9pzcY4JduSvRRyDmVOSqEW19gT4vtK+aCiPNVcDlmkxvGG0YbFd36RTDq1wExOupXnvNF/j1oiHHDA==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.5", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.5", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.5.tgz", + "integrity": "sha512-WsePbcX8RtfidRfsxK1eCZCjF81ZDzAKHH/evLs0hdV2wpoCb0vArGZHdzdOJrSS3k4zfdtbKDaBh8+phkrYnw==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -6374,6 +6871,43 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -6573,6 +7107,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6580,6 +7123,15 @@ "dev": true, "license": "MIT" }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/vite": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.2.tgz", @@ -6787,6 +7339,17 @@ "node": ">=18" } }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==" + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -6826,7 +7389,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -7013,6 +7575,35 @@ "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } + }, + "node_modules/zustand": { + "version": "5.0.15", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.15.tgz", + "integrity": "sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/web/package.json b/web/package.json index 38f8c9d..21a346f 100644 --- a/web/package.json +++ b/web/package.json @@ -19,11 +19,14 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@react-three/drei": "^10.7.8", + "@react-three/fiber": "^9.7.0", "clsx": "^2.1.1", "lucide-react": "^0.438.0", - "react": "^19.2.8", - "react-dom": "^19.2.8", - "react-router-dom": "^6.26.0" + "react": "19.2.8", + "react-dom": "19.2.8", + "react-router-dom": "^6.26.0", + "three": "^0.186.0" }, "devDependencies": { "@eslint/js": "^9.9.0", @@ -32,6 +35,7 @@ "@testing-library/user-event": "^14.5.2", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.5", + "@types/three": "^0.185.4", "@vitejs/plugin-react": "^6.1.0", "@vitest/coverage-v8": "^4.1.11", "autoprefixer": "^10.4.20", diff --git a/web/src/components/Live/ActivityRail.tsx b/web/src/components/Live/ActivityRail.tsx new file mode 100644 index 0000000..e169dbd --- /dev/null +++ b/web/src/components/Live/ActivityRail.tsx @@ -0,0 +1,158 @@ +import { useMemo } from 'react'; +import type { ReactNode } from 'react'; +import { CheckCircle2, FolderTree, ListTodo, ScrollText, Wrench, XCircle, PanelRightClose } from 'lucide-react'; +import clsx from 'clsx'; +import EventLog from './EventLog'; +import LiveTaskPanel from './LiveTaskPanel'; +import LiveFileInspector from './LiveFileInspector'; +import CalibrationBanner from './CalibrationBanner'; +import TokenStream from './TokenStream'; +import RecoveryPanel from './RecoveryPanel'; +import ResultPanel from './ResultPanel'; +import LiveFeedback from './LiveFeedback'; +import { buildRecovery, recoveryTally } from './recovery'; +import { useStickToBottom } from '@/hooks/useUiState'; +import type { LatestRunResponse, RunEvent } from '@/types'; + +// ── One column for everything the stage does not draw ──────────────────── +// +// The Live page used to be a log in the middle and five tabs on the side. The +// floor now takes the middle, and the side is ONE column: the log, first and +// by default, with the task list, the self-healing record, the file changes +// and the result reachable as filters on the same column rather than as a +// second navigation. A run is one stream of things happening; this is the +// place to read it, and the floor is the place to watch it. + +export type RailView = 'log' | 'tasks' | 'fixes' | 'files' | 'result'; + +export interface ActivityRailProps { + events: RunEvent[]; + running: boolean; + result: LatestRunResponse | null; + tokenStream: string; + view: RailView; + onView: (v: RailView) => void; + onClose?: () => void; + /** True when the rail is an overlay (narrow viewport), so it shows a close button. */ + overlay?: boolean; +} + +export default function ActivityRail({ events, running, result, tokenStream, view, onView, onClose, overlay }: ActivityRailProps) { + const logRef = useStickToBottom(events, view === 'log'); + const fixes = useMemo(() => recoveryTally(buildRecovery(events)), [events]); + const fixesBadge = + fixes.needsYou > 0 ? String(fixes.needsYou) : fixes.healing > 0 ? String(fixes.healing) : fixes.resolved > 0 ? String(fixes.resolved) : undefined; + const fileCount = useMemo(() => { + const set = new Set(); + for (const e of events) { + if (e.kind === 'file_change' && e.scope) set.add(e.scope); + } + return set.size; + }, [events]); + const taskCount = useMemo(() => { + const ids = new Set(); + for (const e of events) if (e.task_id) ids.add(e.task_id); + return ids.size; + }, [events]); + + return ( +
+
+ onView('log')} icon={} label="Log" badge={events.length > 0 ? compact(events.length) : undefined} /> + onView('tasks')} icon={} label="Tasks" badge={taskCount > 0 ? String(taskCount) : undefined} /> + onView('fixes')} + icon={ 0 ? 'text-amber-500' : undefined} />} + label="Fixes" + badge={fixesBadge} + tone={fixes.needsYou > 0 ? 'warn' : undefined} + /> + onView('files')} icon={} label="Files" badge={fileCount > 0 ? String(fileCount) : undefined} /> + onView('result')} + icon={ + result?.result ? ( + result.result.success ? : + ) : ( + + ) + } + label="Result" + /> + {overlay && onClose && ( + + )} +
+ +
+ {view === 'log' && ( +
+ {events.length === 0 ? ( +

+ {running ? 'Waiting for the first event…' : 'The run’s log streams here — phases, agents, tool calls and file writes.'} +

+ ) : ( +
+ + + +
+ )} +
+ )} + {view === 'tasks' && } + {view === 'fixes' && ( +
+ +
+ )} + {view === 'files' && } + {view === 'result' && } +
+ + {/* Feedback docks to the bottom of the column, next to what you are + replying to. */} +
+ +
+
+ ); +} + +function Chip({ active, onClick, icon, label, badge, tone }: { active: boolean; onClick: () => void; icon: ReactNode; label: string; badge?: string; tone?: 'warn' }) { + return ( + + ); +} + +function compact(n: number): string { + return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n); +} diff --git a/web/src/components/Live/LiveView.tsx b/web/src/components/Live/LiveView.tsx index 4b2b8e5..a97afa8 100644 --- a/web/src/components/Live/LiveView.tsx +++ b/web/src/components/Live/LiveView.tsx @@ -2,18 +2,12 @@ import { useState, useEffect, useRef, useContext, useMemo } from 'react'; import { AlertTriangle, Bot, - CheckCircle2, Circle, - FolderTree, - ListTodo, Loader2, PanelRightClose, PanelRightOpen, Play, Square, - Users, - Wrench, - XCircle, } from 'lucide-react'; import { AppContext } from '@/App'; import { @@ -27,6 +21,7 @@ import { getInterruptedRuns, resumeRun, getSquads, + getTasks, } from '@/api/client'; import type { AgentSpec, @@ -34,49 +29,44 @@ import type { DynamicComposition, InterruptedRun, SquadsView, + Task, TeamSpec, } from '@/types'; -import EventLog from './EventLog'; -import LiveTaskPanel from './LiveTaskPanel'; -import LiveFileInspector from './LiveFileInspector'; -import LiveFeedback from './LiveFeedback'; -import CalibrationBanner from './CalibrationBanner'; -import TokenStream from './TokenStream'; -import SquadPanel from './SquadPanel'; import TeamPicker from './TeamPicker'; import NowBar from './NowBar'; -import RecoveryPanel from './RecoveryPanel'; -import ResultPanel from './ResultPanel'; -import { buildRecovery, recoveryTally } from './recovery'; import PhaseRail from './PhaseRail'; import type { PhaseState, RailGroup } from './PhaseRail'; import RunSetup from './RunSetup'; +import ActivityRail from './ActivityRail'; +import type { RailView } from './ActivityRail'; +import TeamFloor from './floor/TeamFloor'; +import { buildFloor } from './floor/floorModel'; import ResizeHandle from '@/components/ui/ResizeHandle'; import { useToast } from '@/components/ui/Toast'; import { FOCUS_PROMPT_EVENT } from '@/hooks/useKeyboard'; -import { usePersistentState, useMediaQuery, useStickToBottom } from '@/hooks/useUiState'; +import { usePersistentState, useMediaQuery } from '@/hooks/useUiState'; import clsx from 'clsx'; /** * The live run console. * - * The layout is four fixed zones, in priority order, and the ordering is the - * whole design: + * Four fixed zones, in priority order: * - * 1. Command bar — what you type and the button you press. Always one row - * on desktop; wraps on narrow screens. Never scrolls away. - * 2. Phase rail — where the run is, in ~44px. See PhaseRail. + * 1. Command bar — what you type and the button you press. Never scrolls. + * 2. Phase journey — where the run is, as one track it walks. See PhaseRail. * 3. Run setup — how the run is configured, behind a disclosure that is * open while idle and closed while running. See RunSetup. - * 4. Stream + rail — everything left over, which is most of the screen. + * 4. Floor + rail — the rest of the screen. The FLOOR is the teams at their + * tables: who is on which team, who manages them, which + * ticket each holds, who is working right now, what flows + * between teams and where one waits on another (see + * floor/). The RAIL is one column for everything the floor + * does not draw — the log by default, with tasks, fixes, + * files and the result as filters on it (see ActivityRail). * - * What changed and why: the previous version stacked six always-expanded - * panels above the log — a metrics grid, a progress card, a stage card, an - * agent-activity card, a composition panel and an active-agent panel. Each was - * individually reasonable and together they pushed the event stream, the one - * thing a page called "Live" exists to show, off a 900px viewport entirely. - * Every one of them survives here; they just had to stop competing with the - * stream for the same pixels at the same moment. + * The log used to be the centre of this page and five tabs sat beside it. A + * run is people doing things, and a person watching one for eleven minutes + * wants to SEE that; the lines are still there, one column to the right. */ const PIPELINE_GROUPS: RailGroup[] = [ @@ -87,8 +77,6 @@ const PIPELINE_GROUPS: RailGroup[] = [ { id: 'finish', label: 'Finish', phases: ['memory', 'done'] }, ]; -type RailTab = 'tasks' | 'teams' | 'fixes' | 'files' | 'result'; - /** Where the side rail becomes a column instead of an overlay. */ const WIDE_VIEWPORT = '(min-width: 1024px)'; @@ -113,24 +101,20 @@ export default function LiveView() { const setResult = ctx?.setLiveResult || (() => {}); const [query, setQuery] = useState(''); - // Persisted: a layout the user arranged has to survive a reload. - const [railTab, setRailTab] = usePersistentState('live.rail.tab', 'tasks'); - // The self-healing tally rides on the tab, so a user who never opens it still - // sees that something was found and something was done about it. - const fixes = useMemo(() => recoveryTally(buildRecovery(events)), [events]); - const fixesBadge = - fixes.needsYou > 0 - ? String(fixes.needsYou) - : fixes.healing > 0 - ? String(fixes.healing) - : fixes.resolved > 0 - ? String(fixes.resolved) - : undefined; + // Persisted: a layout the user arranged has to survive a reload. The old + // 'live.rail.tab' key held tab names this column no longer has. + const [railView, setRailView] = usePersistentState('live.rail.view', 'log'); const [railWidth, setRailWidth] = usePersistentState('live.rail.width', RAIL_DEFAULT_PX); - // The org chart, so the activity bar can say which TEAM the running task - // belongs to. Polled with the run rather than once: a run that assembles - // teams does so several phases in, long after this component mounted. + // The org chart and the board, so the floor can draw the teams, their + // tickets and which team the running task belongs to. Polled with the run + // rather than once: a run that assembles teams does so several phases in, + // long after this component mounted, and the board grows with every wave. const [squads, setSquads] = useState(null); + const [tasks, setTasks] = useState([]); + // True once the org chart and the board have been asked for at least once: + // the floor stays empty until then, so the first thing drawn is the real + // floor and not a crew table that the teams then replace. + const [boardReady, setBoardReady] = useState(false); // The rail is a column on a wide viewport and a full-height OVERLAY below it, // so its default cannot be the same on both: opening it by default on a phone // means the first thing a user sees is the task drawer covering the console @@ -160,10 +144,6 @@ export default function LiveView() { const [interrupted, setInterrupted] = useState([]); const [previewLoading, setPreviewLoading] = useState(false); const promptRef = useRef(null); - // Follows the stream only while the user is already AT the bottom. An - // unconditional scroll yanked the log back down every flush, which made - // reading anything mid-run impossible. - const logRef = useStickToBottom(events, true); // `/` focuses the prompt from anywhere in the app. useEffect(() => { @@ -179,14 +159,18 @@ export default function LiveView() { // second re-fetching a file that has not moved. useEffect(() => { let alive = true; + // Both halves land together: the floor decides between "teams" and + // "one crew" from the org chart, so tickets arriving a beat before the + // chart would draw every ticket on one crew table for a frame and then + // redraw it as teams. One settled pair, one paint. const load = () => { - getSquads() - .then((v) => { - if (alive) setSquads(v); - }) - .catch(() => { - /* no org chart is the normal state, not an error */ - }); + Promise.allSettled([getSquads(), getTasks()]).then(([chart, board]) => { + if (!alive) return; + // No org chart is the normal state, not an error; an empty board is a fact. + if (chart.status === 'fulfilled') setSquads(chart.value); + if (board.status === 'fulfilled') setTasks(board.value?.tasks ?? []); + setBoardReady(true); + }); }; load(); if (!running) return () => { alive = false; }; @@ -446,6 +430,21 @@ export default function LiveView() { return ids.size; }, [events]); + // The floor: everything the stage draws, derived once per change. + const floor = useMemo( + () => + boardReady + ? buildFloor({ squads, tasks, events, composition: shownComposition, running }) + : buildFloor({ squads: null, tasks: [], events: [], composition: null, running: false }), + [boardReady, squads, tasks, events, shownComposition, running], + ); + + // Picking a ticket on the floor opens it in the rail's task list. + const focusTicket = () => { + setRailView('tasks'); + setRailOpen(true); + }; + return (
{/* ── 1. Command bar ─────────────────────────────────────────── */} @@ -626,49 +625,22 @@ export default function LiveView() { compositionError={running ? '' : persistedCompositionError} /> - {/* ── 4. Stream + rail ───────────────────────────────────────── */} + {/* ── 4. Floor + rail ────────────────────────────────────────── */}
- {/* What is happening RIGHT NOW, pinned above the scroll. The log - answers "what happened" and scrolls it away; on a local 30B the - next line can be four minutes out, and a wall of finished lines - under a blinking cursor reads as a hang. */} - -
- {events.length === 0 ? ( - // Keyed on EVENTS alone, not on `result`. A finished run leaves a - // result behind, so the old `!result` clause meant that reopening - // Studio after any previous run showed a bare "waiting for - // events" line instead of the empty state — on exactly the visit - // where a first-time reader most needs to be told what to do. - // The result itself is one tab away in the rail. - - ) : ( - // 110ch is a reading measure, and it is right for prose. It is - // wrong for the tool output, diffs and JSON that make up most of - // this log — on a 27" screen it wrapped a 200-column stack trace - // inside a 900px ribbon with a third of the display blank. So the - // measure grows with the viewport instead of being one number. -
- {/* Calibration runs before the first token of a run, so its - progress sits above the stream that replaces it. */} - - - -
- )} -
- - {/* Feedback docks to the bottom of the stream, where a reply belongs — - next to what you are replying to, not in a header card above it. */} -
- +
+
+ {/* What is happening RIGHT NOW, pinned under the floor as a ticker. + On a local 30B the next log line can be four minutes out, and a + clock that keeps moving is the difference between "thinking" and + "hung". */} +
- {/* The rail is a column at ≥1024px and a full-height overlay below it. - An overlay rather than a stacked block: on a phone the stream and the - task list both want the whole screen, and splitting it gives neither + {/* The rail is a column at ≥1024px and a full-height OVERLAY below it. + An overlay rather than a stacked block: on a phone the floor and the + log both want the whole screen, and splitting it gives neither enough to be readable. */} {railOpen && ( <> @@ -678,9 +650,6 @@ export default function LiveView() { onClick={() => setRailOpen(false)} className="fixed inset-0 z-30 bg-black/30 lg:hidden" /> - {/* The divider exists only on a wide viewport: as an overlay the - rail has no neighbour to steal width from, so a horizontal drag - would mean nothing. */} {isWide && ( )} )} - -
-
- ); -} - -function RailTabButton({ - active, - onClick, - icon, - label, - badge, -}: { - active: boolean; - onClick: () => void; - badge?: string; - icon: React.ReactNode; - label: string; -}) { - return ( - - ); -} - -function EmptyState({ hasPreviousRun }: { hasPreviousRun: boolean }) { - return ( -
-
- -
-
-

- Nothing running yet -

-

- Describe a change above and press Run. Phases, agent activity and file - writes stream here as they happen. -

- {hasPreviousRun && ( -

- The previous run’s summary is under Result. -

- )}
); diff --git a/web/src/components/Live/PhaseRail.tsx b/web/src/components/Live/PhaseRail.tsx index b98d322..22cf796 100644 --- a/web/src/components/Live/PhaseRail.tsx +++ b/web/src/components/Live/PhaseRail.tsx @@ -1,23 +1,18 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; +import { Check } from 'lucide-react'; import clsx from 'clsx'; /** - * The pipeline as ONE horizontal track. + * The pipeline as ONE journey. * - * It replaces a stack of four separate header panels — a metrics grid, a - * percentage bar, a wrapped grid of phase chips, and a "current stage" card — - * that between them consumed roughly a third of the viewport to answer a single - * question: where in the run are we? On a laptop that pushed the live event log, - * which is the entire point of this page, below the fold. + * A single track from the first phase to the last, drawn as a path the run + * walks: phases are stops, the five groups are the colored stretches between + * them, the stretch already walked is lit and the stop the run is at pulses. + * Position along the track IS the progress, so the two can never disagree. * - * One track answers it in ~44px, and answers it better: position along the rail - * IS the progress bar, so the two can never disagree. - * - * Colour carries meaning and nothing else. Phases used to be tinted by a - * fifteen-entry per-phase palette, which reads as decoration because it is — - * nobody can hold fifteen hues to fifteen phase names. Here the tint is the - * phase's GROUP (five of them, and groups are a real concept in pipeline.yaml), - * and STATE is carried by fill and weight rather than by hue. + * Color carries the GROUP (five of them, and groups are a real concept in + * pipeline.yaml) and state is carried by fill, weight and motion — nobody can + * hold fifteen hues to fifteen phase names. */ export type PhaseState = 'pending' | 'active' | 'completed'; @@ -29,12 +24,12 @@ export interface RailGroup { } /** Group accents. Five, matching the pipeline's own grouping. */ -const GROUP_TONE: Record = { - prepare: { dot: 'bg-sky-500', text: 'text-sky-600 dark:text-sky-400' }, - design: { dot: 'bg-violet-500', text: 'text-violet-600 dark:text-violet-400' }, - build: { dot: 'bg-amber-500', text: 'text-amber-600 dark:text-amber-400' }, - verify: { dot: 'bg-emerald-500', text: 'text-emerald-600 dark:text-emerald-400' }, - finish: { dot: 'bg-slate-400', text: 'text-slate-500 dark:text-slate-400' }, +const GROUP_TONE: Record = { + prepare: { hex: '#0ea5e9', text: 'text-sky-600 dark:text-sky-400', soft: 'bg-sky-500/10' }, + design: { hex: '#8b5cf6', text: 'text-violet-600 dark:text-violet-400', soft: 'bg-violet-500/10' }, + build: { hex: '#f59e0b', text: 'text-amber-600 dark:text-amber-400', soft: 'bg-amber-500/10' }, + verify: { hex: '#10b981', text: 'text-emerald-600 dark:text-emerald-400', soft: 'bg-emerald-500/10' }, + finish: { hex: '#64748b', text: 'text-slate-500 dark:text-slate-400', soft: 'bg-slate-500/10' }, }; function toneFor(groupID: string) { @@ -54,89 +49,99 @@ export default function PhaseRail({ }) { const activeRef = useRef(null); - // Keep the active phase in view as the run walks the rail. Without this the - // rail is only useful until the run scrolls past the fold of its own track. + // Keep the active phase in view as the run walks the track. useEffect(() => { activeRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); }, [activePhase]); - const total = groups.reduce((n, g) => n + g.phases.length, 0); + const shown = useMemo(() => groups.filter((g) => g.phases.length > 0), [groups]); + const all = useMemo(() => shown.flatMap((g) => g.phases), [shown]); + const total = all.length; const done = Object.values(phaseState).filter((s) => s === 'completed').length; + // How far along the track the run is: the active stop, else the last + // completed one, else the start. + const activeIndex = activePhase ? all.indexOf(activePhase) : -1; + const reached = activeIndex >= 0 ? activeIndex : Math.max(-1, done - 1); + const progress = total <= 1 ? (reached >= 0 ? 100 : 0) : Math.max(0, Math.min(100, (reached / (total - 1)) * 100)); if (total === 0) return null; return ( -
+
- {/* The fade is the only affordance that this track scrolls: a clipped - chip at the right edge otherwise reads as a rendering bug rather - than as "there is more pipeline over here". */} -
- {groups.map((group) => { - // A group whose phases were all filtered out by a budget class is - // not a group. Callers filter too, but a lone dangling group label - // is a confusing enough artifact to guard against here as well. - if (group.phases.length === 0) return null; - const tone = toneFor(group.id); - return ( -
- - {group.label} - - {group.phases.map((phase) => { - const state = phaseState[phase] ?? 'pending'; - const isActive = state === 'active'; - return ( -
- - {phase} -
- ); - })} -
- ); - })} -
- -
-
+
+
+ {/* The track: a base line and the lit portion the run has walked. */} + + +
+ {done}/{total} + + {activePhase && running ? `now: ${activePhase}` : done === total && total > 0 ? 'complete' : 'phases'} +
diff --git a/web/src/components/Live/RunSetup.test.tsx b/web/src/components/Live/RunSetup.test.tsx index 61f81ed..b4d0968 100644 --- a/web/src/components/Live/RunSetup.test.tsx +++ b/web/src/components/Live/RunSetup.test.tsx @@ -191,11 +191,11 @@ describe('RunSetup teams', () => { expect(screen.getByText(/2 teams build in parallel/)).toBeInTheDocument(); const backend = screen.getByTestId('run-team-backend-go'); expect(within(backend).getByText('triage')).toBeInTheDocument(); - expect(within(backend).getByText('(default)')).toBeInTheDocument(); + expect(within(backend).getByText('default')).toBeInTheDocument(); const frontend = screen.getByTestId('run-team-frontend-react'); expect(within(frontend).getByText('fe-triage')).toBeInTheDocument(); expect(within(frontend).getByText('pinned')).toBeInTheDocument(); - expect(within(frontend).queryByText('(default)')).not.toBeInTheDocument(); + expect(within(frontend).queryByText('default')).not.toBeInTheDocument(); }); it('says when one team staffs the whole run', () => { @@ -215,3 +215,37 @@ describe('RunSetup teams', () => { expect(screen.getByText(/no team matched this request/)).toBeInTheDocument(); }); }); + +describe('RunSetup seats', () => { + beforeEach(() => localStorage.clear()); + + // A team without a tester on a pipeline with a test phase: the panel names + // who tests its work and where they came from, not "pipeline default". + it('shows a seat the pipeline fills for a team that left it empty', () => { + setup({ + composition: { + ...COMPOSITION, + team_mode: 'parallel', + teams: [ + { + id: 'backend-go', + worker: 'go-worker', + manager: 'triage', + manager_default: true, + seats: [ + { role: 'worker', agent: 'go-worker', source: 'team' }, + { role: 'reviewer', agent: 'reviewer', source: 'pipeline' }, + { role: 'tester', agent: 'go-tester', source: 'pipeline' }, + { role: 'manager', agent: 'triage', source: 'default' }, + ], + gaps: ['team backend-go names no reviewer — the pipeline\'s reviewer takes its reviewer seat', 'team backend-go names no tester — the pipeline\'s go-tester takes its tester seat'], + }, + ], + }, + }); + const card = screen.getByTestId('run-team-backend-go'); + expect(within(card).getByText('go-tester')).toBeInTheDocument(); + expect(within(card).getAllByText('from pipeline')).toHaveLength(2); + expect(within(card).getByText('2 seats filled from the pipeline')).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/Live/RunSetup.tsx b/web/src/components/Live/RunSetup.tsx index bc5a89e..07c4a5d 100644 --- a/web/src/components/Live/RunSetup.tsx +++ b/web/src/components/Live/RunSetup.tsx @@ -182,15 +182,33 @@ export default function RunSetup({ {t.name && t.name !== t.id && {t.name}}
- {t.worker && worker {t.worker}} - {t.reviewer && reviewer {t.reviewer}} - {t.tester && tester {t.tester}} - - + {(t.seats && t.seats.length > 0 + ? t.seats + : [ + { role: 'worker', agent: t.worker ?? '', source: 'team' }, + { role: 'reviewer', agent: t.reviewer ?? '', source: 'team' }, + { role: 'tester', agent: t.tester ?? '', source: 'team' }, + { role: 'manager', agent: t.manager || 'triage', source: t.manager_default ? 'default' : 'team' }, + ] + ) + .filter((s) => s.agent) + .map((s) => ( + + {s.role === 'manager' && + ))}
+ {(t.gaps ?? []).length > 0 && ( +

+ {t.gaps!.length} seat{t.gaps!.length === 1 ? '' : 's'} filled from the pipeline +

+ )} {t.reason &&

{t.reason}

}
))} diff --git a/web/src/components/Live/floor/FloorDossier.tsx b/web/src/components/Live/floor/FloorDossier.tsx new file mode 100644 index 0000000..5d7d638 --- /dev/null +++ b/web/src/components/Live/floor/FloorDossier.tsx @@ -0,0 +1,424 @@ +import { useEffect, useMemo } from 'react'; +import { ArrowUpRight, X } from 'lucide-react'; +import clsx from 'clsx'; +import type { RunEvent } from '@/types'; +import { teamColor } from '@/components/Board/teamColor'; +import { agentTrail, type FloorAgent, type FloorModel, type FloorStageSeat, type FloorTeam, type FloorTicket } from './floorModel'; +import { TICKET_HEX, TICKET_LABEL, ago, clock, findAgent, findTicket, glyphFor, seatTitle, type FloorSelection } from './floorShared'; + +// ── The dossier: who is this, what are they doing ──────────────────────── +// +// Click a person on the floor and this opens beside the stage: their seat +// and team, who manages them, whether they are working right now and on +// what, the last thing they said, the tickets they have touched, and their +// recent lines from the log. Click a ticket and it is the ticket's turn: its +// state, who holds it, everyone who has touched it. Every name in here is a +// link to the next dossier, so the floor can be explored by clicking around. + +export interface FloorDossierProps { + floor: FloorModel; + events: RunEvent[]; + selection: FloorSelection; + running: boolean; + now: number; + onSelect: (sel: FloorSelection) => void; + /** Jump to the ticket in the Tasks rail. */ + onTicket?: (id: string) => void; +} + +const TONE: Record = { + info: 'text-gray-500 dark:text-gray-400', + warning: 'text-amber-600 dark:text-amber-400', + error: 'text-red-600 dark:text-red-400', + problem: 'text-red-600 dark:text-red-400', + success: 'text-emerald-600 dark:text-emerald-400', +}; + +export default function FloorDossier({ floor, events, selection, running, now, onSelect, onTicket }: FloorDossierProps) { + const person = findAgent(floor, selection); + const ticket = findTicket(floor, selection); + const onStage = !person && selection?.kind === 'agent' ? floor.stage.find((a) => a.id === selection.id) ?? null : null; + + // Esc closes, like every other overlay in the studio. + useEffect(() => { + if (!selection) return undefined; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onSelect(null); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [selection, onSelect]); + + if (!person && !ticket && !onStage) return null; + + return ( + + ); +} + +function Header({ hex, glyph, title, sub, onClose }: { hex: string; glyph: string; title: string; sub: string; onClose: () => void }) { + return ( +
+ +
+
{title}
+
{sub}
+
+ +
+ ); +} + +function AgentDossier({ + agent, + team, + floor, + events, + running, + now, + onSelect, + onTicket, +}: { + agent: FloorAgent; + team: FloorTeam; + floor: FloorModel; + events: RunEvent[]; + running: boolean; + now: number; + onSelect: (sel: FloorSelection) => void; + onTicket?: (id: string) => void; +}) { + const hex = hexFor(team); + const trail = useMemo(() => agentTrail(events, agent.id, 8), [events, agent.id]); + const isManager = agent.seat === 'manager'; + const active = agent.active && running; + const manager = team.agents.find((a) => a.seat === 'manager'); + const reports = team.agents.filter((a) => a.seat !== 'manager'); + const held = agent.task ? team.tickets.find((t) => t.id === agent.task) : undefined; + const mine = agent.tickets.map((id) => team.tickets.find((t) => t.id === id)).filter((t): t is FloorTicket => !!t); + const holding = team.tickets.filter((t) => t.agent === agent.id); + + return ( + <> +
onSelect(null)} + /> +
+ {/* Status */} +
+
+
+ {agent.lastMessage && ( +
+ {agent.lastMessage} +
+ )} + {held && held.title &&
{held.title}
} +
+ + {/* Management */} +
+ {isManager ? ( + <> +
+ Manages {reports.length} {reports.length === 1 ? 'person' : 'people'} + {team.managerDefault ? · the run's default manager, since the team names none : null} +
+
+ {reports.map((r) => ( + + ))} +
+
+ Splits the request into tickets, dispatches each to a seat, moves stuck ones, and answers for the gate + {team.acceptance ? <> ({team.acceptance}) : null}. +
+ + ) : ( + <> +
+ Managed by{' '} + {manager ? : {team.manager}} +
+ {agent.borrowed && ( +
+ {team.name} names no {agent.seat}; the {agent.borrowed === 'pipeline' ? 'pipeline lent this one for the run' : 'run default fills the seat'}. +
+ )} + + )} +
+ + {/* Tickets */} +
+
+ Tickets + + {holding.length > 0 ? `${holding.length} in hand · ` : ''}{mine.length} touched + +
+ {mine.length === 0 && holding.length === 0 ? ( +
none yet{isManager ? ` — the manager's ${team.total} tickets are on the table` : ''}
+ ) : ( +
    + {dedupe([...holding, ...mine]).map((t) => ( +
  • + +
  • + ))} +
+ )} +
+ + {/* Trail */} +
+
Recent lines
+ {trail.length === 0 ? ( +
nothing in the log from {agent.id} yet
+ ) : ( +
    + {trail.map((l, i) => ( +
  1. + {ago(l.at, now)} + + {l.task ? : null} + {l.message} + +
  2. + ))} +
+ )} +
+
+
+ {floor.teams.length > 1 ? `${floor.teams.length} teams on the floor` : team.crew ? 'the pipeline crew' : `team ${team.id}`} + {onTicket && (held || holding[0]) && ( + + )} +
+ + ); +} + +function TicketDossier({ + ticket, + team, + floor, + now, + onSelect, + onTicket, +}: { + ticket: FloorTicket; + team: FloorTeam | null; + floor: FloorModel; + now: number; + onSelect: (sel: FloorSelection) => void; + onTicket?: (id: string) => void; +}) { + void now; + const hex = team ? hexFor(team) : '#94a3b8'; + const color = TICKET_HEX[ticket.state]; + const seatOf = (id: string) => team?.agents.find((a) => a.id === id) ?? floor.teams.flatMap((t) => t.agents).find((a) => a.id === id); + const holder = ticket.agent ? seatOf(ticket.agent) : undefined; + const others = ticket.touchedBy.filter((id) => id !== ticket.agent); + const handoffs = floor.handoffs.filter((h) => h.task === ticket.id); + return ( + <> +
onSelect(null)} /> +
+
+
{ticket.title || 'untitled'}
+
+ {TICKET_LABEL[ticket.state]} +
+
+
+ {holder ? ( +
+ In the hands of + {holder.active ? · typing now : null} +
+ ) : ticket.state === 'done' ? ( +
Finished{ticket.touchedBy.length ? ` — last by ${ticket.touchedBy[ticket.touchedBy.length - 1]}` : ''}.
+ ) : ( +
Nobody holds it right now{team ? ` — ${team.manager} decides who is next` : ''}.
+ )} + {others.length > 0 && ( +
+ also touched by + {others.map((id) => { + const a = seatOf(id); + return a ? : {id}; + })} +
+ )} + {handoffs.map((h) => ( +
+ moved {h.from} → {h.to}{h.reason ? ` — ${h.reason}` : ''} +
+ ))} +
+ {team && ( +
+ One of {team.total} on {team.name} · {team.done} done{team.blocked ? ` · ${team.blocked} blocked` : ''} + {team.manager ? <> · managed by : null} +
+ )} +
+ {onTicket && ( +
+ +
+ )} + + ); +} + +/** A phase agent on the pipeline's stage: what phase, whether speaking, what was said. */ +function StageDossier({ seat, floor, events, running, now, onSelect }: { seat: FloorStageSeat; floor: FloorModel; events: RunEvent[]; running: boolean; now: number; onSelect: (sel: FloorSelection) => void }) { + const trail = useMemo(() => agentTrail(events, seat.id, 8), [events, seat.id]); + const active = seat.active && running; + const others = floor.stage.filter((a) => a.id !== seat.id); + return ( + <> +
onSelect(null)} /> +
+
+
+
+ {seat.lastMessage && ( +
{seat.lastMessage}
+ )} +
+
+ One of the pipeline's own: not on a team, but the run's thinking between the tables' work + {seat.phase ? <> — the {seat.phase} phase is theirs : null}. + {others.length > 0 && ( +
+ also on the stage: + {others.map((a) => ( + + ))} +
+ )} +
+
+
Recent lines
+ {trail.length === 0 ? ( +
nothing in the log from {seat.id} yet
+ ) : ( +
    + {trail.map((l, i) => ( +
  1. + {ago(l.at, now)} + {l.message} +
  2. + ))} +
+ )} +
+
+ + ); +} + +function AgentChip({ agent, team, running, onSelect }: { agent: FloorAgent; team: string; running: boolean; onSelect: (sel: FloorSelection) => void }) { + const live = agent.active && running; + return ( + + ); +} + +function TicketLink({ id, team, onSelect }: { id: string; team: string; onSelect: (sel: FloorSelection) => void }) { + return ( + + ); +} + +function dedupe(list: FloorTicket[]): FloorTicket[] { + const seen = new Set(); + return list.filter((t) => (seen.has(t.id) ? false : (seen.add(t.id), true))); +} + +const HEX: Record = { + teal: '#14b8a6', + fuchsia: '#d946ef', + cyan: '#06b6d4', + rose: '#f43f5e', + lime: '#84cc16', + purple: '#a855f7', + gray: '#94a3b8', +}; + +function hexFor(team: FloorTeam): string { + return HEX[teamColor(team.crew ? '' : team.id).name] ?? HEX.gray; +} diff --git a/web/src/components/Live/floor/PulseFeed.tsx b/web/src/components/Live/floor/PulseFeed.tsx new file mode 100644 index 0000000..73034c1 --- /dev/null +++ b/web/src/components/Live/floor/PulseFeed.tsx @@ -0,0 +1,83 @@ +import clsx from 'clsx'; +import type { FloorPulse } from './floorModel'; +import type { FloorSelection } from './floorShared'; + +// ── The pulse feed: what just happened, sliding in at the side ─────────── +// +// Every change on the floor — a ticket appearing, a state flipping, a person +// starting, a handoff, a gate — becomes a card that slides in on the right +// and fades after PULSE_TTL_MS. Each card is a link into the dossier of the +// ticket or the person concerned, so a flash can be chased. + +export interface PulseFeedProps { + pulses: FloorPulse[]; + now: number; + onSelect: (sel: FloorSelection) => void; + running?: boolean; +} + +const TONE_CLASS: Record = { + info: 'border-sky-300/80 bg-sky-50/95 text-sky-900 dark:border-sky-700 dark:bg-sky-950/80 dark:text-sky-100', + good: 'border-emerald-300/80 bg-emerald-50/95 text-emerald-900 dark:border-emerald-700 dark:bg-emerald-950/80 dark:text-emerald-100', + warn: 'border-amber-300/80 bg-amber-50/95 text-amber-900 dark:border-amber-700 dark:bg-amber-950/80 dark:text-amber-100', + bad: 'border-red-300/80 bg-red-50/95 text-red-900 dark:border-red-700 dark:bg-red-950/80 dark:text-red-100', + brand: 'border-brand-300/80 bg-brand-50/95 text-brand-900 dark:border-brand-700 dark:bg-brand-950/80 dark:text-brand-100', +}; + +const KIND_GLYPH: Record = { + 'ticket-new': '🎫', + 'ticket-moved': '↪', + 'ticket-working': '🔧', + 'ticket-review': '👁️', + 'ticket-blocked': '⛔', + 'ticket-done': '✅', + 'ticket-failed': '💥', + 'agent-start': '⚡', + phase: '🧭', + handoff: '🔁', + gate: '🚦', + 'team-complete': '🏁', +}; + +const SHOW = 7; + +export default function PulseFeed({ pulses, now, onSelect, running }: PulseFeedProps) { + const shown = pulses.slice(-SHOW).reverse(); + if (shown.length === 0) { + return ( +

+ {running ? 'What happens next shows up here: tickets appearing, people starting, gates.' : 'Start a run and what happens shows up here.'} +

+ ); + } + return ( +
    + {shown.map((p) => { + const age = (now - p.at) / 1000; + const target: FloorSelection = p.ticket ? { kind: 'ticket', id: p.ticket, team: p.team } : p.agent ? { kind: 'agent', id: p.agent, team: p.team } : null; + return ( +
  1. 15 && 'floor-feed-card-fading')} style={{ animationDelay: '0s' }}> + +
  2. + ); + })} +
+ ); +} diff --git a/web/src/components/Live/floor/TeamFloor.test.tsx b/web/src/components/Live/floor/TeamFloor.test.tsx new file mode 100644 index 0000000..e542c65 --- /dev/null +++ b/web/src/components/Live/floor/TeamFloor.test.tsx @@ -0,0 +1,208 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import TeamFloor from './TeamFloor'; +import { buildFloor } from './floorModel'; +import type { RunEvent, SquadsView, Task } from '@/types'; + +// jsdom has no WebGL, so the wrapper renders the flat stage — which draws the +// same model, and is what these tests read. The 3D scene is exercised in a +// browser; its inputs are the model, which floorModel.test covers. + +const T0 = Date.parse('2026-09-10T10:00:00Z'); +const at = (s: number) => new Date(T0 + s * 1000).toISOString(); + +function task(id: string, over: Partial = {}): Task { + return { + id, title: `Task ${id}`, description: '', role: 'worker', assignee: '', column: 'ready_to_dev', status: 'ready', + priority: 0, depends_on: [], files: [], acceptance: '', checklist: [], output: '', review: '', retries: 0, + error: '', updated_at: '', notes: '', ...over, + }; +} + +const chart: SquadsView = { + ok: true, + summary: 'Go API + React SPA', + squads: [ + { id: 'backend-go', name: 'Backend', owns: ['cmd/**'], acceptance: 'go test ./...', worker: 'go-worker', manager: 'backend-triage', total: 2, done: 1, blocked: 0, in_flight: 1, complete: false, stuck: false }, + { id: 'frontend-react', name: 'Frontend', owns: ['web/**'], acceptance: 'npm run build', worker: 'react-worker', total: 1, done: 0, blocked: 1, in_flight: 0, complete: false, stuck: true }, + ], + task_teams: { T1: 'backend-go', T2: 'backend-go', T3: 'frontend-react' }, + interfaces: [{ id: 'GET /api/todos', provider: 'backend-go', consumers: ['frontend-react'] }], + stalls: [{ squad: 'frontend-react', interface: 'GET /api/todos', provider: 'backend-go' }], + gates: [{ team: 'backend-go', ran: true, ok: true }], + integration: { acceptance: 'make e2e', ready: false, reason: 'frontend-react is not complete' }, +}; + +const tasks = [task('T1', { status: 'done', column: 'done' }), task('T2', { status: 'running', column: 'in_progress' }), task('T3', { status: 'blocked', column: 'blocked' })]; +const events: RunEvent[] = [ + { phase: 'execute', kind: 'agent_start', agent: 'go-worker', task_id: 'T2', message: 'implementing T2', time: at(4) }, +]; + +beforeEach(() => { + localStorage.clear(); +}); + +describe('TeamFloor', () => { + it('draws every team as an island with its people and tickets', () => { + const floor = buildFloor({ squads: chart, tasks, events, composition: null, running: true, now: T0 + 5000 }); + render(); + + expect(screen.getByTestId('team-floor')).toBeInTheDocument(); + const backend = screen.getByTestId('island-backend-go'); + expect(within(backend).getByText('Backend')).toBeInTheDocument(); + expect(within(backend).getByTestId('agent-backend-triage')).toBeInTheDocument(); + expect(within(backend).getByTestId('agent-go-worker')).toHaveAttribute('data-active', 'true'); + expect(within(backend).getByTestId('ticket-T2')).toHaveAttribute('data-state', 'working'); + expect(within(backend).getByTestId('ticket-T1')).toHaveAttribute('data-state', 'done'); + expect(within(backend).getByText(/^T2 · \d+s$/)).toBeInTheDocument(); + + const frontend = screen.getByTestId('island-frontend-react'); + expect(within(frontend).getByText(/manager · run default/)).toBeInTheDocument(); + expect(within(frontend).getByText(/waiting on GET \/api\/todos/)).toBeInTheDocument(); + }); + + it('draws the contract as a conduit and marks the stalled one', () => { + const floor = buildFloor({ squads: chart, tasks, events, composition: null, running: true, now: T0 }); + render(); + const conduit = screen.getByTestId('conduit-backend-go-frontend-react'); + expect(conduit).toHaveAttribute('data-stalled', 'true'); + expect(within(conduit).getByText(/GET \/api\/todos · waiting/)).toBeInTheDocument(); + expect(screen.getByTestId('integration-plate')).toHaveTextContent('integration waits'); + }); + + it('animates a recent manager handoff between two people', () => { + const withHandoff: RunEvent[] = [ + ...events, + { phase: 'plan', kind: 'output', agent: 'go-corrector', task_id: 'T2', message: 'T2 reassigned from go-worker to go-corrector — compile error', time: at(11) }, + ]; + const withCorrector: SquadsView = { + ...chart, + squads: [{ ...chart.squads![0], agents: ['go-corrector'] }, chart.squads![1]], + }; + const floor = buildFloor({ squads: withCorrector, tasks, events: withHandoff, composition: null, running: true, now: T0 + 12_000 }); + render(); + expect(screen.getByTestId('handoff-T2')).toHaveTextContent('T2 → go-corrector'); + }); + + it('opens a ticket’s dossier on click, and jumps to the Tasks rail from there', () => { + const onTicket = vi.fn(); + const floor = buildFloor({ squads: chart, tasks, events, composition: null, running: true, now: T0 }); + render(); + fireEvent.click(screen.getByTestId('ticket-T2')); + const dossier = screen.getByTestId('floor-dossier'); + expect(within(dossier).getByText('Task T2')).toBeInTheDocument(); + expect(within(dossier).getByTestId('dossier-ticket-state')).toHaveTextContent('in progress'); + expect(within(dossier).getByTestId('dossier-holder')).toHaveTextContent('In the hands of'); + expect(within(dossier).getByTestId('dossier-holder')).toHaveTextContent('go-worker'); + expect(screen.getByTestId('ticket-T2')).toHaveAttribute('data-selected', 'true'); + expect(onTicket).not.toHaveBeenCalled(); + fireEvent.click(within(dossier).getByRole('button', { name: /open in Tasks/ })); + expect(onTicket).toHaveBeenCalledWith('T2'); + }); + + it('opens a person’s dossier: status, manager, tickets, recent lines', () => { + const log: RunEvent[] = [ + { phase: 'execute', kind: 'agent_start', agent: 'go-worker', task_id: 'T1', message: 'implementing T1', time: at(1) }, + { phase: 'execute', kind: 'agent_end', agent: 'go-worker', task_id: 'T1', message: 'done', time: at(2) }, + ...events, + ]; + const floor = buildFloor({ squads: chart, tasks, events: log, composition: null, running: true, now: T0 + 5000 }); + render(); + fireEvent.click(screen.getByTestId('agent-go-worker')); + const dossier = screen.getByTestId('floor-dossier'); + expect(dossier).toHaveAttribute('aria-label', 'About go-worker'); + expect(within(dossier).getByTestId('dossier-status')).toHaveTextContent(/working on T2/); + expect(within(dossier).getByTestId('dossier-status')).toHaveTextContent('0:30'); + expect(within(dossier).getByTestId('dossier-status')).toHaveTextContent('implementing T2'); + expect(within(dossier).getByTestId('dossier-management')).toHaveTextContent('Managed by'); + expect(within(dossier).getByTestId('dossier-management')).toHaveTextContent('backend-triage'); + expect(within(dossier).getByTestId('dossier-tickets')).toHaveTextContent('1 in hand · 2 touched'); + expect(within(dossier).getByTestId('dossier-trail')).toHaveTextContent('implementing T1'); + expect(screen.getByTestId('agent-go-worker')).toHaveAttribute('data-selected', 'true'); + + // Every name links on: the manager's dossier says who they manage. + fireEvent.click(within(dossier).getByRole('button', { name: /backend-triage/ })); + expect(screen.getByTestId('floor-dossier')).toHaveAttribute('aria-label', 'About backend-triage'); + expect(within(screen.getByTestId('dossier-management')).getByText(/Manages 1 person/)).toBeInTheDocument(); + + // Esc closes. + fireEvent.keyDown(window, { key: 'Escape' }); + expect(screen.queryByTestId('floor-dossier')).not.toBeInTheDocument(); + }); + + it('lists what just changed in the feed, and each card opens its dossier', () => { + const before = buildFloor({ squads: chart, tasks, events: [], composition: null, running: true, now: T0 }); + const { rerender } = render(); + expect(screen.queryByTestId('floor-feed')).not.toBeInTheDocument(); + + const after = buildFloor({ + squads: chart, + tasks: [...tasks.slice(0, 1), task('T2', { status: 'done', column: 'done' }), tasks[2], task('T4', { title: 'a new one' })], + events, + composition: null, + running: true, + now: T0 + 6000, + }); + rerender(); + const feed = screen.getByTestId('floor-feed'); + expect(within(feed).getByText(/^T2 done ✓/)).toBeInTheDocument(); + expect(within(feed).getByText(/T4 appeared/)).toBeInTheDocument(); + expect(within(feed).getByText('go-worker started on T2')).toBeInTheDocument(); + expect(screen.getByTestId('ticket-T2')).toHaveClass('floor-ticket-flash'); + + fireEvent.click(within(feed).getByText(/^T2 done ✓/)); + expect(screen.getByTestId('floor-dossier')).toHaveAttribute('aria-label', 'About T2'); + }); + + it('shows the pipeline crew on one island when there is no org chart', () => { + const floor = buildFloor({ + squads: { ok: false }, + tasks: tasks.slice(0, 2), + events, + composition: { summary: 'x', team_note: 'no team matched this request — it runs as one stream', execute: { default_role: 'go-worker', reviewer: 'reviewer' } }, + running: true, + now: T0, + }); + render(); + const crew = screen.getByTestId('island-crew'); + expect(within(crew).getByText('Pipeline crew')).toBeInTheDocument(); + expect(within(crew).getByTestId('agent-go-worker')).toHaveAttribute('data-active', 'true'); + expect(within(crew).getByTestId('agent-reviewer')).toBeInTheDocument(); + }); + + it('shows the pipeline’s own people beside the tables, and their dossier', () => { + const log: RunEvent[] = [ + { phase: 'plan', kind: 'agent_start', agent: 'planner', message: 'reading the request', time: at(1) }, + { phase: 'plan', kind: 'output', agent: 'planner', message: 'three tickets, two teams', time: at(2) }, + ]; + const floor = buildFloor({ + squads: chart, + tasks, + events: log, + composition: { summary: 'x', phases: [{ id: 'plan', agent: 'planner', enabled: true, when: 'auto' }, { id: 'split', agent: 'splitter', enabled: true, when: 'auto' }] }, + running: true, + now: T0 + 3000, + }); + render(); + expect(screen.getByTestId('pipeline-phase')).toHaveTextContent('plan'); + expect(screen.getByTestId('stage-planner')).toHaveAttribute('data-active', 'true'); + expect(screen.getByTestId('stage-splitter')).not.toHaveAttribute('data-active'); + fireEvent.click(screen.getByTestId('stage-planner')); + const dossier = screen.getByTestId('floor-dossier'); + expect(dossier).toHaveAttribute('aria-label', 'About planner'); + expect(within(dossier).getByTestId('dossier-status')).toHaveTextContent('speaking'); + expect(within(dossier).getByTestId('dossier-trail')).toHaveTextContent('three tickets, two teams'); + }); + + it('explains an empty floor', () => { + const floor = buildFloor({ squads: null, tasks: [], events: [], composition: null, running: false, now: T0 }); + render(); + expect(screen.getByTestId('team-floor-idle')).toHaveTextContent('The floor is empty'); + }); + + it('offers no 3D toggle where WebGL is absent', () => { + const floor = buildFloor({ squads: chart, tasks, events, composition: null, running: true, now: T0 }); + render(); + expect(screen.queryByRole('button', { name: /3D/ })).not.toBeInTheDocument(); + }); +}); diff --git a/web/src/components/Live/floor/TeamFloor.tsx b/web/src/components/Live/floor/TeamFloor.tsx new file mode 100644 index 0000000..398cef1 --- /dev/null +++ b/web/src/components/Live/floor/TeamFloor.tsx @@ -0,0 +1,175 @@ +import { Suspense, lazy, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; +import { Box, LayoutTemplate } from 'lucide-react'; +import clsx from 'clsx'; +import { AppContext } from '@/App'; +import { usePersistentState } from '@/hooks/useUiState'; +import type { RunEvent } from '@/types'; +import TeamFloorFlat from './TeamFloorFlat'; +import FloorDossier from './FloorDossier'; +import PulseFeed from './PulseFeed'; +import { diffFloors, freshPulses, type FloorModel, type FloorPulse } from './floorModel'; +import type { FloorSelection } from './floorShared'; + +// ── The team floor: 3D when the machine can, flat when it cannot ──────── +// +// The scene is WebGL, and WebGL is not a given: a remote desktop, a locked +// down browser, a test runner. The flat SVG stage draws the same model, so +// the page always has a floor — the 3D one is loaded lazily on top of it, +// only where it can run, and the user can pin either. +// +// Whichever stage draws, this wrapper owns what is interactive about it: +// the selection (a clicked person or ticket, opened in the dossier), and +// the pulses (what changed since the last floor, flashed on the stage and +// listed in the feed). + +export interface TeamFloorProps { + floor: FloorModel; + running: boolean; + /** The run's log, for the dossier's trail. */ + events?: RunEvent[]; + onTicket?: (id: string) => void; + /** Current time, injected for tests. */ + now?: number; +} + +const TeamFloor3D = lazy(() => import('./TeamFloor3D')); + +const NO_EVENTS: RunEvent[] = []; +const PULSE_KEEP = 40; + +function webGLAvailable(): boolean { + if (typeof window === 'undefined') return false; + // jsdom defines neither constructor; asking it for a context logs an error. + if (typeof (window as unknown as { WebGL2RenderingContext?: unknown }).WebGL2RenderingContext === 'undefined' && + typeof (window as unknown as { WebGLRenderingContext?: unknown }).WebGLRenderingContext === 'undefined') { + return false; + } + try { + const canvas = document.createElement('canvas'); + return !!(canvas.getContext('webgl2') || canvas.getContext('webgl')); + } catch { + return false; + } +} + +function useReducedMotion(): boolean { + const [reduced, setReduced] = useState(() => + typeof window !== 'undefined' && typeof window.matchMedia === 'function' + ? window.matchMedia('(prefers-reduced-motion: reduce)').matches + : false, + ); + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return undefined; + const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); + const onChange = () => setReduced(mq.matches); + mq.addEventListener?.('change', onChange); + return () => mq.removeEventListener?.('change', onChange); + }, []); + return reduced; +} + +/** A one-second clock while the floor is alive, frozen to `fixed` in tests. */ +function useClock(alive: boolean, fixed?: number): number { + const [tick, setTick] = useState(() => fixed ?? Date.now()); + useEffect(() => { + if (fixed !== undefined) { + setTick(fixed); + return undefined; + } + setTick(Date.now()); + if (!alive) return undefined; + const id = window.setInterval(() => setTick(Date.now()), 1000); + return () => window.clearInterval(id); + }, [alive, fixed]); + return tick; +} + +/** + * usePulses diffs each new floor against the last and keeps the changes for + * PULSE_TTL_MS. Diffing happens in the effect, not in render, so a floor that + * re-renders without changing produces nothing. + */ +function usePulses(floor: FloorModel, now: number, fixed?: number): FloorPulse[] { + const prev = useRef(null); + const [pulses, setPulses] = useState([]); + useEffect(() => { + const at = fixed ?? Date.now(); + const found = diffFloors(prev.current, floor, at); + prev.current = floor; + if (found.length > 0) setPulses((list) => freshPulses([...list, ...found], at).slice(-PULSE_KEEP)); + }, [floor, fixed]); + // Pruning: driven by the shared clock so the feed's "12s" labels and its + // fading stay in step. + useEffect(() => { + setPulses((list) => { + const kept = freshPulses(list, now); + return kept.length === list.length ? list : kept; + }); + }, [now]); + return pulses; +} + +export default function TeamFloor({ floor, running, events = NO_EVENTS, onTicket, now: fixedNow }: TeamFloorProps) { + const ctx = useContext(AppContext); + const dark = ctx?.dark ?? false; + const reducedMotion = useReducedMotion(); + const canGL = useMemo(() => webGLAvailable(), []); + // The user's pick outlives the session: someone on a weak GPU who chose the + // flat floor once should not have to choose it on every visit. + const [prefer, setPrefer] = usePersistentState<'3d' | 'flat'>('live.floor.mode', '3d'); + const use3D = canGL && prefer === '3d'; + + const [selection, setSelection] = useState(null); + const onSelect = useCallback((sel: FloorSelection) => setSelection(sel), []); + const now = useClock(running || selection !== null, fixedNow); + const pulses = usePulses(floor, now, fixedNow); + + if (floor.mode === 'idle') { + return ; + } + + const stageProps = { floor, running, selection, onSelect, pulses, onTicket }; + + // The stage gets the width minus a fixed column at the right for the + // 3D/map toggle and the feed: what slides in there never covers a label in + // the scene, whatever the camera is doing. + return ( +
+
+ {use3D ? ( + }> + + + ) : ( + + )} + +
+
+ {canGL && ( +
+ + +
+ )} + +
+
+ ); +} diff --git a/web/src/components/Live/floor/TeamFloor3D.tsx b/web/src/components/Live/floor/TeamFloor3D.tsx new file mode 100644 index 0000000..689d207 --- /dev/null +++ b/web/src/components/Live/floor/TeamFloor3D.tsx @@ -0,0 +1,1557 @@ +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import * as THREE from 'three'; +import { Canvas, useFrame, useThree, type ThreeEvent } from '@react-three/fiber'; +import { ContactShadows, Float, Html, OrbitControls, QuadraticBezierLine, RoundedBox, Sparkles } from '@react-three/drei'; +import type { OrbitControls as OrbitControlsImpl } from 'three-stdlib'; +import { teamColor } from '@/components/Board/teamColor'; +import type { FloorAgent, FloorHandoff, FloorModel, FloorPhase, FloorPulse, FloorStageSeat, FloorTeam, FloorTicket, TicketState } from './floorModel'; +import { TICKET_HEX, TICKET_LABEL, glyphFor, seatTitle, type FloorSelection } from './floorShared'; + +// ── The team floor, in three dimensions ────────────────────────────────── +// +// Each team sits at its own round table. The manager has the head seat with +// the team's board on the wall behind them; the members sit around it, each +// with a monitor whose screen lights up when the log says that agent is +// working — with a pop-out over their head saying which ticket. The tickets +// lie on the table, colored by state, and a thread runs from each one to the +// monitor of whoever holds it. The frozen contract runs between tables as +// glowing conduits with packets travelling provider → consumer, turning +// amber and slowing when the consumer waits on a clause it has not been +// given. A manager dispatching someone onto a ticket is an arc from the head +// seat; a reassignment is a spark across the table; a ticket finishing or +// failing bursts. Gates paint the table's rim green or red. +// +// Everything is clickable: a person or a ticket opens its dossier (owned by +// the wrapper), a table focuses the camera, empty floor clears. The camera is +// the user's: drag to orbit, wheel to zoom, right-drag to pan, "follow" keeps +// whoever is working in the middle. Everything animates on the GPU per frame +// and freezes (frameloop on demand) under prefers-reduced-motion. +// +// All data comes from floorModel; this file only draws it. + +export interface TeamFloor3DProps { + floor: FloorModel; + running: boolean; + dark: boolean; + reducedMotion: boolean; + selection: FloorSelection; + onSelect: (sel: FloorSelection) => void; + pulses: FloorPulse[]; + onTicket?: (id: string) => void; +} + +const HEX: Record = { + teal: '#14b8a6', + fuchsia: '#d946ef', + cyan: '#06b6d4', + rose: '#f43f5e', + lime: '#84cc16', + purple: '#a855f7', + gray: '#94a3b8', +}; + +const TONE_HEX: Record = { + info: '#38bdf8', + good: '#10b981', + warn: '#f59e0b', + bad: '#ef4444', + brand: '#8b5cf6', +}; + +const TABLE_R = 2.1; +const TABLE_Y = 0.95; +const SEAT_R = TABLE_R + 0.95; +/** Table centre to table centre; the rugs (SEAT_R + 1) never touch. */ +const TABLE_GAP = 10.4; +const SELECT = '#7c3aed'; +/** The wall board: its size, how far behind the head seat it stands, how high its bottom edge is. */ +const BOARD_W = 3.4; +const BOARD_H = 1.9; +const BOARD_BACK = SEAT_R + 2.4; +const BOARD_BOTTOM = 1.9; +/** The pipeline's stage: a platform at the back of the hall, behind the boards. */ +const STAGE_R = 2.7; +const STAGE_H = 0.22; +const STAGE_Z = -(BOARD_BACK + 4.6); +const BURST_S = 1.7; +const DISPATCH_S = 5; +const FLASH_S = 0.9; + +interface Seat { + pos: THREE.Vector3; + angle: number; + /** Where this seat's monitor is, in table space — the end of a ticket thread. */ + screen: THREE.Vector3; +} + +interface Placed { + team: FloorTeam; + pos: THREE.Vector3; + hex: string; + seats: Map; + /** Ticket card centres in table space, for threads and bursts. */ + slots: Map; +} + +const ORDER: TicketState[] = ['working', 'review', 'blocked', 'failed', 'queued', 'done']; +const CARD_W = 0.62; +const CARD_D = 0.42; +const CARD_GAP = 0.14; +const CARDS_PER_ROW = 4; +const CARDS_MAX = 12; + +/** Where each ticket card lies on a table, in state order, four to a row. */ +function ticketSlots(team: FloorTeam): { shown: FloorTicket[]; more: number; slots: Map } { + const sorted = [...team.tickets].sort((a, b) => ORDER.indexOf(a.state) - ORDER.indexOf(b.state)); + const shown = sorted.slice(0, CARDS_MAX); + const rows = Math.ceil(shown.length / CARDS_PER_ROW); + const slots = new Map(); + shown.forEach((t, i) => { + const row = Math.floor(i / CARDS_PER_ROW); + const inRow = Math.min(CARDS_PER_ROW, shown.length - row * CARDS_PER_ROW); + const col = i % CARDS_PER_ROW; + const x = -((inRow - 1) * (CARD_W + CARD_GAP)) / 2 + col * (CARD_W + CARD_GAP); + const z = -((rows - 1) * (CARD_D + CARD_GAP)) / 2 + row * (CARD_D + CARD_GAP); + slots.set(t.id, new THREE.Vector3(x, TABLE_Y + 0.12, z)); + }); + return { shown, more: sorted.length - shown.length, slots }; +} + +/** Tables on a shallow arc facing the camera; seats around each table. */ +function layout(teams: FloorTeam[]): Placed[] { + const n = teams.length; + return teams.map((team, i) => { + const x = (i - (n - 1) / 2) * TABLE_GAP; + const z = n <= 1 ? 0 : -Math.abs(i - (n - 1) / 2) * 1.2 + 0.6; + const pos = new THREE.Vector3(x, 0, z); + const hex = HEX[teamColor(team.crew ? '' : team.id).name] ?? HEX.gray; + const seats = new Map(); + const manager = team.agents.find((a) => a.seat === 'manager'); + const others = team.agents.filter((a) => a.seat !== 'manager'); + const seatAt = (angle: number): Seat => { + const p = new THREE.Vector3(Math.cos(angle) * SEAT_R, 0, Math.sin(angle) * SEAT_R); + // The monitor sits 0.5 toward the table from the chair, at screen height. + const toward = p.clone().multiplyScalar(-1).normalize().multiplyScalar(0.5); + return { pos: p, angle, screen: p.clone().add(toward).setY(1.0) }; + }; + // Manager at the far side (facing the camera), others spread along the + // near arc so their screens are visible. + if (manager) seats.set(manager.id, seatAt(-Math.PI / 2)); + const span = Math.min(Math.PI * 1.2, 0.64 * Math.max(others.length - 1, 0) + 0.001); + others.forEach((a, k) => { + const f = others.length === 1 ? 0.5 : k / (others.length - 1); + const angle = Math.PI / 2 - span / 2 + f * span; // centred on +z (toward camera) + seats.set(a.id, seatAt(angle)); + }); + return { team, pos, hex, seats, slots: ticketSlots(team).slots }; + }); +} + +function nowSeconds(): number { + return Date.now() / 1000; +} + +export default function TeamFloor3D({ floor, running, dark, reducedMotion, selection, onSelect, pulses, onTicket }: TeamFloor3DProps) { + const placed = useMemo(() => layout(floor.teams), [floor.teams]); + const byID = useMemo(() => new Map(placed.map((p) => [p.team.id, p])), [placed]); + const [focus, setFocus] = useState(null); + const [focusDistance, setFocusDistance] = useState(null); + const [autoRotate, setAutoRotate] = useState(false); + const [follow, setFollow] = useState(false); + const [resetKey, setResetKey] = useState(0); + const animate = !reducedMotion; + + const width = Math.max(1, placed.length) * TABLE_GAP; + const hasStage = floor.mode === 'teams' && (floor.stage.length > 0 || !!floor.phase); + // Frame the tables with their rugs; the stage sits behind them, between the + // boards (or beside the one board), and comes along for free. + const edge = (width - TABLE_GAP) / 2 + SEAT_R + 1.2; + const span = edge * 2; + const home = useMemo(() => new THREE.Vector3(0, 1.6, hasStage ? -1.5 : 0), [hasStage]); + const camZ = 5 + span * 0.92; + const homeCam = useMemo(() => new THREE.Vector3(home.x, camZ * 0.55, camZ + 1), [home, camZ]); + + // Two or more tables leave a gap between their boards: the stage shows + // through it. One table's board is in the middle, so the stage steps aside. + const stageAt = useMemo(() => new THREE.Vector3(placed.length >= 2 ? 0 : -(BOARD_W / 2 + STAGE_R + 1.2), 0, STAGE_Z), [placed.length]); + const stageSeats = useMemo(() => stageLayout(floor.stage, stageAt), [floor.stage, stageAt]); + + // World position of a person or a ticket, for focusing. + const worldOf = useCallback( + (sel: FloorSelection): THREE.Vector3 | null => { + if (!sel) return null; + if (sel.kind === 'agent') { + const onStage = stageSeats.get(sel.id); + if (onStage && !sel.team) return onStage.clone().setY(1.2); + } + const home = byID.get(sel.team); + const tables = home ? [home, ...placed.filter((p) => p !== home)] : placed; + for (const p of tables) { + if (sel.kind === 'agent') { + const s = p.seats.get(sel.id); + if (s) return s.pos.clone().add(p.pos).setY(1.1); + } else { + const s = p.slots.get(sel.id); + if (s) return s.clone().add(p.pos); + } + } + if (sel.kind === 'agent') { + const onStage = stageSeats.get(sel.id); + if (onStage) return onStage.clone().setY(1.2); + } + return null; + }, + [byID, placed, stageSeats], + ); + + // Selecting something brings the camera to it. + useEffect(() => { + if (!selection) return; + const w = worldOf(selection); + if (w) { + setFocus(w); + setFocusDistance(selection.kind === 'agent' ? 7.5 : 6); + } + }, [selection, worldOf]); + + // Follow mode: keep whoever is working in the middle of the picture. + const nowAgent = floor.now?.agent ?? ''; + const nowTeam = floor.now?.team ?? ''; + useEffect(() => { + if (!follow || !nowAgent) return; + const w = worldOf({ kind: 'agent', id: nowAgent, team: nowTeam }); + if (w) { + setFocus(w); + setFocusDistance(8); + } + }, [follow, nowAgent, nowTeam, worldOf]); + + const clear = () => { + setFocus(null); + setFocusDistance(null); + onSelect(null); + }; + + return ( +
+ + + + + {floor.links.map((link) => { + const a = byID.get(link.from); + const b = byID.get(link.to); + if (!a || !b) return null; + return ; + })} + {placed.map((p) => ( + { + setFocus(p.pos.clone().setY(0.8)); + setFocusDistance(13); + }} + /> + ))} + {floor.handoffs.map((h) => ( + + ))} + {floor.mode === 'teams' && (floor.stage.length > 0 || floor.phase) && ( + { + setFocus(stageAt.clone().setY(1)); + setFocusDistance(11); + }} + /> + )} + {floor.mode === 'teams' && placed.length > 1 && floor.integration && ( + + )} + + + + + +
+ +
+ + + + drag to orbit · wheel to zoom · click a person or a ticket +
+
+ + ); +} + +// ── Scene pieces ───────────────────────────────────────────────────────── + +function Lights({ dark }: { dark: boolean }) { + return ( + <> + + + + + + + + + ); +} + +function Ground({ dark, width, animate }: { dark: boolean; width: number; animate: boolean }) { + return ( + + + + + + {/* A pool of light under the floor's centre, so the tables sit in a room and not on a void. */} + + + + + {animate && } + + ); +} + +/** The soft round rug a team's table stands on, in its color. */ +function Rug({ hex, dark }: { hex: string; dark: boolean }) { + return ( + + + + + ); +} + +function Table({ + placed, + running, + animate, + dark, + selection, + onSelect, + pulses, + onTicket, + onFocus, +}: { + placed: Placed; + running: boolean; + animate: boolean; + dark: boolean; + selection: FloorSelection; + onSelect: (sel: FloorSelection) => void; + pulses: FloorPulse[]; + onTicket?: (id: string) => void; + onFocus: () => void; +}) { + const { team, pos, hex, seats, slots } = placed; + const rim = + team.gate === 'green' ? '#10b981' : team.gate === 'red' ? '#ef4444' : team.waitingOn.length > 0 ? '#f59e0b' : hex; + const rimRef = useRef(null); + const waiting = team.waitingOn.length > 0; + useFrame(({ clock }) => { + if (!animate || !rimRef.current) return; + const t = clock.getElapsedTime(); + rimRef.current.emissiveIntensity = waiting ? 0.6 + Math.sin(t * 4) * 0.5 : team.gate ? 0.9 : 0.35 + Math.sin(t * 1.5) * 0.15; + }); + const pct = team.total > 0 ? Math.round((team.done / team.total) * 100) : 0; + + // Who everyone else glances at: the person working. + const activeAgent = running ? team.agents.find((a) => a.active) : undefined; + const lookAt = activeAgent ? seats.get(activeAgent.id)?.pos : undefined; + const managerSeat = team.agents.find((a) => a.seat === 'manager'); + const managerPos = managerSeat ? seats.get(managerSeat.id) : undefined; + + // Pulses on this table, still fresh enough to draw. + const mine = pulses.filter((p) => p.team === team.id || (team.crew && !p.team)); + const latestByTicket = new Map(); + for (const p of mine) if (p.ticket) latestByTicket.set(p.ticket, p); + + // The board stands behind the head seat; the team's sign hangs above it. + const boardDir = managerPos ? managerPos.pos.clone().setY(0).normalize() : new THREE.Vector3(0, 0, -1); + const signAt = boardDir.clone().multiplyScalar(BOARD_BACK).setY(BOARD_BOTTOM + BOARD_H + 0.75); + + return ( + + + {activeAgent && animate && } + {/* Pedestal + top. */} + + + + + { + e.stopPropagation(); + onFocus(); + }} + onPointerOver={() => (document.body.style.cursor = 'pointer')} + onPointerOut={() => (document.body.style.cursor = '')} + > + + + + + + + + {/* The team's sign, above its board — nothing else is drawn up there. */} + + + + + {/* The board on the wall behind the manager. */} + + + + + {/* Threads: each held ticket to its holder's monitor. */} + {team.tickets.map((t) => { + if (!t.agent) return null; + const seat = seats.get(t.agent); + const slot = slots.get(t.id); + if (!seat || !slot) return null; + const holder = team.agents.find((a) => a.id === t.agent); + return ; + })} + + {team.agents.map((a, i) => { + const seat = seats.get(a.id); + if (!seat) return null; + const selected = selection?.kind === 'agent' && selection.id === a.id && selection.team === team.id; + return ( + onSelect(selected ? null : { kind: 'agent', id: a.id, team: team.id })} + /> + ); + })} + + {/* Dispatches: the manager sending someone onto a ticket. */} + {managerPos && + mine + .filter((p) => p.kind === 'agent-start' && p.agent && p.agent !== managerSeat?.id && seats.has(p.agent)) + .map((p, i) => )} + + {/* Bursts: a ticket finishing, failing, appearing; a gate; a team done. */} + {mine.map((p) => { + const age = nowSeconds() - p.at / 1000; + if (age > BURST_S) return null; + const where = p.ticket ? slots.get(p.ticket) : undefined; + const big = p.kind === 'gate' || p.kind === 'team-complete'; + if (!where && !big) return null; + return ; + })} + + ); +} + +function Tickets({ + team, + animate, + selection, + onSelect, + latestByTicket, + onTicket, +}: { + team: FloorTeam; + animate: boolean; + selection: FloorSelection; + onSelect: (sel: FloorSelection) => void; + latestByTicket: Map; + onTicket?: (id: string) => void; +}) { + void onTicket; + const { shown, more, slots } = useMemo(() => ticketSlots(team), [team]); + return ( + + {shown.map((t) => { + const slot = slots.get(t.id)!; + const selected = selection?.kind === 'ticket' && selection.id === t.id; + return ( + onSelect(selected ? null : { kind: 'ticket', id: t.id, team: team.id })} + /> + ); + })} + {more > 0 && ( + + +{more} + + )} + + ); +} + +function TicketCard({ + ticket, + x, + z, + animate, + selected, + pulse, + onSelect, +}: { + ticket: FloorTicket; + x: number; + z: number; + animate: boolean; + selected: boolean; + pulse?: FloorPulse; + onSelect: () => void; +}) { + const live = ticket.state === 'working' || ticket.state === 'review'; + const mat = useRef(null); + const grp = useRef(null); + const ring = useRef(null); + const [hover, setHover] = useState(false); + const color = TICKET_HEX[ticket.state]; + const pulseAt = pulse ? pulse.at / 1000 : -Infinity; + const spawned = pulse?.kind === 'ticket-new'; + + useFrame(({ clock }) => { + const t = clock.getElapsedTime(); + const age = nowSeconds() - pulseAt; + if (mat.current) { + // A fresh pulse flashes the card white-hot, then it settles into its + // state's glow: breathing when live, steady when selected or hovered. + if (animate && age < FLASH_S) { + mat.current.emissive.set('#ffffff'); + mat.current.emissiveIntensity = 1.6 * (1 - age / FLASH_S); + } else { + mat.current.emissive.set(color); + if (selected || hover) mat.current.emissiveIntensity = 0.7; + else if (live && animate) mat.current.emissiveIntensity = 0.5 + Math.sin(t * 5) * 0.4; + else mat.current.emissiveIntensity = live ? 0.6 : 0.08; + } + } + if (grp.current) { + // A card that just appeared drops onto the table with a little bounce. + if (animate && spawned && age < 0.7) { + const k = age / 0.7; + const ease = 1 - Math.pow(1 - k, 3); + grp.current.position.y = (1 - ease) * 1.4; + grp.current.scale.setScalar(0.4 + ease * 0.6); + } else { + grp.current.position.y = 0; + grp.current.scale.setScalar(selected ? 1.12 : 1); + } + } + if (ring.current) { + ring.current.visible = selected; + if (selected && animate) ring.current.rotation.z = t * 1.2; + } + }); + + const body = ( + ) => { + e.stopPropagation(); + onSelect(); + }} + onPointerOver={(e: ThreeEvent) => { + e.stopPropagation(); + setHover(true); + document.body.style.cursor = 'pointer'; + }} + onPointerOut={() => { + setHover(false); + document.body.style.cursor = ''; + }} + > + + + ); + return ( + + + {live && animate ? {body} : body} + + + + + + + + {ticket.state === 'done' ? '✓ ' : ticket.state === 'blocked' || ticket.state === 'failed' ? '! ' : ''} + {ticket.id.length > 9 ? ticket.id.slice(0, 8) + '…' : ticket.id} + {hover && !selected && ( + + {TICKET_LABEL[ticket.state]} + {ticket.agent ? ` · ${ticket.agent}` : ''} +
+ {ticket.title} +
+ )} +
+ +
+ ); +} + +const UP = new THREE.Vector3(0, 1, 0); + +function Person({ + agent, + team, + pos, + angle, + hex, + running, + animate, + selected, + tall, + lookAt, + onSelect, +}: { + agent: FloorAgent; + team: FloorTeam; + pos: THREE.Vector3; + angle: number; + hex: string; + running: boolean; + animate: boolean; + selected: boolean; + /** Every other seat's pill sits higher, so neighbours' names never touch. */ + tall?: boolean; + /** Table-space point this person glances at when idle (the one working). */ + lookAt?: THREE.Vector3; + onSelect: () => void; +}) { + const isManager = agent.seat === 'manager'; + const active = agent.active && running; + const screen = useRef(null); + const body = useRef(null); + const head = useRef(null); + const flash = useRef(null); + const halo = useRef(null); + const pulse = useRef(null); + const code = useRef([]); + const flashStart = useRef(-1); + const wasActive = useRef(false); + const [hover, setHover] = useState(false); + + // A flash ring when this person starts working: the "message received" + // cue, once per activation. + useEffect(() => { + if (active && !wasActive.current) flashStart.current = performance.now(); + wasActive.current = active; + }, [active]); + + // Seat faces the table centre: rotate the whole person so its screen is + // between them and the table. + const yaw = Math.atan2(-pos.x, -pos.z); + const headYaw = useMemo(() => { + if (!lookAt) return 0; + const local = lookAt.clone().sub(pos).applyAxisAngle(UP, -yaw); + return THREE.MathUtils.clamp(Math.atan2(-local.x, -local.z), -1.1, 1.1); + }, [lookAt, pos, yaw]); + + useFrame(({ clock }) => { + const t = clock.getElapsedTime(); + if (screen.current) { + screen.current.emissiveIntensity = active ? (animate ? 1.2 + Math.sin(t * 6) * 0.5 : 1.4) : 0.12; + } + if (body.current) { + if (animate) { + // Typing: a small nod while active; a slow breath otherwise. + body.current.position.y = active ? Math.abs(Math.sin(t * 9)) * 0.03 : Math.sin(t * 1.2 + angle) * 0.01; + body.current.rotation.x = active ? Math.sin(t * 9) * 0.04 : 0; + } + const target = hover || selected ? 1.08 : 1; + body.current.scale.lerp(new THREE.Vector3(target, target, target), 0.2); + } + if (head.current) { + // Idle people glance at whoever is working; the worker watches the screen. + const want = active ? 0 : headYaw; + head.current.rotation.y = animate ? THREE.MathUtils.lerp(head.current.rotation.y, want, 0.06) : want; + } + if (flash.current) { + const age = flashStart.current < 0 ? Infinity : (performance.now() - flashStart.current) / 1000; + const visible = age < 1.2 && animate; + flash.current.visible = visible; + if (visible) { + const s = 0.6 + age * 2.2; + flash.current.scale.set(s, s, s); + (flash.current.material as THREE.MeshBasicMaterial).opacity = Math.max(0, 0.8 - age * 0.7); + } + } + if (halo.current) { + halo.current.visible = selected; + if (selected && animate) { + halo.current.rotation.z = t * 0.8; + const s = 1 + Math.sin(t * 3) * 0.04; + halo.current.scale.set(s, s, s); + } + } + if (pulse.current) { + // The working ring: a slow breath under the chair, in the team's color. + pulse.current.visible = active; + if (active && animate) { + const k = (t * 0.9 + angle) % 1; + const s = 0.8 + k * 0.9; + pulse.current.scale.set(s, s, s); + (pulse.current.material as THREE.MeshBasicMaterial).opacity = 0.55 * (1 - k); + } + } + // Lines of code rising off the screen while they type. + code.current.forEach((m, i) => { + if (!m) return; + m.visible = active && animate; + if (!m.visible) return; + const k = (t * 0.55 + i / code.current.length) % 1; + m.position.set(-0.12 + (i % 3) * 0.12, 1.2 + k * 0.7, -0.5); + m.scale.set(0.5 + ((i * 7) % 5) * 0.12, 1, 1); + (m.material as THREE.MeshBasicMaterial).opacity = k < 0.15 ? k / 0.15 : 1 - (k - 0.15) / 0.85; + }); + }); + + const label = agent.id.length > 18 ? agent.id.slice(0, 17) + '…' : agent.id; + const saying = agent.lastMessage && agent.lastMessage.length > 54 ? agent.lastMessage.slice(0, 53) + '…' : agent.lastMessage; + const skin = isManager ? hex : '#e5e7eb'; + const pick = { + onClick: (e: ThreeEvent) => { + e.stopPropagation(); + onSelect(); + }, + onPointerOver: (e: ThreeEvent) => { + e.stopPropagation(); + setHover(true); + document.body.style.cursor = 'pointer'; + }, + onPointerOut: () => { + setHover(false); + document.body.style.cursor = ''; + }, + }; + + return ( + + {/* Selection halo on the floor + a spot from above. */} + + + + + {selected && } + {/* Working: a pool of light from above, a breathing ring on the floor. */} + {active && } + + + + + {Array.from({ length: 6 }).map((_, i) => ( + { if (el) code.current[i] = el; }} visible={false}> + + + + ))} + {/* Chair */} + + + + + + + + + {/* Person */} + + + + + + + + + + + {/* Eyes, so a turned head reads as a glance. */} + + + + + + + + + {isManager && ( + + + + + )} + + + {/* Desk + monitor, toward the table (−z after the yaw). */} + + + + + + + + + + + + + {/* A mug on the idle desk; it goes when the typing starts. */} + {!active && ( + + + + + )} + {/* Activation flash. */} + + + + + {/* Name + the pop-out when working. */} + +
+ {active && ( +
+ + {agent.task ? `on ${agent.task}` : 'working'} + + + {saying && {saying}} +
+ )} + + {label} + {isManager && {team.managerDefault ? ' · manager (default)' : ' · manager'}} + {agent.borrowed && {` · ${agent.seat} from the ${agent.borrowed}`}} + +
+ +
+ ); +} + +/** Standing places on the stage: an arc facing the camera, the newest speaker in the middle. */ +function stageLayout(seats: FloorStageSeat[], at: THREE.Vector3): Map { + const out = new Map(); + const n = seats.length; + if (n === 0) return out; + const span = Math.min(Math.PI * 0.95, 0.9 * Math.max(n - 1, 0) + 0.001); + seats.forEach((s, k) => { + const f = n === 1 ? 0.5 : k / (n - 1); + const angle = Math.PI / 2 - span / 2 + f * span; + out.set(s.id, new THREE.Vector3(at.x + Math.cos(angle) * (STAGE_R - 0.5), STAGE_H, at.z + Math.sin(angle) * (STAGE_R - 0.5) * 0.55 - 0.5)); + }); + return out; +} + +/** + * The pipeline's stage: where the run's thinking happens between the tables' + * typing. A platform with a screen naming the phase the run is in and what is + * being said, and the phase agents — planner, splitter, architect… — standing + * at lecterns; whoever is speaking is lit, the rest wait in the wings. + */ +function Stage({ + seats, + phase, + at, + positions, + running, + animate, + dark, + selection, + onSelect, + onFocus, +}: { + seats: FloorStageSeat[]; + phase: FloorPhase | null; + at: THREE.Vector3; + positions: Map; + running: boolean; + animate: boolean; + dark: boolean; + selection: FloorSelection; + onSelect: (sel: FloorSelection) => void; + onFocus: () => void; +}) { + const live = running && !!phase; + const glow = useRef(null); + useFrame(({ clock }) => { + if (!glow.current) return; + const t = clock.getElapsedTime(); + glow.current.emissiveIntensity = live ? (animate ? 0.55 + Math.sin(t * 2.2) * 0.3 : 0.7) : 0.12; + }); + const message = phase?.message && phase.message.length > 90 ? phase.message.slice(0, 89) + '…' : phase?.message; + return ( + + {/* Platform */} + { e.stopPropagation(); onFocus(); }}> + + + + + + + + {/* The screen at the back of the stage. */} + + + + + + + + + + + + + + + + + + +
+ pipeline + {phase ? phase.id : running ? 'starting' : 'idle'} + {phase?.agent && {phase.agent}} + {message && {message}} +
+ +
+ + + + {seats.map((seat, i) => { + const p = positions.get(seat.id); + if (!p) return null; + const selected = selection?.kind === 'agent' && selection.id === seat.id; + return ( + onSelect(selected ? null : { kind: 'agent', id: seat.id, team: '' })} + /> + ); + })} +
+ ); +} + +/** One person standing at a lectern on the stage. */ +function StageFigure({ seat, pos, tall, running, animate, selected, onSelect }: { seat: FloorStageSeat; pos: THREE.Vector3; tall: boolean; running: boolean; animate: boolean; selected: boolean; onSelect: () => void }) { + const active = seat.active && running; + const body = useRef(null); + const ring = useRef(null); + const [hover, setHover] = useState(false); + const hex = '#8b5cf6'; + const glyph = ROLE_GLYPH_STAGE[seat.id] ?? ROLE_GLYPH_STAGE[seat.phase ?? ''] ?? '🎓'; + useFrame(({ clock }) => { + const t = clock.getElapsedTime(); + if (body.current) { + if (animate) { + // Speaking: a lively sway; waiting: a slow breath; dim before they have spoken. + body.current.position.y = active ? Math.abs(Math.sin(t * 5)) * 0.04 : Math.sin(t * 1.1 + pos.x) * 0.01; + body.current.rotation.z = active ? Math.sin(t * 2.5) * 0.05 : 0; + } + const target = hover || selected ? 1.08 : 1; + body.current.scale.lerp(new THREE.Vector3(target, target, target), 0.2); + } + if (ring.current) { + ring.current.visible = active || selected; + if (animate) { + const k = (t * 0.9) % 1; + const s = active ? 0.8 + k * 0.9 : 1; + ring.current.scale.set(s, s, s); + (ring.current.material as THREE.MeshBasicMaterial).opacity = active ? 0.55 * (1 - k) : 0.9; + } + } + }); + const pick = { + onClick: (e: ThreeEvent) => { + e.stopPropagation(); + onSelect(); + }, + onPointerOver: (e: ThreeEvent) => { + e.stopPropagation(); + setHover(true); + document.body.style.cursor = 'pointer'; + }, + onPointerOut: () => { + setHover(false); + document.body.style.cursor = ''; + }, + }; + const saying = seat.lastMessage && seat.lastMessage.length > 54 ? seat.lastMessage.slice(0, 53) + '…' : seat.lastMessage; + const tint = seat.spoke ? '#ddd6fe' : '#e5e7eb'; + return ( + + + + + + {active && } + {/* Lectern */} + + + + + + + + + + + + + + + + + + + +
+ {active && ( +
+ {seat.phase ? `${seat.phase} · speaking` : 'speaking'} + + {saying && {saying}} +
+ )} + + {seat.id} + {seat.phase && {` · ${seat.phase}`}} + +
+ +
+ ); +} + +const ROLE_GLYPH_STAGE: Record = { + planner: '📋', + plan: '📋', + splitter: '✂️', + split: '✂️', + explorer: '🔍', + explore: '🔍', + architect: '🏗️', + coordinator: '🎯', + coord: '🎯', + docs: '📖', + memory: '💾', + context: '📝', + composer: '🎼', + clarify: '💬', + skills: '🧰', + learn: '🎓', + polish: '✨', + qa: '🧪', + test: '🧪', +}; + +/** The team's board on the wall behind the manager: a column per state, a tile per ticket. */ +function WallBoard({ team, hex, dark, dir, running }: { team: FloorTeam; hex: string; dark: boolean; dir: THREE.Vector3; running: boolean }) { + const columns: [TicketState[], string, string][] = [ + [['queued'], 'queued', TICKET_HEX.queued], + [['working'], 'doing', TICKET_HEX.working], + [['review'], 'review', TICKET_HEX.review], + [['blocked', 'failed'], 'stuck', TICKET_HEX.blocked], + [['done'], 'done', TICKET_HEX.done], + ]; + const W = BOARD_W; + const H = BOARD_H; + const colW = W / columns.length; + const legH = BOARD_BOTTOM; + // Behind the manager's chair, facing the table, high enough that the + // manager's name never crosses it from the camera's side. + const pos = dir.clone().multiplyScalar(BOARD_BACK).setY(BOARD_BOTTOM + H / 2); + const yaw = Math.atan2(-dir.x, -dir.z); + const counts = columns.map(([states]) => team.tickets.filter((t) => states.includes(t.state)).length); + return ( + + {/* Legs + panel */} + + + + + + + + + + + + + + + + + {columns.map(([states, label, color], ci) => { + const x = -W / 2 + colW * (ci + 0.5); + const tickets = team.tickets.filter((t) => states.includes(t.state)); + const tileH = 0.11; + const tileGap = 0.04; + const maxTiles = Math.floor((H - 0.55) / (tileH + tileGap)); + const shown = tickets.slice(0, maxTiles); + return ( + + {/* The column's cap, in its state's color. */} + + + 0 ? 0.5 : 0.05} /> + + {shown.map((t, i) => ( + + + + + ))} + {ci < columns.length - 1 && ( + + + + + )} + + ); + })} + {/* One line of counts above the board — the columns below are the picture. */} + + `${counts[i]} ${label}`).join(' · ')}> + {team.tickets.length === 0 + ? running + ? 'the manager is splitting the work…' + : 'no tickets' + : columns.map(([, label, color], i) => ( + + + {counts[i]} + + ))} + + + + ); +} + +/** A thread from a ticket to the monitor of whoever holds it; beads run along it while they type. */ +function Thread({ from, to, color, live, animate }: { from: THREE.Vector3; to: THREE.Vector3; color: string; live: boolean; animate: boolean }) { + const mid = useMemo(() => from.clone().lerp(to, 0.5).add(new THREE.Vector3(0, 0.55, 0)), [from, to]); + const curve = useMemo(() => new THREE.QuadraticBezierCurve3(from, mid, to), [from, mid, to]); + const beads = useRef([]); + const N = live ? 2 : 0; + useFrame(({ clock }) => { + if (!animate) return; + const t = clock.getElapsedTime(); + beads.current.forEach((m, i) => { + if (!m) return; + const u = ((t * 0.5 + i / 2) % 1 + 1) % 1; + m.position.copy(curve.getPoint(u)); + }); + }); + return ( + + + {Array.from({ length: N }).map((_, i) => ( + { if (el) beads.current[i] = el; }} position={from}> + + + + ))} + + ); +} + +/** The manager sending someone onto a ticket: an arc from the head seat, alive for DISPATCH_S. */ +function Dispatch({ from, to, label, at, slot, animate }: { from: THREE.Vector3; to: THREE.Vector3; label: string; at: number; slot: number; animate: boolean }) { + const curve = useMemo(() => { + const s = from.clone().add(new THREE.Vector3(0, 1.5, 0)); + const e = to.clone().add(new THREE.Vector3(0, 2.3, 0)); + const m = s.clone().lerp(e, 0.5).add(new THREE.Vector3(0, 2.4, 0)); + return new THREE.QuadraticBezierCurve3(s, m, e); + }, [from, to]); + const dot = useRef(null); + const grp = useRef(null); + const [alive, setAlive] = useState(true); + useFrame(() => { + const age = nowSeconds() - at / 1000; + if (age > DISPATCH_S) { + if (alive) setAlive(false); + return; + } + if (dot.current) { + // The message flies for 1.2s, lands, and dissolves — nothing lingers on the person. + const flight = animate ? Math.min(1, age / 1.2) : 1; + dot.current.position.copy(curve.getPoint(flight)); + const fade = age < 1.2 ? 1 : Math.max(0, 1 - (age - 1.2) / 0.6); + dot.current.scale.setScalar(fade); + dot.current.visible = fade > 0; + } + if (grp.current) grp.current.visible = true; + }); + if (!alive) return null; + // The chip stacks over the manager's head, one row per dispatch, so two + // sent in the same breath never share a spot. + const chipAt = from.clone().add(new THREE.Vector3(0, 2.45 + slot * 0.36, 0)); + return ( + + + + + + + + 👔 → {label} + + + ); +} + +/** A burst: a ring that expands and sparks that rise, gone after BURST_S. */ +function Burst({ at, position, color, size, animate }: { at: number; position: THREE.Vector3; color: string; size: number; animate: boolean }) { + const ring = useRef(null); + const sparks = useRef([]); + const [alive, setAlive] = useState(true); + const N = 8; + const dirs = useMemo( + () => Array.from({ length: N }, (_, i) => new THREE.Vector3(Math.cos((i / N) * Math.PI * 2), 1.6 + (i % 3) * 0.3, Math.sin((i / N) * Math.PI * 2))), + [], + ); + useFrame(() => { + const age = nowSeconds() - at / 1000; + if (age > BURST_S || !animate) { + if (alive) setAlive(false); + return; + } + const k = age / BURST_S; + if (ring.current) { + const s = size * (0.3 + k * 2.4); + ring.current.scale.set(s, s, s); + (ring.current.material as THREE.MeshBasicMaterial).opacity = Math.max(0, 1 - k) * 0.9; + } + sparks.current.forEach((m, i) => { + if (!m) return; + const d = dirs[i]; + m.position.set(d.x * k * size * 1.4, d.y * k * size - 2.2 * k * k * size, d.z * k * size * 1.4); + (m.material as THREE.MeshStandardMaterial).opacity = Math.max(0, 1 - k); + }); + }); + if (!alive || !animate) return null; + return ( + + + + + + {dirs.map((_, i) => ( + { if (el) sparks.current[i] = el; }}> + + + + ))} + + ); +} + +function Conduit({ from, to, label, stalled, running, animate }: { from: Placed; to: Placed; label: string; stalled: boolean; running: boolean; animate: boolean }) { + const start = useMemo(() => from.pos.clone().add(new THREE.Vector3(0, TABLE_Y + 0.4, 0)), [from.pos]); + const end = useMemo(() => to.pos.clone().add(new THREE.Vector3(0, TABLE_Y + 0.4, 0)), [to.pos]); + const mid = useMemo(() => start.clone().lerp(end, 0.5).add(new THREE.Vector3(0, 2.6, 0)), [start, end]); + const curve = useMemo(() => new THREE.QuadraticBezierCurve3(start, mid, end), [start, mid, end]); + const packets = useRef([]); + const color = stalled ? '#f59e0b' : '#8b5cf6'; + const N = 3; + useFrame(({ clock }) => { + if (!animate) return; + const t = clock.getElapsedTime(); + const speed = stalled ? 0.05 : running ? 0.28 : 0.1; + packets.current.forEach((m, i) => { + if (!m) return; + const u = ((t * speed + i / N) % 1 + 1) % 1; + m.position.copy(curve.getPoint(u)); + const s = stalled ? 0.9 + Math.sin(t * 8) * 0.3 : 1; + m.scale.setScalar(s); + }); + }); + return ( + + + {Array.from({ length: N }).map((_, i) => ( + { if (el) packets.current[i] = el; }} position={start}> + + + + ))} + {/* The label rides a third of the way along, off the stage behind. */} + + + {label.length > 28 ? label.slice(0, 27) + '…' : label} + {stalled ? ' · waiting' : ''} + + + + ); +} + +function Spark({ handoff, placed, animate }: { handoff: FloorHandoff; placed: Map; animate: boolean }) { + const curve = useMemo(() => { + const find = (id: string) => { + const at = (p: Placed) => { + const s = p.seats.get(id); + return s ? s.pos.clone().add(p.pos) : undefined; + }; + const home = handoff.team ? placed.get(handoff.team) : undefined; + if (home) { + const w = at(home); + if (w) return w; + } + for (const p of placed.values()) { + const w = at(p); + if (w) return w; + } + return undefined; + }; + const a = find(handoff.from); + const b = find(handoff.to); + if (!a || !b) return null; + const s = a.clone().add(new THREE.Vector3(0, 1.5, 0)); + const e = b.clone().add(new THREE.Vector3(0, 1.5, 0)); + const m = s.clone().lerp(e, 0.5).add(new THREE.Vector3(0, 1.8, 0)); + return new THREE.QuadraticBezierCurve3(s, m, e); + }, [handoff.from, handoff.to, handoff.team, placed]); + const dot = useRef(null); + useFrame(({ clock }) => { + if (!curve || !dot.current) return; + const u = animate ? (clock.getElapsedTime() * 0.6) % 1 : 1; + dot.current.position.copy(curve.getPoint(u)); + }); + if (!curve) return null; + const mid = curve.getPoint(0.5); + return ( + + + + + + + + + {handoff.task} → {handoff.to} + {handoff.reason ? — {handoff.reason.length > 60 ? handoff.reason.slice(0, 59) + '…' : handoff.reason} : null} + + + + ); +} + +function IntegrationPad({ integration, unassigned }: { integration: NonNullable; unassigned: number }) { + const ready = !!integration.ready; + const color = ready ? '#10b981' : '#94a3b8'; + return ( + + + + + + + + {ready ? 'ready to join the halves' : 'integration waits'} + {!ready && integration.reason ? — {integration.reason} : null} +
+ {integration.acceptance || 'no integration command'}{unassigned > 0 ? ` · ${unassigned} seam ticket${unassigned === 1 ? '' : 's'}` : ''} +
+ +
+ ); +} + +/** + * Eases the orbit target to a focused point — and the camera in to `distance` + * of it when one is asked for — and hosts the controls. A close focus (a + * person, a ticket) is nudged toward the camera's left so the subject lands + * beside the dossier, not under it. The glide stops the moment the user takes + * the camera back (any drag or wheel). + */ +function CameraRig({ focus, distance, home, homeCam, autoRotate }: { focus: THREE.Vector3 | null; distance: number | null; home: THREE.Vector3; homeCam: THREE.Vector3; autoRotate: boolean }) { + const controls = useRef(null); + const target = useRef(home.clone()); + const want = useRef(null); + const glide = useRef(null); + const { invalidate, camera } = useThree(); + // The floor grew or shrank (a stage appeared, a team joined): re-frame it, + // unless the user is looking at something in particular. + useEffect(() => { + if (focus) return; + glide.current = homeCam.clone(); + invalidate(); + }, [homeCam, focus, invalidate]); + useEffect(() => { + const t = focus ? focus.clone() : home.clone(); + if (focus && distance !== null && distance < 10) { + const right = new THREE.Vector3().setFromMatrixColumn(camera.matrixWorld, 0).setY(0).normalize(); + t.sub(right.multiplyScalar(distance * 0.28)); + } + target.current.copy(t); + want.current = focus ? distance : null; + invalidate(); + }, [focus, distance, home, invalidate, camera]); + useEffect(() => { + const c = controls.current; + if (!c) return undefined; + const release = () => { + want.current = null; + glide.current = null; + }; + c.addEventListener('start', release); + return () => c.removeEventListener('start', release); + }, []); + useFrame(() => { + const c = controls.current; + if (!c) return; + let moved = false; + if (c.target.distanceToSquared(target.current) > 0.0004) { + c.target.lerp(target.current, 0.08); + moved = true; + } + if (glide.current) { + if (camera.position.distanceToSquared(glide.current) > 0.01) { + camera.position.lerp(glide.current, 0.06); + moved = true; + } else { + glide.current = null; + } + } + if (want.current !== null) { + glide.current = null; + const dir = camera.position.clone().sub(c.target); + const have = dir.length(); + if (Math.abs(have - want.current) > 0.05) { + const goal = c.target.clone().add(dir.normalize().multiplyScalar(want.current)); + camera.position.lerp(goal, 0.08); + moved = true; + } else { + want.current = null; + } + } + if (moved) c.update(); + }); + return ; +} + +function Legend({ floor }: { floor: FloorModel }) { + const items: [TicketState, string][] = [ + ['working', 'in progress'], + ['review', 'in review'], + ['blocked', 'blocked'], + ['done', 'done'], + ['queued', 'queued'], + ]; + return ( +
+ {floor.summary} + {items.map(([state, label]) => ( + + + {label} + + ))} +
+ ); +} diff --git a/web/src/components/Live/floor/TeamFloorFlat.tsx b/web/src/components/Live/floor/TeamFloorFlat.tsx new file mode 100644 index 0000000..215e3cb --- /dev/null +++ b/web/src/components/Live/floor/TeamFloorFlat.tsx @@ -0,0 +1,642 @@ +import { useEffect, useMemo, useState } from 'react'; +import clsx from 'clsx'; +import { teamColor } from '@/components/Board/teamColor'; +import type { FloorAgent, FloorHandoff, FloorModel, FloorPhase, FloorPulse, FloorStageSeat, FloorTeam, FloorTicket, SeatKind, TicketState } from './floorModel'; +import { ago, type FloorSelection } from './floorShared'; + +// ── The team floor ─────────────────────────────────────────────────────── +// +// A run is people on islands. Each team is a raised platform with its manager +// at the head, its members around it and its tickets on the desk; the frozen +// contract runs between platforms as conduits, and traffic flows along them +// from provider to consumer. Whoever is working glows and says what on, a +// consumer waiting on a clause it has not been given turns its conduit amber, +// a half that proved itself gets a green rim, and a manager moving a ticket +// from one person to another is a spark that crosses the island. +// +// It is drawn in SVG under a CSS perspective — a tilted, softly lit floor +// rather than a chart — because the page exists to be watched for eleven +// minutes at a time, and a thing that breathes reads as alive where a table +// reads as stuck. Every motion is CSS, so `prefers-reduced-motion` (honored +// globally) freezes it to a still picture with nothing lost. + +export interface TeamFloorFlatProps { + floor: FloorModel; + running: boolean; + /** Current time, injected for tests. */ + now?: number; + /** Optional: what to do when a ticket is clicked (e.g. focus it in the rail). */ + onTicket?: (id: string) => void; + /** The clicked person or ticket, drawn highlighted. */ + selection?: FloorSelection; + onSelect?: (sel: FloorSelection) => void; + /** What just changed; the flat stage flashes the tickets concerned. */ + pulses?: FloorPulse[]; +} + +const HEX: Record = { + teal: '#14b8a6', + fuchsia: '#d946ef', + cyan: '#06b6d4', + rose: '#f43f5e', + lime: '#84cc16', + purple: '#a855f7', + gray: '#9ca3af', +}; + +const SEAT_GLYPH: Record = { + manager: '👔', + worker: '🔧', + reviewer: '👁️', + tester: '🧪', + member: '🤖', +}; + +const ROLE_GLYPH: Record = { + planner: '📋', + splitter: '✂️', + explorer: '🔍', + architect: '🏗️', + coordinator: '🎯', + docs: '📖', + memory: '💾', + context: '📝', + composer: '🎼', + triage: '👔', + corrector: '✏️', + deep: '🧠', + reviewer: '👁️', + tester: '🧪', + worker: '🔧', +}; + +const TICKET_FILL: Record = { + queued: '#cbd5e1', + working: '#f59e0b', + review: '#f97316', + blocked: '#ef4444', + done: '#10b981', + failed: '#dc2626', +}; + +const TICKET_LABEL: Record = { + queued: 'queued', + working: 'in progress', + review: 'in review', + blocked: 'blocked', + done: 'done', + failed: 'failed', +}; + +// Stage geometry, in SVG units. The viewBox scales to the container. +const W = 1000; +const H = 560; +const ISLAND_RX = 150; +const ISLAND_RY = 78; +const ISLAND_DEPTH = 16; + +interface Placed { + team: FloorTeam; + cx: number; + cy: number; + hex: string; + agentPos: Map; +} + +function glyphFor(agent: FloorAgent): string { + if (agent.seat !== 'member') return SEAT_GLYPH[agent.seat]; + for (const [role, glyph] of Object.entries(ROLE_GLYPH)) { + if (agent.id === role || agent.id.endsWith('-' + role)) return glyph; + } + return SEAT_GLYPH.member; +} + +/** Islands are laid out on a shallow arc, so three read as a floor and not a row. */ +function layout(teams: FloorTeam[]): Placed[] { + const n = teams.length; + return teams.map((team, i) => { + const t = n === 1 ? 0.5 : i / (n - 1); + const cx = n === 1 ? W / 2 : 190 + t * (W - 380); + // Alternate rows so conduits have room to curve. + const cy = n <= 2 ? H / 2 + 10 : 200 + (i % 2) * 190; + const hex = HEX[teamColor(team.crew ? '' : team.id).name] ?? HEX.gray; + const agentPos = new Map(); + // Manager at the head; everyone else spaced along the front edge of the + // island, where the tickets are within reach. + const others = team.agents.filter((a) => a.seat !== 'manager'); + const manager = team.agents.find((a) => a.seat === 'manager'); + if (manager) agentPos.set(manager.id, { x: cx, y: cy - ISLAND_RY - 34 }); + const spread = Math.min(ISLAND_RX * 1.6, 56 * Math.max(others.length, 1)); + others.forEach((a, k) => { + const f = others.length === 1 ? 0.5 : k / (others.length - 1); + agentPos.set(a.id, { x: cx - spread / 2 + f * spread, y: cy + ISLAND_RY - 6 }); + }); + return { team, cx, cy, hex, agentPos }; + }); +} + +export default function TeamFloorFlat({ floor, running, now, onTicket, selection = null, onSelect, pulses }: TeamFloorFlatProps) { + const placed = useMemo(() => layout(floor.teams), [floor.teams]); + const byID = useMemo(() => new Map(placed.map((p) => [p.team.id, p])), [placed]); + + // A clock, for the "on this for 2m14s" bubble and for expiring handoffs. + const [tick, setTick] = useState(() => now ?? Date.now()); + useEffect(() => { + if (now !== undefined) { + setTick(now); + return undefined; + } + if (!running) return undefined; + const id = window.setInterval(() => setTick(Date.now()), 1000); + return () => window.clearInterval(id); + }, [running, now]); + + // Tickets with a pulse still on them flash, whichever kind it was. + const flashing = useMemo(() => new Set((pulses ?? []).filter((p) => p.ticket && tick - p.at < 4000).map((p) => p.ticket!)), [pulses, tick]); + + if (floor.mode === 'idle') { + return ; + } + + return ( +
+ + ); +} + +// ── Pieces ─────────────────────────────────────────────────────────────── + +function Island({ + placed, + running, + tick, + onTicket, + selection, + onSelect, + flashing, +}: { + placed: Placed; + running: boolean; + tick: number; + onTicket?: (id: string) => void; + selection: FloorSelection; + onSelect?: (sel: FloorSelection) => void; + flashing: Set; +}) { + const { team, cx, cy, hex, agentPos } = placed; + const pct = team.total > 0 ? Math.round((team.done / team.total) * 100) : 0; + const rim = + team.gate === 'green' ? '#10b981' : team.gate === 'red' ? '#ef4444' : team.waitingOn.length > 0 ? '#f59e0b' : hex; + const rimWidth = team.gate !== '' || team.waitingOn.length > 0 ? 3.5 : 2; + const active = team.agents.some((a) => a.active); + + return ( + + {/* Thickness, then the top face. */} + + + {team.waitingOn.length > 0 && ( + + )} + + {/* Name + progress ring on the desk. */} + + {team.name} + + + {team.total > 0 ? `${team.done}/${team.total} done` : running ? 'no tickets yet' : 'idle'} + {team.blocked > 0 ? ` · ${team.blocked} blocked` : ''} + {team.gate === 'green' ? ' · proved' : team.gate === 'red' ? ' · RED' : team.gate === 'unverified' ? ' · unverified' : ''} + + + + {/* Tickets, stacked on the desk in state order. */} + + + {/* People. */} + {team.agents.map((a) => { + const pos = agentPos.get(a.id); + if (!pos) return null; + return ; + })} + + {team.waitingOn.length > 0 && ( + + waiting on {team.waitingOn.join(', ')} + + )} + + ); +} + +function ProgressArc({ cx, cy, r, pct, color }: { cx: number; cy: number; r: number; pct: number; color: string }) { + const c = 2 * Math.PI * r; + return ( + + + + + {pct}% + + + ); +} + +const ORDER: TicketState[] = ['working', 'review', 'blocked', 'failed', 'queued', 'done']; + +function Tickets({ team, cx, cy, onTicket, onSelect, selected, flashing }: { team: FloorTeam; cx: number; cy: number; onTicket?: (id: string) => void; onSelect?: (sel: FloorSelection) => void; selected: string; flashing: Set }) { + const sorted = [...team.tickets].sort((a, b) => ORDER.indexOf(a.state) - ORDER.indexOf(b.state)); + const shown = sorted.slice(0, 8); + const more = sorted.length - shown.length; + const perRow = 4; + const w = 52; + const h = 18; + const gap = 6; + const rows = Math.ceil(shown.length / perRow); + const startY = cy + 14 - ((rows - 1) * (h + gap)) / 2; + return ( + + {shown.map((t, i) => { + const row = Math.floor(i / perRow); + const inRow = Math.min(perRow, shown.length - row * perRow); + const col = i % perRow; + const x = cx - (inRow * (w + gap) - gap) / 2 + col * (w + gap); + const y = startY + row * (h + gap); + return ; + })} + {more > 0 && ( + + +{more} + + )} + + ); +} + +function Ticket({ + ticket, + x, + y, + w, + h, + onTicket, + onSelect, + selected, + flashing, +}: { + ticket: FloorTicket; + x: number; + y: number; + w: number; + h: number; + onTicket?: (id: string) => void; + onSelect?: (sel: FloorSelection) => void; + selected: boolean; + flashing: boolean; +}) { + const live = ticket.state === 'working' || ticket.state === 'review'; + // A click opens the dossier when the floor has one; the dossier links on + // to the Tasks rail. Without a dossier the click goes straight there. + const pick = onSelect ? () => onSelect({ kind: 'ticket', id: ticket.id, team: ticket.team }) : onTicket ? () => onTicket(ticket.id) : undefined; + return ( + { + if (e.key === 'Enter' || e.key === ' ') pick(); + } + : undefined + } + > + {`${ticket.id} · ${TICKET_LABEL[ticket.state]}${ticket.agent ? ` · ${ticket.agent}` : ''}\n${ticket.title}`} + {selected && } + {flashing && } + + + {ticket.id.length > 8 ? ticket.id.slice(0, 7) + '…' : ticket.id} + + + ); +} + +function Avatar({ + agent, + x, + y, + hex, + running, + tick, + team, + selected, + onSelect, +}: { + agent: FloorAgent; + x: number; + y: number; + hex: string; + running: boolean; + tick: number; + team: FloorTeam; + selected: boolean; + onSelect?: (sel: FloorSelection) => void; +}) { + const r = agent.seat === 'manager' ? 17 : 15; + const pick = onSelect ? () => onSelect({ kind: 'agent', id: agent.id, team: team.id }) : undefined; + const label = agent.id.length > 16 ? agent.id.slice(0, 15) + '…' : agent.id; + const isManager = agent.seat === 'manager'; + const glowing = agent.active && running; + return ( + { + if (e.key === 'Enter' || e.key === ' ') pick(); + } + : undefined + } + > + {selected && } + + {`${agent.id} · ${isManager ? (team.managerDefault ? 'project manager (run default)' : 'project manager') : agent.seat}` + + (agent.borrowed ? ` (the team names none — lent by the ${agent.borrowed})` : '') + + (agent.touched ? ` · ${agent.touched} ticket${agent.touched === 1 ? '' : 's'} touched` : '')} + + {glowing && } + {glowing && } + + + {glyphFor(agent)} + + + {label} + + {isManager && ( + + {team.managerDefault ? 'manager · run default' : 'manager'} + + )} + {agent.borrowed && ( + + {agent.seat} · from the {agent.borrowed} + + )} + {glowing && ( + + + + {agent.task ? agent.task : 'working'} · {elapsed(tick, agent)} + + + )} + + ); +} + +function elapsed(tick: number, agent: FloorAgent): string { + // Since the agent's last line — the closest thing the log has to "started". + return agent.lastAt ? ago(agent.lastAt, tick).replace(' ago', '') : 'live'; +} + +function Conduit({ from, to, label, stalled, running }: { from: Placed; to: Placed; label: string; stalled: boolean; running: boolean }) { + // From the provider's right edge to the consumer's left edge, bowing away + // from the islands' centre line. + const x1 = from.cx + (to.cx >= from.cx ? ISLAND_RX * 0.6 : -ISLAND_RX * 0.6); + const y1 = from.cy; + const x2 = to.cx + (to.cx >= from.cx ? -ISLAND_RX * 0.6 : ISLAND_RX * 0.6); + const y2 = to.cy; + const bow = from.cy === to.cy ? -70 : 0; + const mx = (x1 + x2) / 2; + const my = (y1 + y2) / 2 + bow; + const d = `M ${x1} ${y1} Q ${mx} ${my} ${x2} ${y2}`; + const color = stalled ? '#f59e0b' : '#8b5cf6'; + return ( + + + + + + + {label.length > 24 ? label.slice(0, 23) + '…' : label} + {stalled ? ' · waiting' : ''} + + + + ); +} + +function Handoff({ handoff, placed, tick }: { handoff: FloorHandoff; placed: Map; tick: number }) { + // Find both ends: on the ticket's team when known, else anywhere. + const find = (id: string) => { + const home = handoff.team ? placed.get(handoff.team) : undefined; + if (home?.agentPos.get(id)) return home.agentPos.get(id)!; + for (const p of placed.values()) { + const pos = p.agentPos.get(id); + if (pos) return pos; + } + return undefined; + }; + const a = find(handoff.from); + const b = find(handoff.to); + if (!a || !b) return null; + const age = Math.max(0, tick - handoff.at); + const fade = Math.max(0.25, 1 - age / 45_000); + const d = `M ${a.x} ${a.y - 40} Q ${(a.x + b.x) / 2} ${Math.min(a.y, b.y) - 90} ${b.x} ${b.y - 40}`; + return ( + + + + + + + {handoff.task} → {handoff.to} + + + ); +} + +function IntegrationPlate({ integration, unassigned }: { integration: NonNullable; unassigned: number }) { + const ready = !!integration.ready; + const color = ready ? '#10b981' : '#94a3b8'; + return ( + + + + {ready ? 'ready to join the halves' : `integration waits${integration.reason ? ` — ${integration.reason}` : ''}`} + + + {integration.acceptance ? integration.acceptance : 'no integration command'} + {unassigned > 0 ? ` · ${unassigned} seam ticket${unassigned === 1 ? '' : 's'}` : ''} + + + ); +} + +function Legend({ floor }: { floor: FloorModel }) { + const items: [TicketState, string][] = [ + ['working', 'in progress'], + ['review', 'in review'], + ['blocked', 'blocked'], + ['done', 'done'], + ['queued', 'queued'], + ]; + return ( +
+ {floor.mode === 'teams' ? floor.summary : floor.summary} + {items.map(([state, label]) => ( + + + {label} + + ))} +
+ ); +} + +/** The pipeline's own people, off to the side: the phase the run is in and who is speaking. */ +function PipelineStrip({ stage, phase, running, selection, onSelect }: { stage: FloorStageSeat[]; phase: FloorPhase | null; running: boolean; selection: FloorSelection; onSelect?: (sel: FloorSelection) => void }) { + return ( +
+
+ pipeline + {phase ? phase.id : running ? 'starting' : 'idle'} + {phase?.agent && {phase.agent}} +
+ {phase?.message &&
{phase.message}
} + {stage.length > 0 && ( +
    + {stage.map((a) => { + const live = a.active && running; + const selected = selection?.kind === 'agent' && selection.id === a.id; + return ( +
  • + +
  • + ); + })} +
+ )} +
+ ); +} + +function IdleFloor() { + return ( +
+ + ); +} + +function cssID(id: string): string { + return id.replace(/[^a-z0-9_-]/gi, '_'); +} diff --git a/web/src/components/Live/floor/floorModel.test.ts b/web/src/components/Live/floor/floorModel.test.ts new file mode 100644 index 0000000..edd5387 --- /dev/null +++ b/web/src/components/Live/floor/floorModel.test.ts @@ -0,0 +1,392 @@ +import { describe, expect, it } from 'vitest'; +import { agentTrail, buildFloor, diffFloors, freshPulses, ticketState, HANDOFF_TTL_MS, PULSE_TTL_MS } from './floorModel'; +import type { DynamicComposition, RunEvent, SquadsView, Task } from '@/types'; + +const T0 = Date.parse('2026-09-10T10:00:00Z'); +const at = (s: number) => new Date(T0 + s * 1000).toISOString(); + +function task(id: string, over: Partial = {}): Task { + return { + id, + title: `Task ${id}`, + description: '', + role: 'worker', + assignee: '', + column: 'ready_to_dev', + status: 'ready', + priority: 0, + depends_on: [], + files: [], + acceptance: '', + checklist: [], + output: '', + review: '', + retries: 0, + error: '', + updated_at: '', + notes: '', + ...over, + }; +} + +const chart: SquadsView = { + ok: true, + summary: 'Go API + React SPA', + squads: [ + { + id: 'backend-go', name: 'Backend', owns: ['cmd/**'], acceptance: 'go test ./...', + worker: 'go-worker', reviewer: 'go-reviewer', tester: 'go-tester', manager: 'backend-triage', + agents: ['go-corrector'], total: 2, done: 1, blocked: 0, in_flight: 1, complete: false, stuck: false, + }, + { + id: 'frontend-react', name: 'Frontend', owns: ['web/**'], acceptance: 'npm run build', + worker: 'react-worker', total: 1, done: 0, blocked: 1, in_flight: 0, complete: false, stuck: true, + }, + ], + task_teams: { T1: 'backend-go', T2: 'backend-go', T3: 'frontend-react' }, + interfaces: [{ id: 'GET /api/todos', provider: 'backend-go', consumers: ['frontend-react'], spec: '200 -> []' }], + stalls: [{ squad: 'frontend-react', interface: 'GET /api/todos', provider: 'backend-go' }], + gates: [{ team: 'backend-go', command: 'go test ./...', ran: true, ok: true }], + integration: { acceptance: 'make e2e', ready: false, reason: 'frontend-react is not complete' }, +}; + +const tasks = [ + task('T1', { status: 'done', column: 'done' }), + task('T2', { status: 'running', column: 'in_progress' }), + task('T3', { status: 'blocked', column: 'blocked' }), + task('T9', { title: 'the seam' }), +]; + +const events: RunEvent[] = [ + { phase: 'charter', kind: 'agent_start', agent: 'manager', message: 'team backend-go owns cmd/**', time: at(1) }, + { phase: 'execute', kind: 'agent_start', agent: 'go-worker', task_id: 'T1', message: 'implementing T1', time: at(2) }, + { phase: 'execute', kind: 'agent_end', agent: 'go-worker', task_id: 'T1', message: 'done', time: at(3) }, + { phase: 'execute', kind: 'agent_start', agent: 'go-worker', task_id: 'T2', message: 'implementing T2', time: at(4) }, +]; + +describe('ticketState', () => { + it('folds column and status into the six the floor draws', () => { + expect(ticketState({ column: 'done', status: '' })).toBe('done'); + expect(ticketState({ column: 'in_progress', status: '' })).toBe('working'); + expect(ticketState({ column: 'in_review', status: '' })).toBe('review'); + expect(ticketState({ column: 'ready_to_dev', status: 'correcting' })).toBe('review'); + expect(ticketState({ column: 'blocked', status: '' })).toBe('blocked'); + expect(ticketState({ column: 'ready_to_dev', status: 'failed' })).toBe('failed'); + expect(ticketState({ column: 'to_scope', status: 'todo' })).toBe('queued'); + }); +}); + +describe('buildFloor with an org chart', () => { + const floor = buildFloor({ squads: chart, tasks, events, composition: null, running: true, now: T0 + 5000 }); + + it('puts each team on the floor with its seats and manager', () => { + expect(floor.mode).toBe('teams'); + expect(floor.teams.map((t) => t.id)).toEqual(['backend-go', 'frontend-react']); + const backend = floor.teams[0]; + expect(backend.manager).toBe('backend-triage'); + expect(backend.managerDefault).toBe(false); + expect(backend.agents.map((a) => `${a.seat}:${a.id}`)).toEqual([ + 'manager:backend-triage', 'worker:go-worker', 'reviewer:go-reviewer', 'tester:go-tester', 'member:go-corrector', + ]); + const frontend = floor.teams[1]; + expect(frontend.manager).toBe('triage'); + expect(frontend.managerDefault).toBe(true); + }); + + it('attaches tickets to their team and keeps the seam apart', () => { + const backend = floor.teams[0]; + expect(backend.tickets.map((t) => `${t.id}:${t.state}`)).toEqual(['T1:done', 'T2:working']); + expect(floor.teams[1].tickets.map((t) => t.id)).toEqual(['T3']); + expect(floor.unassigned.map((t) => t.id)).toEqual(['T9']); + }); + + it('marks the agent the log says is working, on its ticket, on its team only', () => { + const backend = floor.teams[0]; + const worker = backend.agents.find((a) => a.id === 'go-worker')!; + expect(worker.active).toBe(true); + expect(worker.task).toBe('T2'); + expect(worker.touched).toBe(2); + expect(worker.tickets).toEqual(['T1', 'T2']); + expect(worker.lastMessage).toBe('implementing T2'); + expect(worker.lastAt).toBe(T0 + 4000); + expect(backend.tickets.find((t) => t.id === 'T2')!.agent).toBe('go-worker'); + expect(backend.tickets.find((t) => t.id === 'T2')!.touchedBy).toEqual(['go-worker']); + // T1 was released by agent_end. + expect(backend.tickets.find((t) => t.id === 'T1')!.agent).toBeUndefined(); + expect(floor.teams[1].agents.every((a) => !a.active)).toBe(true); + expect(floor.now).toMatchObject({ agent: 'go-worker', task: 'T2', team: 'backend-go' }); + }); + + it('draws the contract as links and marks the stalled one', () => { + expect(floor.links).toEqual([ + { id: 'backend-go→frontend-react:GET /api/todos', from: 'backend-go', to: 'frontend-react', interface: 'GET /api/todos', stalled: true }, + ]); + expect(floor.teams[1].waitingOn).toEqual(['GET /api/todos']); + }); + + it('carries the gates and the integration state', () => { + expect(floor.teams[0].gate).toBe('green'); + expect(floor.teams[1].gate).toBe(''); + expect(floor.integration).toMatchObject({ acceptance: 'make e2e', ready: false }); + }); + + it('prefers the composition’s resolved manager over the chart’s wish', () => { + const composition: DynamicComposition = { + summary: 'x', + teams: [{ id: 'frontend-react', manager: 'fe-triage', manager_default: false }], + }; + const f = buildFloor({ squads: chart, tasks, events, composition, running: true, now: T0 }); + expect(f.teams[1].manager).toBe('fe-triage'); + expect(f.teams[1].managerDefault).toBe(false); + }); + + it('keeps recent manager handoffs and forgets old ones', () => { + const withHandoff: RunEvent[] = [ + ...events, + { phase: 'coord', kind: 'debug', agent: 'backend-triage', task_id: 'T2', message: 'backend-triage proposes go-corrector — compile error', time: at(10) }, + { phase: 'plan', kind: 'output', agent: 'go-corrector', task_id: 'T2', message: 'T2 reassigned from go-worker to go-corrector — compile error', time: at(11) }, + ]; + const fresh = buildFloor({ squads: chart, tasks, events: withHandoff, composition: null, running: true, now: T0 + 12_000 }); + expect(fresh.handoffs.map((h) => `${h.from}→${h.to}`)).toEqual(['backend-triage→go-corrector', 'go-worker→go-corrector']); + expect(fresh.handoffs[1]).toMatchObject({ task: 'T2', team: 'backend-go', reason: 'compile error' }); + + const stale = buildFloor({ squads: chart, tasks, events: withHandoff, composition: null, running: true, now: T0 + 12_000 + HANDOFF_TTL_MS }); + expect(stale.handoffs).toEqual([]); + }); +}); + +describe('buildFloor with one team', () => { + it('seats the lone team at its own table with every ticket, seam or not', () => { + const one: SquadsView = { ok: true, summary: 'a Python service', squads: [{ id: 'python-api', name: 'Python API', owns: ['**'], acceptance: 'pytest', worker: 'py-worker', reviewer: 'py-reviewer', manager: 'py-lead', total: 2, done: 0, blocked: 0, in_flight: 1, complete: false, stuck: false }], task_teams: { T1: 'python-api' } }; + const f = buildFloor({ squads: one, tasks: [task('T1', { squad: 'python-api' }), task('T2', { status: 'running', column: 'in_progress' })], events: [{ phase: 'execute', kind: 'agent_start', agent: 'py-worker', task_id: 'T2', message: 'on it', time: at(1) }], composition: null, running: true, now: T0 + 2000 }); + expect(f.mode).toBe('teams'); + expect(f.teams).toHaveLength(1); + expect(f.teams[0].manager).toBe('py-lead'); + expect(f.teams[0].tickets.map((t) => t.id)).toEqual(['T1', 'T2']); + expect(f.unassigned).toEqual([]); + expect(f.teams[0].agents.find((a) => a.id === 'py-worker')).toMatchObject({ active: true, task: 'T2' }); + }); +}); + +describe('buildFloor without an org chart', () => { + it('is idle with nothing to show', () => { + const f = buildFloor({ squads: { ok: false }, tasks: [], events: [], composition: null, running: false, now: T0 }); + expect(f.mode).toBe('idle'); + expect(f.teams).toEqual([]); + }); + + it('shows the pipeline crew from the composition and the log', () => { + const composition: DynamicComposition = { + summary: 'a fix', + team_note: 'no team matched this request — it runs as one stream', + execute: { default_role: 'go-worker', reviewer: 'reviewer', corrector: 'corrector' }, + phases: [{ id: 'plan', agent: 'planner', enabled: true }, { id: 'polish', agent: 'reviewer', enabled: false }], + team: [{ role: 'go-tester' }], + }; + const f = buildFloor({ squads: { ok: false }, tasks: tasks.slice(0, 2), events, composition, running: true, now: T0 }); + expect(f.mode).toBe('crew'); + expect(f.teams).toHaveLength(1); + const crew = f.teams[0]; + expect(crew.name).toBe('Pipeline crew'); + expect(crew.crew).toBe(true); + expect(crew.agents.map((a) => a.id)).toEqual(['go-worker', 'reviewer', 'corrector', 'go-tester', 'planner']); + expect(crew.agents.find((a) => a.id === 'go-worker')).toMatchObject({ seat: 'worker', active: true, task: 'T2' }); + expect(crew.tickets).toHaveLength(2); + expect(crew.done).toBe(1); + expect(f.summary).toContain('one stream'); + }); + + it('shows a single library team as the island when one staffs the run', () => { + const composition: DynamicComposition = { + summary: 'x', + team_mode: 'single', + team_note: 'team backend-go staffs this run as one stream', + teams: [{ id: 'backend-go', name: 'Backend', worker: 'go-worker', tester: 'go-tester', manager: 'backend-triage', agents: ['deep'] }], + }; + const f = buildFloor({ squads: { ok: false }, tasks: [], events: [], composition, running: false, now: T0 }); + expect(f.mode).toBe('crew'); + expect(f.teams[0]).toMatchObject({ id: 'backend-go', name: 'Backend', manager: 'backend-triage', managerDefault: false }); + expect(f.teams[0].agents.map((a) => `${a.seat}:${a.id}`)).toEqual([ + 'manager:backend-triage', 'worker:go-worker', 'tester:go-tester', 'member:deep', + ]); + }); + + it('adds whoever the log heard from, even when the plan never named them', () => { + const ev: RunEvent[] = [{ phase: 'explore', kind: 'agent_start', agent: 'explorer', message: 'looking', time: at(1) }]; + const f = buildFloor({ squads: null, tasks: [], events: ev, composition: null, running: true, now: T0 }); + expect(f.mode).toBe('crew'); + expect(f.teams[0].agents).toMatchObject([{ id: 'explorer', seat: 'member', active: true, task: undefined, touched: 0 }]); + expect(f.now?.agent).toBe('explorer'); + }); +}); + +describe('borrowed seats', () => { + it('seats the pipeline’s tester at a team that names none, marked as borrowed', () => { + const noTester: SquadsView = { + ...chart, + squads: [{ ...chart.squads![0], tester: undefined, reviewer: undefined }, chart.squads![1]], + }; + const composition: DynamicComposition = { + summary: 'x', + teams: [ + { + id: 'backend-go', + worker: 'go-worker', + manager: 'backend-triage', + seats: [ + { role: 'worker', agent: 'go-worker', source: 'team' }, + { role: 'reviewer', agent: 'reviewer', source: 'pipeline' }, + { role: 'tester', agent: 'go-tester', source: 'pipeline' }, + { role: 'manager', agent: 'backend-triage', source: 'team' }, + ], + gaps: ['team backend-go names no tester — the pipeline\'s go-tester takes its tester seat'], + }, + ], + }; + const f = buildFloor({ squads: noTester, tasks, events, composition, running: true, now: T0 }); + const backend = f.teams[0]; + expect(backend.agents.map((a) => `${a.seat}:${a.id}${a.borrowed ? `(${a.borrowed})` : ''}`)).toEqual([ + 'manager:backend-triage', 'worker:go-worker', 'member:go-corrector', 'reviewer:reviewer(pipeline)', 'tester:go-tester(pipeline)', + ]); + // A borrowed seat never duplicates a member the team already has. + const dup: DynamicComposition = { summary: 'x', teams: [{ id: 'backend-go', seats: [{ role: 'tester', agent: 'go-corrector', source: 'pipeline' }] }] }; + const g = buildFloor({ squads: noTester, tasks, events, composition: dup, running: true, now: T0 }); + expect(g.teams[0].agents.filter((a) => a.id === 'go-corrector')).toHaveLength(1); + }); +}); + +describe('agentTrail', () => { + it('lists one agent’s recent lines, newest first, skipping token noise', () => { + const withNoise: RunEvent[] = [ + ...events, + { phase: 'execute', kind: 'token', agent: 'go-worker', task_id: 'T2', message: 'fn', time: at(5) }, + { phase: 'execute', kind: 'output', agent: 'go-worker', task_id: 'T2', message: 'wrote handler.go', time: at(6) }, + { phase: 'execute', kind: 'output', agent: 'go-reviewer', task_id: 'T2', message: 'looks fine', time: at(7) }, + ]; + const trail = agentTrail(withNoise, 'go-worker'); + expect(trail.map((l) => l.message)).toEqual(['wrote handler.go', 'implementing T2', 'done', 'implementing T1']); + expect(trail[0]).toMatchObject({ task: 'T2', kind: 'output', at: T0 + 6000 }); + expect(agentTrail(withNoise, 'go-worker', 2)).toHaveLength(2); + expect(agentTrail(withNoise, 'nobody')).toEqual([]); + }); +}); + +describe('diffFloors', () => { + const base = { squads: chart, composition: null, running: true, now: T0 + 5000 }; + + it('says nothing about the first floor', () => { + const next = buildFloor({ ...base, tasks, events }); + expect(diffFloors(null, next, T0)).toEqual([]); + }); + + it('flashes a ticket that appears, one that finishes, and a person who starts', () => { + const before = buildFloor({ ...base, tasks: tasks.slice(0, 3), events: events.slice(0, 3) }); + const after = buildFloor({ + ...base, + tasks: [task('T1', { status: 'done', column: 'done' }), task('T2', { status: 'done', column: 'done' }), task('T3', { status: 'blocked', column: 'blocked' }), task('T4', { title: 'a new one' })], + events, + }); + const pulses = diffFloors(before, after, T0 + 9000); + const kinds = pulses.map((p) => [p.kind, p.kind === 'agent-start' ? p.agent : p.ticket]); + expect(kinds).toEqual( + expect.arrayContaining([ + ['ticket-done', 'T2'], + ['ticket-new', 'T4'], + ['agent-start', 'go-worker'], + ]), + ); + const done = pulses.find((p) => p.kind === 'ticket-done')!; + expect(done).toMatchObject({ tone: 'good', team: 'backend-go', text: 'T2 done ✓ · go-worker', detail: 'Task T2', at: T0 + 9000 }); + const fresh = pulses.find((p) => p.kind === 'ticket-new')!; + expect(fresh.text).toBe('T4 appeared'); // T4 is not on any team: the seam + const start = pulses.find((p) => p.kind === 'agent-start')!; + expect(start).toMatchObject({ team: 'backend-go', ticket: 'T2', text: 'go-worker started on T2', detail: 'implementing T2' }); + // Unchanged tickets produce nothing. + expect(pulses.some((p) => p.ticket === 'T1' || p.ticket === 'T3')).toBe(false); + }); + + it('notices a gate, a finished team and a fresh handoff', () => { + const before = buildFloor({ ...base, squads: { ...chart, gates: [] }, tasks, events }); + const allDone: SquadsView = { + ...chart, + squads: [{ ...chart.squads![0], done: 2, in_flight: 0, complete: true }, chart.squads![1]], + }; + const handoff: RunEvent = { phase: 'plan', kind: 'output', agent: 'go-corrector', task_id: 'T2', message: 'T2 reassigned from go-worker to go-corrector — compile error', time: at(8) }; + const after = buildFloor({ + ...base, + squads: allDone, + tasks: [task('T1', { status: 'done', column: 'done' }), task('T2', { status: 'done', column: 'done' }), task('T3', { status: 'blocked', column: 'blocked' }), task('T9')], + events: [...events, handoff], + now: T0 + 9000, + }); + const pulses = diffFloors(before, after, T0 + 9000); + expect(pulses.map((p) => p.kind)).toEqual(expect.arrayContaining(['gate', 'team-complete', 'handoff'])); + expect(pulses.find((p) => p.kind === 'gate')).toMatchObject({ tone: 'good', text: 'Backend: gate green', detail: 'go test ./...' }); + expect(pulses.find((p) => p.kind === 'team-complete')).toMatchObject({ text: 'Backend finished every ticket', detail: '2/2' }); + expect(pulses.find((p) => p.kind === 'handoff')).toMatchObject({ ticket: 'T2', agent: 'go-corrector', text: 'T2: go-worker → go-corrector', detail: 'compile error' }); + }); + + it('forgets pulses after their time', () => { + const p = { id: 'x', kind: 'ticket-new' as const, tone: 'brand' as const, at: T0, team: '', text: 'x' }; + expect(freshPulses([p], T0 + PULSE_TTL_MS - 1)).toHaveLength(1); + expect(freshPulses([p], T0 + PULSE_TTL_MS)).toHaveLength(0); + }); +}); + +describe('several people at once, and the stage', () => { + const log: RunEvent[] = [ + { phase: 'plan', kind: 'agent_start', agent: 'planner', message: 'reading the request', time: at(1) }, + { phase: 'plan', kind: 'agent_end', agent: 'planner', message: 'planned 3 tickets', time: at(2) }, + { phase: 'split', kind: 'agent_start', agent: 'splitter', message: 'splitting', time: at(3) }, + { phase: 'split', kind: 'agent_end', agent: 'splitter', message: 'split', time: at(4) }, + { phase: 'execute', kind: 'agent_start', agent: 'go-worker', task_id: 'T2', message: 'implementing T2', time: at(5) }, + { phase: 'execute', kind: 'agent_start', agent: 'react-worker', task_id: 'T3', message: 'implementing T3', time: at(6) }, + { phase: 'execute', kind: 'token', agent: 'go-worker', task_id: 'T2', message: 'x', time: at(7) }, + ]; + const composition: DynamicComposition = { + summary: 'x', + phases: [ + { id: 'plan', agent: 'planner', enabled: true, when: 'auto' }, + { id: 'split', agent: 'splitter', enabled: true, when: 'auto' }, + { id: 'docs', agent: 'docs', enabled: true, when: 'never' }, + ], + execute: { default_role: 'go-worker' }, + }; + + it('lights every agent inside a start/end pair, each on its own table', () => { + const f = buildFloor({ squads: chart, tasks, events: log, composition, running: true, now: T0 + 8000 }); + const go = f.teams[0].agents.find((a) => a.id === 'go-worker')!; + const react = f.teams[1].agents.find((a) => a.id === 'react-worker')!; + expect(go).toMatchObject({ active: true, task: 'T2' }); + expect(react).toMatchObject({ active: true, task: 'T3' }); + expect(f.teams[0].agents.filter((a) => a.active)).toHaveLength(1); + }); + + it('puts the phase agents on the stage, in phase order, and knows the phase', () => { + const f = buildFloor({ squads: chart, tasks, events: log, composition, running: true, now: T0 + 8000 }); + expect(f.stage.map((a) => a.id)).toEqual(['planner', 'splitter']); + expect(f.stage[0]).toMatchObject({ phase: 'plan', active: false, spoke: true, lastMessage: 'reading the request' }); + expect(f.phase).toMatchObject({ id: 'execute', agent: 'react-worker', message: 'implementing T3', since: T0 + 5000 }); + }); + + it('keeps the newest voice active even without a start/end pair', () => { + const f = buildFloor({ squads: chart, tasks, events: log.slice(0, 1), composition, running: true, now: T0 + 8000 }); + expect(f.stage[0]).toMatchObject({ id: 'planner', active: true }); + expect(f.phase?.id).toBe('plan'); + }); + + it('has no stage at the crew table — the crew already seats those roles', () => { + const f = buildFloor({ squads: null, tasks: [], events: log, composition, running: true, now: T0 + 8000 }); + expect(f.mode).toBe('crew'); + expect(f.stage).toEqual([]); + expect(f.teams[0].agents.map((a) => a.id)).toEqual(expect.arrayContaining(['planner', 'splitter', 'go-worker', 'react-worker'])); + }); + + it('pulses a phase change and a stage agent starting', () => { + const before = buildFloor({ squads: chart, tasks, events: log.slice(0, 2), composition, running: true, now: T0 + 3000 }); + const after = buildFloor({ squads: chart, tasks, events: log.slice(0, 3), composition, running: true, now: T0 + 4000 }); + const pulses = diffFloors(before, after, T0 + 4000); + expect(pulses.find((p) => p.kind === 'phase')).toMatchObject({ text: 'phase: split', detail: 'splitting' }); + expect(pulses.find((p) => p.kind === 'agent-start')).toMatchObject({ agent: 'splitter', text: 'splitter is on · split' }); + }); +}); diff --git a/web/src/components/Live/floor/floorModel.ts b/web/src/components/Live/floor/floorModel.ts new file mode 100644 index 0000000..4f6384d --- /dev/null +++ b/web/src/components/Live/floor/floorModel.ts @@ -0,0 +1,850 @@ +import type { DynamicComposition, RunEvent, SquadsView, Task } from '@/types'; + +// ── The team floor, as data ────────────────────────────────────────────── +// +// The Live page used to answer "what is happening" with a log: a thousand +// lines in which the shape of the work — who is on which team, which ticket +// each agent holds, which manager answers for them, which half is waiting on +// which — is present but invisible. This model is that shape, derived from +// the four sources the page already has: +// +// the org chart (GET /api/squads) teams, managers, contract, stalls, gates +// the board (GET /api/tasks) tickets, their state and their team +// the event log (SSE) who is working right now, and on what +// the composition (compose event) the crew of a run that has no teams +// +// It is pure: same inputs, same floor. Everything the stage draws comes from +// here, so the stage stays a renderer and this stays testable. + +export type TicketState = 'queued' | 'working' | 'review' | 'blocked' | 'done' | 'failed'; + +export interface FloorTicket { + id: string; + title: string; + state: TicketState; + /** The agent holding it right now, when the log says one does. */ + agent?: string; + team: string; + /** Every agent that has touched it this run, in first-touch order. */ + touchedBy: string[]; +} + +export type SeatKind = 'manager' | 'worker' | 'reviewer' | 'tester' | 'member'; + +export interface FloorAgent { + id: string; + seat: SeatKind; + /** True when the log's most recent activity is this agent's. */ + active: boolean; + /** The ticket it is on, when active. */ + task?: string; + /** How many tickets on this team it has touched this run. */ + touched: number; + /** Those tickets, by id, most recently touched last. */ + tickets: string[]; + /** The last thing the log heard from this agent, and when. */ + lastMessage?: string; + lastAt?: number; + lastTask?: string; + /** + * Set when the team left this seat empty and the pipeline lent the agent: + * 'pipeline' or 'default'. A borrowed seat is drawn as such. + */ + borrowed?: string; +} + +export type GateState = 'green' | 'red' | 'unverified' | ''; + +export interface FloorTeam { + id: string; + name: string; + charter?: string; + manager: string; + managerDefault: boolean; + agents: FloorAgent[]; + tickets: FloorTicket[]; + total: number; + done: number; + blocked: number; + inFlight: number; + complete: boolean; + gate: GateState; + acceptance?: string; + /** Interfaces this team is waiting on, by id. */ + waitingOn: string[]; + /** True for the crew of a run with no org chart. */ + crew?: boolean; +} + +export interface FloorLink { + id: string; + from: string; + to: string; + interface: string; + stalled: boolean; +} + +export interface FloorHandoff { + task: string; + from: string; + to: string; + team: string; + at: number; + reason: string; +} + +export interface FloorNow { + agent: string; + task: string; + team: string; + message: string; + since: number; +} + +export type FloorMode = 'teams' | 'crew' | 'idle'; + +/** + * Someone on the pipeline's stage: a phase agent — planner, splitter, + * architect, explorer, coordinator, docs, memory, composer — who is not on any + * team's roster but does the run's thinking between the tables' typing. + */ +export interface FloorStageSeat { + id: string; + /** The phase this agent serves, when the composition says. */ + phase?: string; + active: boolean; + /** True once the log has heard from them this run. */ + spoke: boolean; + lastMessage?: string; + lastAt?: number; +} + +/** The phase the run is in right now, from the newest line of the log. */ +export interface FloorPhase { + id: string; + agent: string; + message: string; + since: number; +} + +export interface FloorModel { + mode: FloorMode; + teams: FloorTeam[]; + /** The pipeline's stage: phase agents not seated at any table. */ + stage: FloorStageSeat[]; + phase: FloorPhase | null; + links: FloorLink[]; + handoffs: FloorHandoff[]; + now: FloorNow | null; + integration: { acceptance?: string; ready?: boolean; reason?: string } | null; + /** The seam: tickets no team owns. */ + unassigned: FloorTicket[]; + summary: string; +} + +export interface FloorInputs { + squads: SquadsView | null | undefined; + tasks: Task[]; + events: RunEvent[]; + composition: DynamicComposition | null | undefined; + running: boolean; + /** Now, for handoff freshness. Injected so tests are deterministic. */ + now?: number; +} + +/** How long a handoff stays animated on the floor. */ +export const HANDOFF_TTL_MS = 45_000; + +/** How long a pulse stays in the feed and on the floor. */ +export const PULSE_TTL_MS = 20_000; + +export type PulseKind = + | 'ticket-new' + | 'ticket-moved' + | 'ticket-working' + | 'ticket-review' + | 'ticket-blocked' + | 'ticket-done' + | 'ticket-failed' + | 'agent-start' + | 'phase' + | 'handoff' + | 'gate' + | 'team-complete'; + +export type PulseTone = 'info' | 'good' | 'warn' | 'bad' | 'brand'; + +/** + * A pulse is one thing that just changed on the floor — a ticket appearing, + * a state flipping, someone starting on something — kept for PULSE_TTL_MS so + * the stage can flash it and the feed can list it. + */ +export interface FloorPulse { + id: string; + kind: PulseKind; + tone: PulseTone; + at: number; + team: string; + /** The ticket concerned, when one is. */ + ticket?: string; + /** The agent concerned, when one is. */ + agent?: string; + /** One line for the feed. */ + text: string; + /** A second, quieter line: the ticket title, the reason, the message. */ + detail?: string; +} + +/** One line of an agent's recent history, for the dossier. */ +export interface FloorTrailLine { + at: number; + kind: string; + phase: string; + task?: string; + message: string; + level?: string; +} + +const CREW_ID = 'crew'; + +const reReassigned = /^(\S+) reassigned from (\S+) to (\S+)(?: — (.*))?/; +const reProposes = /^(\S+) proposes (\S+)(?: — (.*))?/; + +/** ticketState folds a task's column and status into the six the floor draws. */ +export function ticketState(t: Pick): TicketState { + const status = (t.status || '').toLowerCase(); + const column = (t.column || '').toLowerCase(); + if (status === 'done' || column === 'done') return 'done'; + if (status === 'failed') return 'failed'; + if (status === 'blocked' || column === 'blocked') return 'blocked'; + if (status === 'review' || status === 'correcting' || column === 'in_review') return 'review'; + if (status === 'running' || column === 'in_progress') return 'working'; + return 'queued'; +} + +/** + * buildFloor derives the floor. + * + * Mode: `teams` when the org chart names teams — one table per team, even + * when there is only one, since a lone Python team still has a manager, a + * roster and a gate worth drawing; `crew` when a run (or a composition) + * exists without a chart — the pipeline's own roles staff a single island; + * `idle` when there is nothing at all to show. + */ +export function buildFloor(inputs: FloorInputs): FloorModel { + const { squads, tasks, events, composition, running } = inputs; + const nowMs = inputs.now ?? Date.now(); + const activity = readActivity(events); + const handoffs = readHandoffs(events, nowMs, squads); + + const chart = squads?.ok ? (squads.squads ?? []) : []; + const floor = + chart.length >= 1 + ? floorFromChart(squads!, tasks, activity, handoffs, composition, nowMs) + : floorFromCrew(tasks, activity, handoffs, composition, running, events, nowMs); + if (floor.mode === 'idle') return floor; + floor.phase = activity.phase; + // The crew table already seats the pipeline's roles; only tables from an + // org chart leave the phase agents with nowhere to stand. + floor.stage = floor.mode === 'teams' ? stageSeats(floor.teams, composition, activity) : []; + return floor; +} + +/** + * stageSeats lists the pipeline's own people — the composition's phase agents + * and anyone the log heard from who sits at no table — in phase order, with + * whoever is speaking now marked active. + */ +function stageSeats(teams: FloorTeam[], composition: DynamicComposition | null | undefined, activity: Activity): FloorStageSeat[] { + const seated = new Set(); + for (const t of teams) for (const a of t.agents) seated.add(a.id); + const out: FloorStageSeat[] = []; + const have = new Set(); + const add = (raw: string | undefined, phase?: string) => { + const id = (raw ?? '').trim().toLowerCase(); + if (!id || seated.has(id) || have.has(id)) return; + have.add(id); + const w = activity.working.get(id); + const last = activity.lastBy.get(id); + out.push({ id, phase, active: !!w || activity.lastAgent === id, spoke: !!last, lastMessage: last?.message, lastAt: last?.at }); + }; + for (const p of composition?.phases ?? []) { + if (p.enabled && p.when !== 'never') add(p.agent, p.id); + } + for (const m of composition?.team ?? []) add(m.role); + for (const id of activity.speakers) add(id, activity.lastBy.get(id) ? activity.phaseOf.get(id) : undefined); + return out; +} + +// ── The org chart's floor ──────────────────────────────────────────────── + +function floorFromChart( + view: SquadsView, + tasks: Task[], + activity: Activity, + handoffs: FloorHandoff[], + composition: DynamicComposition | null | undefined, + nowMs: number, +): FloorModel { + const chart = view.squads ?? []; + const taskTeams = view.task_teams ?? {}; + const stalls = view.stalls ?? []; + const gates = new Map((view.gates ?? []).map((g) => [g.team, g])); + const compTeams = new Map((composition?.teams ?? []).map((t) => [t.id, t])); + + const ticketsByTeam = new Map(); + const unassigned: FloorTicket[] = []; + // With one team there is no seam: every ticket is that team's. + const only = chart.length === 1 ? chart[0].id : ''; + for (const t of tasks) { + const team = t.squad || taskTeams[t.id] || only; + const ticket: FloorTicket = { + id: t.id, + title: t.title, + state: ticketState(t), + agent: activity.holder.get(t.id), + team, + touchedBy: [...(activity.touchedBy.get(t.id) ?? [])], + }; + if (team === '') { + unassigned.push(ticket); + continue; + } + const list = ticketsByTeam.get(team) ?? []; + list.push(ticket); + ticketsByTeam.set(team, list); + } + + const teams: FloorTeam[] = chart.map((s) => { + const tickets = ticketsByTeam.get(s.id) ?? []; + const comp = compTeams.get(s.id); + // The manager the run resolves: the composition knows (it applied the + // triage-capability rule); the chart's own field is the author's wish. + const manager = comp?.manager || s.manager || 'triage'; + const managerDefault = comp ? !!comp.manager_default : !s.manager; + const gate = gates.get(s.id); + return { + id: s.id, + name: s.name || s.id, + charter: s.charter, + manager, + managerDefault, + agents: withBorrowed( + seats( + [ + ['manager', manager], + ['worker', s.worker], + ['reviewer', s.reviewer], + ['tester', s.tester], + ...((s.agents ?? []).map((a) => ['member', a]) as [SeatKind, string][]), + ], + tickets, + activity, + s.id, + ), + comp?.seats, + tickets, + activity, + s.id, + ), + tickets, + total: s.total, + done: s.done, + blocked: s.blocked, + inFlight: s.in_flight, + complete: s.complete, + gate: !gate ? '' : !gate.ran ? 'unverified' : gate.ok ? 'green' : 'red', + acceptance: s.acceptance, + waitingOn: stalls.filter((st) => st.squad === s.id).map((st) => st.interface), + }; + }); + + const stalledKeys = new Set(stalls.map((st) => `${st.provider}→${st.squad}:${st.interface}`)); + const links: FloorLink[] = []; + for (const i of view.interfaces ?? []) { + for (const consumer of i.consumers ?? []) { + if (!consumer || consumer === i.provider) continue; + links.push({ + id: `${i.provider}→${consumer}:${i.id}`, + from: i.provider, + to: consumer, + interface: i.id, + stalled: stalledKeys.has(`${i.provider}→${consumer}:${i.id}`), + }); + } + } + + return { + mode: 'teams', + teams, + stage: [], + phase: null, + links, + handoffs, + now: nowFor(activity, taskTeams, nowMs), + integration: view.integration + ? { acceptance: view.integration.acceptance, ready: view.integration.ready, reason: view.integration.reason } + : null, + unassigned, + summary: view.summary || `${teams.length} teams`, + }; +} + +// ── One island: the crew ───────────────────────────────────────────────── +// +// Most runs have no org chart. They still have people — the planner, the +// splitter, a worker, a reviewer, a tester — and the same questions apply: +// who is on it, who is working, what tickets exist. The crew is those roles +// on one island: from the composition when the run has one (a single library +// team staffing the run shows as that team), else from whoever the log has +// seen speak. + +function floorFromCrew( + tasks: Task[], + activity: Activity, + handoffs: FloorHandoff[], + composition: DynamicComposition | null | undefined, + running: boolean, + events: RunEvent[], + nowMs: number, +): FloorModel { + const single = composition?.team_mode === 'single' ? composition.teams?.[0] : undefined; + const tickets: FloorTicket[] = tasks.map((t) => ({ + id: t.id, + title: t.title, + state: ticketState(t), + agent: activity.holder.get(t.id), + team: CREW_ID, + touchedBy: [...(activity.touchedBy.get(t.id) ?? [])], + })); + + const seatList: [SeatKind, string | undefined][] = []; + if (single) { + seatList.push(['manager', single.manager || 'triage']); + seatList.push(['worker', single.worker], ['reviewer', single.reviewer], ['tester', single.tester]); + for (const a of single.agents ?? []) seatList.push(['member', a]); + } else { + const exec = composition?.execute; + seatList.push(['worker', exec?.default_role], ['reviewer', exec?.reviewer], ['member', exec?.corrector]); + for (const m of composition?.team ?? []) seatList.push(['member', m.role]); + for (const p of composition?.phases ?? []) { + if (p.enabled && p.when !== 'never' && p.agent) seatList.push(['member', p.agent]); + } + } + // Whoever the log has heard from is on the crew too, whatever the plan said. + for (const id of activity.speakers) seatList.push(['member', id]); + + const agents = withBorrowed(seats(seatList, tickets, activity, CREW_ID), single?.seats, tickets, activity, CREW_ID); + const hasAnything = agents.length > 0 || tickets.length > 0 || events.length > 0; + if (!hasAnything && !running) { + return { + mode: 'idle', + teams: [], + stage: [], + phase: null, + links: [], + handoffs: [], + now: null, + integration: null, + unassigned: [], + summary: '', + }; + } + + const done = tickets.filter((t) => t.state === 'done').length; + const blocked = tickets.filter((t) => t.state === 'blocked' || t.state === 'failed').length; + const inFlight = tickets.filter((t) => t.state === 'working' || t.state === 'review').length; + const team: FloorTeam = { + id: single?.id ?? CREW_ID, + name: single?.name || single?.id || 'Pipeline crew', + charter: single?.charter, + manager: single?.manager || 'triage', + managerDefault: single ? !!single.manager_default : true, + agents, + tickets, + total: tickets.length, + done, + blocked, + inFlight, + complete: tickets.length > 0 && done === tickets.length, + gate: '', + acceptance: single?.acceptance, + waitingOn: [], + crew: true, + }; + return { + mode: 'crew', + teams: [team], + stage: [], + phase: null, + links: [], + handoffs, + now: nowFor(activity, {}, nowMs, team.id), + integration: null, + unassigned: [], + summary: composition?.team_note || composition?.summary || (single ? `team ${single.id} staffs this run` : 'one stream'), + }; +} + +// ── Seats ──────────────────────────────────────────────────────────────── + +function seats( + list: [SeatKind, string | undefined][], + tickets: FloorTicket[], + activity: Activity, + team: string, +): FloorAgent[] { + const out: FloorAgent[] = []; + const index = new Map(); + const ticketIDs = new Set(tickets.map((t) => t.id)); + for (const [seat, raw] of list) { + const id = (raw ?? '').trim().toLowerCase(); + if (id === '') continue; + const i = index.get(id); + if (i !== undefined) { + // The same agent in two seats keeps the more specific one. + if (out[i].seat === 'member' && seat !== 'member') out[i].seat = seat; + continue; + } + const { active, held } = workingOn(activity, id); + // An active agent counts for THIS team only when its ticket is here (or + // the run has one island); an agent shared by two teams must not glow on + // both. + const onThisTeam = team === CREW_ID || held === '' || ticketIDs.has(held); + out.push({ + id, + seat, + active: active && onThisTeam, + task: active && onThisTeam && held ? held : undefined, + ...facts(activity, id, ticketIDs), + }); + index.set(id, out.length - 1); + } + return out; +} + +/** + * withBorrowed adds the seats the pipeline lends a team (composer.FillSeats): + * a tester the team never named still sits at its table for this run, drawn + * as borrowed so nobody mistakes it for a member. + */ +function withBorrowed( + own: FloorAgent[], + fills: { role: string; agent: string; source: string }[] | undefined, + tickets: FloorTicket[], + activity: Activity, + team: string, +): FloorAgent[] { + if (!fills || fills.length === 0) return own; + const have = new Set(own.map((a) => a.id)); + const ticketIDs = new Set(tickets.map((t) => t.id)); + const out = [...own]; + for (const f of fills) { + const id = (f.agent ?? '').trim().toLowerCase(); + if (!id || f.source === 'team' || f.role === 'manager' || have.has(id)) continue; + have.add(id); + const seat: SeatKind = f.role === 'worker' || f.role === 'reviewer' || f.role === 'tester' ? f.role : 'member'; + const { active, held } = workingOn(activity, id); + const onThisTeam = team === CREW_ID || held === '' || ticketIDs.has(held); + out.push({ + id, + seat, + active: active && onThisTeam, + task: active && onThisTeam && held ? held : undefined, + ...facts(activity, id, ticketIDs), + borrowed: f.source, + }); + } + return out; +} + +/** + * workingOn says whether an agent is working right now and on which ticket: + * inside an agent_start … agent_end pair, or the newest voice in the log (a + * phase agent that never gets a start/end of its own still counts while it is + * the one speaking). + */ +function workingOn(activity: Activity, id: string): { active: boolean; held: string } { + const w = activity.working.get(id); + if (w) return { active: true, held: w.task }; + if (activity.lastAgent === id) return { active: true, held: activity.lastTask }; + return { active: false, held: '' }; +} + +/** facts is what the dossier says about a person: their tickets here, their last words. */ +function facts(activity: Activity, agent: string, tickets: Set): Pick { + const mine: string[] = []; + for (const [task, agents] of activity.touchedBy) { + if (tickets.has(task) && agents.has(agent)) mine.push(task); + } + const last = activity.lastBy.get(agent); + return { + touched: mine.length, + tickets: mine, + lastMessage: last?.message, + lastAt: last?.at, + lastTask: last?.task, + }; +} + +// ── Reading the log ────────────────────────────────────────────────────── + +interface Activity { + /** task → the agent that most recently started on it. */ + holder: Map; + /** task → every agent that has touched it. */ + touchedBy: Map>; + /** The most recent agent line in the log. */ + lastAgent: string; + lastTask: string; + lastMessage: string; + lastAt: number; + /** Every agent that has appeared, in first-seen order. */ + speakers: string[]; + /** agent → its most recent line. */ + lastBy: Map; + /** Agents inside an agent_start … agent_end pair right now, with their ticket. */ + working: Map; + /** The phase of the newest line, and who spoke it. */ + phase: FloorPhase | null; + /** agent → the phase it last spoke in. */ + phaseOf: Map; +} + +/** An agent_start with no agent_end for this long is a crash, not work. */ +const WORKING_TTL_MS = 10 * 60_000; + +function readActivity(events: RunEvent[]): Activity { + const holder = new Map(); + const touchedBy = new Map>(); + const seen = new Set(); + const speakers: string[] = []; + const lastBy = new Map(); + const working = new Map(); + const phaseOf = new Map(); + const cur: { phase: FloorPhase | null } = { phase: null }; + let lastAgent = ''; + let lastTask = ''; + let lastMessage = ''; + let lastAt = 0; + for (const e of events) { + const agent = (e.agent ?? '').trim().toLowerCase(); + if (e.kind === 'token' || e.kind === 'delta' || e.kind === 'token_delta') { + // Tokens are proof of life for whoever is inside a start/end pair. + const w = agent ? working.get(agent) : undefined; + if (w) w.at = Date.parse(e.time) || w.at; + continue; + } + const at = Date.parse(e.time) || lastAt; + if (e.phase && (e.kind !== 'agent_end' || !cur.phase)) { + const prev = cur.phase; + cur.phase = { id: e.phase, agent: agent === 'manager' || agent === 'loop' ? '' : agent, message: e.message ?? '', since: prev && prev.id === e.phase ? prev.since : at }; + } + if (!agent) continue; + // The charter voice and the loop are not people on a team. + if (agent === 'manager' || agent === 'loop') continue; + if (e.phase) phaseOf.set(agent, e.phase); + if (e.kind === 'agent_start') working.set(agent, { task: e.task_id ?? '', at }); + if (e.kind === 'agent_end') working.delete(agent); + if (!seen.has(agent)) { + seen.add(agent); + speakers.push(agent); + } + if (e.task_id) { + if (e.kind === 'agent_end') { + if (holder.get(e.task_id) === agent) holder.delete(e.task_id); + } else { + holder.set(e.task_id, agent); + } + const set = touchedBy.get(e.task_id) ?? new Set(); + set.add(agent); + touchedBy.set(e.task_id, set); + } + if (e.kind === 'agent_end') { + if (lastAgent === agent) { + lastAgent = ''; + lastTask = ''; + } + continue; + } + lastAgent = agent; + lastTask = e.task_id ?? ''; + lastMessage = e.message ?? ''; + lastAt = Date.parse(e.time) || lastAt; + lastBy.set(agent, { message: lastMessage, at: lastAt, task: lastTask }); + } + for (const [id, w] of working) { + if (lastAt - w.at > WORKING_TTL_MS) working.delete(id); + } + return { holder, touchedBy, lastAgent, lastTask, lastMessage, lastAt, speakers, lastBy, working, phase: cur.phase, phaseOf }; +} + +function nowFor(activity: Activity, taskTeams: Record, nowMs: number, crewTeam?: string): FloorNow | null { + if (!activity.lastAgent) return null; + return { + agent: activity.lastAgent, + task: activity.lastTask, + team: crewTeam ?? (activity.lastTask ? taskTeams[activity.lastTask] ?? '' : ''), + message: activity.lastMessage, + since: activity.lastAt || nowMs, + }; +} + +/** + * readHandoffs finds the manager decisions still worth animating: a ticket + * moved from one agent to another within HANDOFF_TTL_MS, plus a manager's + * proposal that has not been applied yet. + */ +function readHandoffs(events: RunEvent[], nowMs: number, squads: SquadsView | null | undefined): FloorHandoff[] { + const taskTeams = squads?.ok ? (squads.task_teams ?? {}) : {}; + const out: FloorHandoff[] = []; + for (let i = events.length - 1; i >= 0 && out.length < 6; i--) { + const e = events[i]; + const at = Date.parse(e.time) || nowMs; + if (nowMs - at > HANDOFF_TTL_MS) break; + const msg = e.message ?? ''; + let m = reReassigned.exec(msg); + if (m) { + out.push({ task: m[1], from: m[2].toLowerCase(), to: m[3].toLowerCase(), team: taskTeams[m[1]] ?? '', at, reason: m[4] ?? '' }); + continue; + } + m = reProposes.exec(msg); + if (m && e.task_id) { + out.push({ task: e.task_id, from: m[1].toLowerCase(), to: m[2].toLowerCase(), team: taskTeams[e.task_id] ?? '', at, reason: m[3] ?? '' }); + } + } + return out.reverse(); +} + +// ── An agent's trail ───────────────────────────────────────────────────── + +/** + * agentTrail is the last `limit` things the log heard from one agent, newest + * first — what the dossier shows when a person on the floor is clicked. + */ +export function agentTrail(events: RunEvent[], agentID: string, limit = 8): FloorTrailLine[] { + const id = agentID.trim().toLowerCase(); + const out: FloorTrailLine[] = []; + for (let i = events.length - 1; i >= 0 && out.length < limit; i--) { + const e = events[i]; + if (e.kind === 'token' || e.kind === 'delta' || e.kind === 'token_delta') continue; + if ((e.agent ?? '').trim().toLowerCase() !== id) continue; + const message = (e.message ?? '').trim(); + if (!message) continue; + out.push({ at: Date.parse(e.time) || 0, kind: e.kind, phase: e.phase, task: e.task_id || undefined, message, level: e.level }); + } + return out; +} + +// ── What just changed ──────────────────────────────────────────────────── + +const PULSE_CAP = 8; + +/** + * diffFloors lists what changed between two consecutive floors: tickets that + * appeared or changed state or team, people who just started on something, + * fresh handoffs, gates that ran, teams that finished. The first floor (prev + * null) has no changes — the page must not open on a wall of "T1 appeared". + */ +export function diffFloors(prev: FloorModel | null, next: FloorModel, now: number): FloorPulse[] { + if (!prev || prev.mode === 'idle') return []; + const out: FloorPulse[] = []; + const push = (p: Omit) => { + out.push({ ...p, id: `${p.kind}:${p.ticket ?? p.agent ?? p.team}:${now}`, at: now }); + }; + + const before = new Map(); + for (const t of prev.teams) for (const k of t.tickets) before.set(k.id, k); + for (const k of prev.unassigned) before.set(k.id, k); + const teamName = (id: string) => next.teams.find((t) => t.id === id)?.name || id; + + const all: FloorTicket[] = [...next.teams.flatMap((t) => t.tickets), ...next.unassigned]; + let ticketPulses = 0; + for (const k of all) { + const was = before.get(k.id); + if (ticketPulses >= PULSE_CAP) break; + if (!was) { + ticketPulses++; + push({ kind: 'ticket-new', tone: 'brand', team: k.team, ticket: k.id, agent: k.agent, text: `${k.id} appeared${k.team ? ` on ${teamName(k.team)}` : ''}`, detail: k.title }); + continue; + } + if (was.team !== k.team && k.team) { + ticketPulses++; + push({ kind: 'ticket-moved', tone: 'info', team: k.team, ticket: k.id, agent: k.agent, text: `${k.id} moved to ${teamName(k.team)}`, detail: k.title }); + } + if (was.state !== k.state) { + ticketPulses++; + const who = k.agent ? ` · ${k.agent}` : ''; + switch (k.state) { + case 'working': + push({ kind: 'ticket-working', tone: 'info', team: k.team, ticket: k.id, agent: k.agent, text: `${k.id} picked up${who}`, detail: k.title }); + break; + case 'review': + push({ kind: 'ticket-review', tone: 'info', team: k.team, ticket: k.id, agent: k.agent, text: `${k.id} went to review${who}`, detail: k.title }); + break; + case 'blocked': + push({ kind: 'ticket-blocked', tone: 'warn', team: k.team, ticket: k.id, agent: k.agent, text: `${k.id} is blocked${who}`, detail: k.title }); + break; + case 'done': + push({ kind: 'ticket-done', tone: 'good', team: k.team, ticket: k.id, agent: k.agent ?? was.agent, text: `${k.id} done ✓${k.agent || was.agent ? ` · ${k.agent || was.agent}` : ''}`, detail: k.title }); + break; + case 'failed': + push({ kind: 'ticket-failed', tone: 'bad', team: k.team, ticket: k.id, agent: k.agent ?? was.agent, text: `${k.id} failed${k.agent || was.agent ? ` · ${k.agent || was.agent}` : ''}`, detail: k.title }); + break; + default: + ticketPulses--; + } + } + } + + const wasActive = new Map(); + for (const t of prev.teams) for (const a of t.agents) wasActive.set(`${t.id}/${a.id}`, a.active); + for (const t of next.teams) { + for (const a of t.agents) { + if (a.active && !wasActive.get(`${t.id}/${a.id}`)) { + push({ + kind: 'agent-start', + tone: 'info', + team: t.id, + agent: a.id, + ticket: a.task, + text: `${a.id} started${a.task ? ` on ${a.task}` : ''}`, + detail: a.lastMessage, + }); + } + } + } + + const stageWas = new Map(prev.stage.map((a) => [a.id, a.active])); + for (const a of next.stage) { + if (a.active && !stageWas.get(a.id)) { + push({ kind: 'agent-start', tone: 'info', team: '', agent: a.id, text: `${a.id} is on${a.phase ? ` · ${a.phase}` : ''}`, detail: a.lastMessage }); + } + } + if (next.phase && next.phase.id !== prev.phase?.id) { + push({ kind: 'phase', tone: 'brand', team: '', agent: next.phase.agent || undefined, text: `phase: ${next.phase.id}`, detail: next.phase.message }); + } + + const seenHandoffs = new Set(prev.handoffs.map((h) => `${h.task}/${h.from}/${h.to}/${h.at}`)); + for (const h of next.handoffs) { + if (seenHandoffs.has(`${h.task}/${h.from}/${h.to}/${h.at}`)) continue; + push({ kind: 'handoff', tone: 'brand', team: h.team, ticket: h.task, agent: h.to, text: `${h.task}: ${h.from} → ${h.to}`, detail: h.reason || 'reassigned by the manager' }); + } + + const gateBefore = new Map(prev.teams.map((t) => [t.id, t.gate])); + const doneBefore = new Map(prev.teams.map((t) => [t.id, t.complete])); + for (const t of next.teams) { + if (t.gate && t.gate !== 'unverified' && gateBefore.get(t.id) !== t.gate) { + push({ kind: 'gate', tone: t.gate === 'green' ? 'good' : 'bad', team: t.id, text: `${t.name}: gate ${t.gate === 'green' ? 'green' : 'RED'}`, detail: t.acceptance }); + } + if (t.complete && !doneBefore.get(t.id)) { + push({ kind: 'team-complete', tone: 'good', team: t.id, text: `${t.name} finished every ticket`, detail: `${t.done}/${t.total}` }); + } + } + return out; +} + +/** freshPulses drops what has aged out. */ +export function freshPulses(pulses: FloorPulse[], now: number, ttl = PULSE_TTL_MS): FloorPulse[] { + return pulses.filter((p) => now - p.at < ttl); +} diff --git a/web/src/components/Live/floor/floorShared.ts b/web/src/components/Live/floor/floorShared.ts new file mode 100644 index 0000000..9e1f648 --- /dev/null +++ b/web/src/components/Live/floor/floorShared.ts @@ -0,0 +1,100 @@ +import type { FloorAgent, FloorModel, FloorTeam, FloorTicket, SeatKind, TicketState } from './floorModel'; + +// ── What both stages and the dossier share ─────────────────────────────── + +/** What the user has clicked on the floor: a person or a ticket. */ +export type FloorSelection = { kind: 'agent'; id: string; team: string } | { kind: 'ticket'; id: string; team: string } | null; + +export const SEAT_GLYPH: Record = { + manager: '👔', + worker: '🔧', + reviewer: '👁️', + tester: '🧪', + member: '🤖', +}; + +export const ROLE_GLYPH: Record = { + planner: '📋', + splitter: '✂️', + explorer: '🔍', + architect: '🏗️', + coordinator: '🎯', + docs: '📖', + memory: '💾', + context: '📝', + composer: '🎼', + triage: '👔', + corrector: '✏️', + deep: '🧠', + reviewer: '👁️', + tester: '🧪', + worker: '🔧', +}; + +export const TICKET_HEX: Record = { + queued: '#cbd5e1', + working: '#f59e0b', + review: '#fb923c', + blocked: '#ef4444', + done: '#10b981', + failed: '#dc2626', +}; + +export const TICKET_LABEL: Record = { + queued: 'queued', + working: 'in progress', + review: 'in review', + blocked: 'blocked', + done: 'done', + failed: 'failed', +}; + +export function glyphFor(agent: FloorAgent): string { + if (agent.seat !== 'member') return SEAT_GLYPH[agent.seat]; + for (const [role, glyph] of Object.entries(ROLE_GLYPH)) { + if (agent.id === role || agent.id.endsWith('-' + role)) return glyph; + } + return SEAT_GLYPH.member; +} + +/** What a seat is called when a person is described. */ +export function seatTitle(agent: FloorAgent, team: FloorTeam): string { + if (agent.seat === 'manager') return team.managerDefault ? 'manager · run default' : 'manager'; + if (agent.borrowed) return `${agent.seat} · lent by the ${agent.borrowed}`; + return agent.seat; +} + +/** "12s ago", "3m ago", "1h ago". */ +export function ago(at: number | undefined, now: number): string { + if (!at) return ''; + const s = Math.max(0, Math.round((now - at) / 1000)); + if (s < 60) return `${s}s ago`; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + return `${Math.floor(m / 60)}h ${m % 60}m ago`; +} + +/** "0:42", "12:05" — an elapsed clock. */ +export function clock(since: number | undefined, now: number): string { + if (!since) return ''; + const s = Math.max(0, Math.floor((now - since) / 1000)); + const m = Math.floor(s / 60); + return `${m}:${String(s % 60).padStart(2, '0')}`; +} + +export function findAgent(floor: FloorModel, sel: FloorSelection): { agent: FloorAgent; team: FloorTeam } | null { + if (!sel || sel.kind !== 'agent') return null; + const team = floor.teams.find((t) => t.id === sel.team) ?? floor.teams.find((t) => t.agents.some((a) => a.id === sel.id)); + const agent = team?.agents.find((a) => a.id === sel.id); + return team && agent ? { agent, team } : null; +} + +export function findTicket(floor: FloorModel, sel: FloorSelection): { ticket: FloorTicket; team: FloorTeam | null } | null { + if (!sel || sel.kind !== 'ticket') return null; + for (const team of floor.teams) { + const ticket = team.tickets.find((t) => t.id === sel.id); + if (ticket) return { ticket, team }; + } + const loose = floor.unassigned.find((t) => t.id === sel.id); + return loose ? { ticket: loose, team: null } : null; +} diff --git a/web/src/components/Teams/TeamComposer.tsx b/web/src/components/Teams/TeamComposer.tsx index 496b1a9..40e64af 100644 --- a/web/src/components/Teams/TeamComposer.tsx +++ b/web/src/components/Teams/TeamComposer.tsx @@ -1,7 +1,7 @@ import { Sparkles, Play, Pin, AlertTriangle, Users, Layers, Loader2, ShieldCheck } from 'lucide-react'; import clsx from 'clsx'; import { teamColor } from '@/components/Board/teamColor'; -import type { DynamicComposition, TeamPreselect, TeamSpec, TeamStaffing } from '@/types'; +import type { DynamicComposition, SeatFill, TeamPreselect, TeamSpec, TeamStaffing } from '@/types'; // ── Sending a request to teams ─────────────────────────────────────────── // @@ -281,9 +281,9 @@ function PreselectSummary({ ) : null}
- - - + f.role === 'worker')} /> + f.role === 'reviewer')} /> + f.role === 'tester')} />
manager
@@ -302,6 +302,9 @@ function PreselectSummary({
)}
+ {(s.gaps ?? []).map((g) => ( +

{g}

+ ))} {ev && !ev.pinned && (ev.reasons ?? []).length > 0 && (

{ev.reasons!.join('; ')}

)} @@ -349,12 +352,23 @@ function PreselectSummary({ ); } -function Seat({ label, id }: { label: string; id?: string }) { +function Seat({ label, id, fill }: { label: string; id?: string; fill?: SeatFill }) { + // The seat as the run will staff it: the team's own, or the pipeline's + // agent for that role — named, not "pipeline default", because "who tests + // this team's work" deserves an answer. return (
{label}
- {id || pipeline default} + {id ? ( + id + ) : fill?.agent ? ( + <> + {fill.agent} (from the {fill.source}) + + ) : ( + pipeline default + )}
); diff --git a/web/src/components/Teams/TeamsView.test.tsx b/web/src/components/Teams/TeamsView.test.tsx index 4a76695..eeb25ac 100644 --- a/web/src/components/Teams/TeamsView.test.tsx +++ b/web/src/components/Teams/TeamsView.test.tsx @@ -432,7 +432,16 @@ describe('TeamsView send a request', () => { { team_id: 'frontend-react', score: 6, selected: true, pinned: true, reasons: ['selected by hand'] }, ], teams: [ - { id: 'backend-go', worker: 'go-worker', manager: 'triage', manager_default: true }, + { + id: 'backend-go', worker: 'go-worker', manager: 'triage', manager_default: true, + seats: [ + { role: 'worker', agent: 'go-worker', source: 'team' }, + { role: 'reviewer', agent: 'reviewer', source: 'pipeline' }, + { role: 'tester', agent: 'go-tester', source: 'pipeline' }, + { role: 'manager', agent: 'triage', source: 'default' }, + ], + gaps: ['team backend-go names no tester — the pipeline\'s go-tester takes its tester seat'], + }, { id: 'frontend-react', worker: 'react-worker', manager: 'fe-triage', manager_default: false, skills: ['react-components'] }, ], }); @@ -458,6 +467,9 @@ describe('TeamsView send a request', () => { const backend = await screen.findByTestId('staffing-backend-go'); expect(within(backend).getByText('triage')).toBeInTheDocument(); expect(within(backend).getByText('(run default)')).toBeInTheDocument(); + // The seat the team left empty is named as the pipeline will fill it. + expect(within(backend).getAllByText(/go-tester/).length).toBeGreaterThan(0); + expect(within(backend).getByText(/names no tester/)).toBeInTheDocument(); const frontend = screen.getByTestId('staffing-frontend-react'); expect(within(frontend).getByText('fe-triage')).toBeInTheDocument(); expect(within(frontend).queryByText('(run default)')).not.toBeInTheDocument(); diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 3dad96c..914b77d 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -209,3 +209,286 @@ -webkit-text-fill-color: transparent; background-clip: text; } + +/* ── The team floor (Live view) ───────────────────────────────────────── + A tilted, softly lit stage. All motion is CSS so the global + prefers-reduced-motion rule above freezes it to a still picture. */ +.floor-stage { + color: #1f2937; +} +.dark .floor-stage { + color: #e5e7eb; +} +.floor-ground { + background: + radial-gradient(ellipse at 50% 120%, rgba(139, 92, 246, 0.18), transparent 60%), + radial-gradient(ellipse at 20% -20%, rgba(14, 165, 233, 0.10), transparent 50%), + linear-gradient(180deg, transparent, rgba(139, 92, 246, 0.04)); +} +.dark .floor-ground { + background: + radial-gradient(ellipse at 50% 120%, rgba(139, 92, 246, 0.28), transparent 60%), + radial-gradient(ellipse at 20% -20%, rgba(14, 165, 233, 0.14), transparent 50%), + linear-gradient(180deg, rgba(15, 17, 23, 0), rgba(15, 17, 23, 0.6)); +} +.floor-ground::after { + content: ''; + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(139, 92, 246, 0.07) 1px, transparent 1px), + linear-gradient(90deg, rgba(139, 92, 246, 0.07) 1px, transparent 1px); + background-size: 48px 48px; + transform: perspective(900px) rotateX(58deg) translateY(30%) scale(2.2); + transform-origin: 50% 100%; + mask-image: linear-gradient(to top, rgba(0, 0, 0, 0.6), transparent 80%); +} +.floor-tilt { + perspective: 1400px; +} +.floor-tilt > svg { + transform: rotateX(10deg); + transform-origin: 50% 60%; + overflow: visible; +} +.floor-title { font-size: 15px; font-weight: 700; letter-spacing: 0.01em; } +.floor-sub { font-size: 10.5px; font-weight: 600; opacity: 0.75; } +.floor-note { font-size: 9.5px; font-weight: 600; } +.floor-pct { font-size: 8.5px; font-weight: 700; } +.floor-name { font-size: 10px; font-weight: 600; } +.floor-name-manager { font-weight: 700; } +.floor-glyph { font-size: 15px; } +.floor-ticket-id { font-size: 9px; font-weight: 700; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.floor-bubble-text { font-size: 9.5px; font-weight: 700; } + +.floor-island { animation: floorBob 6s ease-in-out infinite; } +.floor-island:nth-of-type(2n) { animation-delay: -2s; } +.floor-island:nth-of-type(3n) { animation-delay: -4s; } +.floor-island-active .floor-face { filter: url(#floor-glow); } +@keyframes floorBob { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-4px); } +} +.floor-halo { animation: floorHalo 2.2s ease-out infinite; transform-box: fill-box; transform-origin: center; } +@keyframes floorHalo { + 0% { transform: scale(0.7); opacity: 0.45; } + 100% { transform: scale(1.6); opacity: 0; } +} +.floor-halo-ring { animation: floorRing 2.2s ease-in-out infinite; } +@keyframes floorRing { + 0%, 100% { stroke-opacity: 0.9; } + 50% { stroke-opacity: 0.3; } +} +.floor-avatar-active .floor-avatar-body { animation: floorBreath 1.6s ease-in-out infinite; transform-box: fill-box; transform-origin: center; } +@keyframes floorBreath { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.08); } +} +.floor-bubble { animation: floorBubble 0.4s ease-out; } +@keyframes floorBubble { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} +.floor-ticket-live rect { animation: floorTicket 1.4s ease-in-out infinite; } +@keyframes floorTicket { + 0%, 100% { opacity: 0.92; } + 50% { opacity: 0.6; } +} +.floor-flow-on { animation: floorFlow 1.2s linear infinite; } +.floor-flow-stalled { animation: floorStall 1s ease-in-out infinite; } +@keyframes floorFlow { + to { stroke-dashoffset: -36; } +} +@keyframes floorStall { + 0%, 100% { stroke-opacity: 1; } + 50% { stroke-opacity: 0.25; } +} +.floor-waiting { animation: floorFlow 2.4s linear infinite; } +.floor-arc { transition: stroke-dasharray 0.7s ease; } +.floor-ghost { animation: floorBob 5s ease-in-out infinite; } + +/* ── The phase journey ─────────────────────────────────────────────────── */ +.journey-active-ring { animation: journeyRing 1.8s ease-out infinite; transform-box: fill-box; transform-origin: center; } +@keyframes journeyRing { + 0% { transform: scale(0.6); opacity: 0.7; } + 100% { transform: scale(2.2); opacity: 0; } +} +.journey-fill { transition: width 0.8s cubic-bezier(0.22, 1, 0.36, 1); } + +/* ── The 3D floor's DOM labels (drei ) ───────────────────────────── */ +.floor3d-label { + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + padding: 4px 10px; + border-radius: 10px; + border: 1.5px solid; + background: rgba(255, 255, 255, 0.86); + backdrop-filter: blur(6px); + box-shadow: 0 6px 18px rgba(17, 24, 39, 0.12); + color: #111827; + white-space: nowrap; + cursor: pointer; + font-family: inherit; +} +.dark .floor3d-label { background: rgba(17, 24, 39, 0.86); color: #f3f4f6; box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45); } +.floor3d-dot { position: absolute; left: 6px; top: 8px; width: 6px; height: 6px; border-radius: 9999px; } +.floor3d-label { position: relative; padding-left: 16px; } +.floor3d-label-name { font-size: 13px; font-weight: 800; letter-spacing: -0.01em; } +.floor3d-label-sub { font-size: 9.5px; font-weight: 600; opacity: 0.7; } +.floor3d-label-wait { font-size: 9.5px; font-weight: 700; color: #d97706; } +.floor3d-more { font-size: 10px; font-weight: 700; color: #6b7280; } +.floor3d-ticket { + position: relative; + display: inline-block; + padding: 1px 6px; + border-radius: 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 9px; + font-weight: 800; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18); + white-space: nowrap; + transition: transform 0.15s ease; +} +.floor3d-ticket-hover { transform: scale(1.15); z-index: 5; } +.floor3d-ticket-tip { + position: absolute; + left: 50%; + bottom: calc(100% + 6px); + transform: translateX(-50%); + min-width: 140px; + max-width: 240px; + padding: 6px 8px; + border-radius: 8px; + background: #111827; + color: #f9fafb; + font-family: inherit; + font-size: 10px; + font-weight: 500; + white-space: normal; + text-align: left; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} +.floor3d-person { display: flex; flex-direction: column; align-items: center; gap: 4px; white-space: nowrap; transform: translate(-50%, -100%); } +.floor3d-name { + padding: 2px 8px; + border-radius: 9999px; + background: rgba(255, 255, 255, 0.85); + color: #1f2937; + font-size: 10.5px; + font-weight: 600; + box-shadow: 0 2px 8px rgba(17, 24, 39, 0.12); + backdrop-filter: blur(4px); +} +.dark .floor3d-name { background: rgba(17, 24, 39, 0.85); color: #e5e7eb; } +.floor3d-name-manager { font-weight: 800; } +.floor3d-name em { font-style: normal; font-weight: 500; opacity: 0.65; } +.floor3d-bubble { + display: inline-flex; + align-items: center; + gap: 6px; + max-width: 150px; + padding: 3px 9px; + border-radius: 12px 12px 12px 3px; + border: 1.5px solid; + background: #ffffff; + color: #111827; + font-size: 11px; + font-weight: 700; + box-shadow: 0 8px 22px rgba(17, 24, 39, 0.18); + animation: floorBubble 0.3s ease-out; +} +.dark .floor3d-bubble { background: #0f1117; color: #f3f4f6; } +.floor3d-bubble-dots { display: inline-flex; gap: 3px; } +.floor3d-bubble-dots i { width: 4px; height: 4px; border-radius: 9999px; background: currentColor; opacity: 0.35; animation: floorDots 1.2s ease-in-out infinite; } +.floor3d-bubble-dots i:nth-child(2) { animation-delay: 0.2s; } +.floor3d-bubble-dots i:nth-child(3) { animation-delay: 0.4s; } +@keyframes floorDots { 0%, 100% { opacity: 0.25; transform: translateY(0); } 50% { opacity: 1; transform: translateY(-2px); } } +.floor3d-conduit, .floor3d-spark, .floor3d-integration { + display: inline-block; + padding: 3px 9px; + border-radius: 9999px; + color: #fff; + font-size: 10px; + font-weight: 700; + white-space: nowrap; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} +.floor3d-spark { background: #7c3aed; animation: floorBubble 0.3s ease-out; } +.floor3d-spark i { font-style: normal; font-weight: 500; opacity: 0.85; } +.floor3d-integration { + border-radius: 10px; + border: 1.5px solid; + background: rgba(255, 255, 255, 0.88); + color: #111827; + text-align: center; + white-space: nowrap; +} +.dark .floor3d-integration { background: rgba(17, 24, 39, 0.88); color: #f3f4f6; } + +/* ── The floor's interactive layer: dossier, feed, selection, flashes ── */ +.floor-dossier { animation: floorBubble 0.22s ease-out; } +.floor-dot-live { animation: floorDots 1.2s ease-in-out infinite; } +.floor-selected { animation: floorSelected 1.6s ease-in-out infinite; } +.floor-selected-ring { animation: floorSpin 6s linear infinite; transform-box: fill-box; transform-origin: center; } +@keyframes floorSelected { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } } +@keyframes floorSpin { to { transform: rotate(360deg); } } +.floor-flash-ring { animation: floorFlashRing 0.9s ease-out forwards; transform-box: fill-box; transform-origin: center; } +@keyframes floorFlashRing { 0% { opacity: 1; transform: scale(0.8); } 100% { opacity: 0; transform: scale(1.8); } } +.floor-ticket-flash rect:last-of-type { animation: floorTicketFlash 0.9s ease-out; } +@keyframes floorTicketFlash { 0% { filter: brightness(2.2); } 100% { filter: brightness(1); } } +.floor-feed-card { animation: floorFeedIn 0.35s cubic-bezier(0.2, 0.9, 0.3, 1.2); transition: opacity 0.6s ease; } +.floor-feed-card-fading { opacity: 0.45; } +@keyframes floorFeedIn { 0% { opacity: 0; transform: translateX(24px) scale(0.96); } 100% { opacity: 1; transform: none; } } +.floor3d-person-selected .floor3d-name { outline: 2px solid #7c3aed; outline-offset: 1px; } +.floor3d-name { transition: transform 0.15s ease, box-shadow 0.15s ease; } +.floor3d-person-hover .floor3d-name { transform: scale(1.1); box-shadow: 0 6px 18px rgba(124, 58, 237, 0.35); } +.floor3d-dispatch { + display: inline-block; + padding: 3px 9px; + border-radius: 9999px; + background: #0284c7; + color: #fff; + font-size: 10px; + font-weight: 700; + white-space: nowrap; + box-shadow: 0 4px 12px rgba(2, 132, 199, 0.35); + animation: floorBubble 0.3s ease-out; +} +.floor3d-board-head { + display: inline-flex; + gap: 8px; + padding: 2px 8px; + border-radius: 9999px; + background: rgba(255, 255, 255, 0.85); + color: #6b7280; + font-size: 9px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + white-space: nowrap; + box-shadow: 0 3px 10px rgba(0, 0, 0, 0.12); +} +.dark .floor3d-board-head { background: rgba(17, 24, 39, 0.85); color: #9ca3af; } +.floor3d-board-cell { display: inline-flex; align-items: center; gap: 3px; } +.floor3d-board-cell i { display: inline-block; width: 6px; height: 6px; border-radius: 2px; } +.floor3d-board-cell b { color: #111827; } +.dark .floor3d-board-cell b { color: #f9fafb; } +/* SVG people and tickets are buttons: a keyboard ring, never a mouse one. */ +.floor-avatar:focus, .floor-ticket:focus { outline: none; } +.floor-avatar:focus-visible, .floor-ticket:focus-visible { outline: 2px solid #7c3aed; outline-offset: 2px; border-radius: 6px; } +@media (prefers-reduced-motion: reduce) { + .floor-dossier, .floor-dot-live, .floor-selected, .floor-selected-ring, .floor-flash-ring, .floor-ticket-flash rect, .floor-feed-card, .floor3d-dispatch { animation: none !important; } +} + +/* ── The pipeline's stage and the stronger "working" cues ── */ +.floor3d-bubble-say { max-width: 84px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 9.5px; font-weight: 500; opacity: 0.8; } +.floor3d-name-waiting { opacity: 0.6; } +.floor3d-screen { display: flex; flex-direction: column; align-items: center; gap: 2px; width: 230px; color: #ede9fe; text-align: center; white-space: normal; } +.floor3d-screen-kicker { font-size: 8.5px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; opacity: 0.6; } +.floor3d-screen-phase { font-size: 22px; font-weight: 800; letter-spacing: 0.04em; text-transform: uppercase; line-height: 1; text-shadow: 0 0 14px rgba(167, 139, 250, 0.7); } +.floor3d-screen-who { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; font-weight: 700; color: #c4b5fd; } +.floor3d-screen-say { font-size: 9.5px; line-height: 1.3; opacity: 0.8; max-height: 3.9em; overflow: hidden; } +@media (prefers-reduced-motion: reduce) { .floor3d-screen-phase { text-shadow: none; } } +.floor-stage-live { animation: floorSelected 1.4s ease-in-out infinite; } diff --git a/web/src/types/index.ts b/web/src/types/index.ts index b95cb61..28931a2 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -433,6 +433,14 @@ export interface DynamicTeamMember { * Mirrors pkg/composer.TeamChoice. Filled by the harness from the library, * never by the composer model. */ +/** One seat on a team, staffed — and where the agent came from. */ +export interface SeatFill { + role: 'worker' | 'reviewer' | 'tester' | 'manager' | string; + agent: string; + /** team = the team's own pick; pipeline = the pipeline's agent for that role; default = the harness default. */ + source: 'team' | 'pipeline' | 'default' | string; +} + export interface TeamChoice { id: string; name?: string; @@ -452,6 +460,10 @@ export interface TeamChoice { reason?: string; pinned?: boolean; score?: number; + /** How the four seats are actually staffed once the pipeline fills the empty ones. */ + seats?: SeatFill[]; + /** The borrowed working seats, in words. */ + gaps?: string[]; } /** What the chosen teams do to the run — see pkg/composer TeamMode*. */ @@ -1458,6 +1470,8 @@ export interface TeamStaffing { skills?: string[]; owns?: string[]; acceptance?: string; + seats?: SeatFill[]; + gaps?: string[]; } /** Why one team scored what it scored — see teams.Evidence in Go. */ diff --git a/web/vite.config.ts b/web/vite.config.ts index 48f269c..affb427 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -25,7 +25,10 @@ export default defineConfig({ // 'hidden' still generates maps for local debugging but emits no // sourceMappingURL comment, so browsers never request them. sourcemap: 'hidden', - chunkSizeWarningLimit: 900, + // three + fiber + drei is one ~950 kB chunk (250 kB gzipped), loaded lazily + // by the Live view's floor only where WebGL exists. It is the one chunk + // allowed past the old limit, and nothing else should approach it. + chunkSizeWarningLimit: 1000, rollupOptions: { output: { // Split the vendor bundle so a Studio code change does not invalidate @@ -41,6 +44,12 @@ export default defineConfig({ return 'react'; } if (id.includes('node_modules/@dnd-kit/')) return 'dnd'; + // The 3D team floor. One chunk, loaded lazily by the Live view only + // when WebGL is available — three alone is larger than the rest of + // Studio, and a page that never shows the floor must not pay for it. + if (/[\\/]node_modules[\\/](three|@react-three|three-stdlib|troika-three-text|troika-three-utils|troika-worker-utils|maath|zustand|suspend-react|its-fine|react-reconciler|@monogrid|camera-controls|stats-gl|stats\.js|detect-gpu|hls\.js|meshline|three-mesh-bvh|webgl-sdf-generator|bidi-js|utility-types|glsl-noise|tunnel-rat|@use-gesture)[\\/]/.test(id)) { + return 'three'; + } if (id.includes('node_modules/lucide-react/')) return 'icons'; return undefined; },