From 0f5ae3a26115e13d8b48bdd91d44128c4fe2ff85 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 06:38:54 +0000 Subject: [PATCH 1/2] Studio: the Live view is a 3D team floor, and the pipeline fills the seats a team lacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Live page's centre is no longer a log. It is a floor: one table per team with the manager at the head and the team's board on the wall behind them, members at monitors that light up when they work, tickets on the table threaded to whoever holds them, contract conduits with packets between tables (amber when a consumer waits), gate-colored rims. The floor moves with the run — a dispatch arc when a manager sends someone onto a ticket, a spark on a handoff, a drop when a ticket appears, a burst when one finishes or fails, idle people glancing at the worker — and every change slides into a feed at the edge. Clicking a person or a ticket opens its dossier (status, manager or reports, tickets, recent log lines; every name a link) and the camera glides to it. Orbit, zoom, follow, spin. The scene is three.js via react-three-fiber, lazy-loaded as its own chunk only where WebGL exists; a flat SVG map draws the same model otherwise or on request, with the same dossier and feed. Both honor reduced motion. Everything drawn comes from a pure floor model (floorModel.ts) derived from the org chart, the board, the log and the composition, so the stages stay renderers and the derivation stays tested. A lone team gets its own table with every ticket, not a "pipeline crew" island. Around the floor: the phase rail is a journey track with progress and the five side tabs became one activity column with the log by default; the squads and tasks polls settle together so the floor never flashes a crew table before its teams arrive. Seats the pipeline fills: when a pipeline needs a seat a team did not name (a test phase, no tester), composer.FillSeats records who takes it and where they came from (`seats` with `source`, `gaps` in words). The composition, the preselect preview, the run setup panel, the charter log, the Teams page and the floor all show it — the borrowed tester sits at the team's table, drawn as such. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011RfCkcsbJZgHoQLzwkqFUL --- docs/changelog.md | 24 +- docs/squads.md | 14 + docs/studio.md | 55 +- pkg/composer/composer.go | 11 + pkg/composer/seats.go | 112 ++ pkg/composer/seats_test.go | 58 + pkg/orchestrator/compose_teams.go | 43 +- pkg/orchestrator/compose_teams_test.go | 55 + pkg/orchestrator/composer.go | 4 + pkg/orchestrator/teams.go | 28 + pkg/server/team_activity_test.go | 9 + pkg/server/teams.go | 21 + web/package-lock.json | 619 ++++++- web/package.json | 10 +- web/src/components/Live/ActivityRail.tsx | 158 ++ web/src/components/Live/LiveView.tsx | 319 +--- web/src/components/Live/PhaseRail.tsx | 183 +- web/src/components/Live/RunSetup.test.tsx | 38 +- web/src/components/Live/RunSetup.tsx | 34 +- .../components/Live/floor/FloorDossier.tsx | 424 +++++ web/src/components/Live/floor/PulseFeed.tsx | 83 + .../components/Live/floor/TeamFloor.test.tsx | 208 +++ web/src/components/Live/floor/TeamFloor.tsx | 176 ++ web/src/components/Live/floor/TeamFloor3D.tsx | 1557 +++++++++++++++++ .../components/Live/floor/TeamFloorFlat.tsx | 642 +++++++ .../components/Live/floor/floorModel.test.ts | 392 +++++ web/src/components/Live/floor/floorModel.ts | 850 +++++++++ web/src/components/Live/floor/floorShared.ts | 100 ++ web/src/components/Teams/TeamComposer.tsx | 26 +- web/src/components/Teams/TeamsView.test.tsx | 14 +- web/src/styles/globals.css | 283 +++ web/src/types/index.ts | 14 + web/vite.config.ts | 11 +- 33 files changed, 6203 insertions(+), 372 deletions(-) create mode 100644 pkg/composer/seats.go create mode 100644 pkg/composer/seats_test.go create mode 100644 web/src/components/Live/ActivityRail.tsx create mode 100644 web/src/components/Live/floor/FloorDossier.tsx create mode 100644 web/src/components/Live/floor/PulseFeed.tsx create mode 100644 web/src/components/Live/floor/TeamFloor.test.tsx create mode 100644 web/src/components/Live/floor/TeamFloor.tsx create mode 100644 web/src/components/Live/floor/TeamFloor3D.tsx create mode 100644 web/src/components/Live/floor/TeamFloorFlat.tsx create mode 100644 web/src/components/Live/floor/floorModel.test.ts create mode 100644 web/src/components/Live/floor/floorModel.ts create mode 100644 web/src/components/Live/floor/floorShared.ts 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..1ee4876 --- /dev/null +++ b/web/src/components/Live/floor/TeamFloor.tsx @@ -0,0 +1,176 @@ +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; }, From 81f1f1c32aadf76747108d2c6e3b369dc25aaba0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 07:33:08 +0000 Subject: [PATCH 2/2] Studio: drop a stray blank line at the end of TeamFloor.tsx Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_011RfCkcsbJZgHoQLzwkqFUL --- web/src/components/Live/floor/TeamFloor.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/src/components/Live/floor/TeamFloor.tsx b/web/src/components/Live/floor/TeamFloor.tsx index 1ee4876..398cef1 100644 --- a/web/src/components/Live/floor/TeamFloor.tsx +++ b/web/src/components/Live/floor/TeamFloor.tsx @@ -173,4 +173,3 @@ export default function TeamFloor({ floor, running, events = NO_EVENTS, onTicket
); } -