Skip to content

Commit aef8eba

Browse files
feat(studio): the Live view is a 3D team floor, and the pipeline fills the seats a team lacks (#37)
The Live page's centre is a floor: one table per team with the manager at the head and the team's board behind them, members at monitors that light up when they work, tickets threaded to whoever holds them, contract conduits between tables, gate-colored rims, the pipeline's phase agents on a stage under a screen naming the current phase. Dispatch arcs, sparks, drops and bursts as the run moves; a feed of every change; dossiers for people and tickets; follow, spin, orbit. three.js via react-three-fiber where WebGL exists, a flat map otherwise, both from one tested floor model. Seats the pipeline fills: when a pipeline needs a seat a team did not name, composer.FillSeats records who takes it and where they came from, shown in the composition, the preselect preview, run setup, the charter log, the Teams page and the floor. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011RfCkcsbJZgHoQLzwkqFUL
1 parent da67f50 commit aef8eba

33 files changed

Lines changed: 6202 additions & 372 deletions

‎docs/changelog.md‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,32 @@
33
## Unreleased
44

55
Teams are a thing you build and send work to, not a panel that fills in when a
6-
run happens to assemble some.
6+
run happens to assemble some — and the Live view shows them working.
77

88
### Added
99

10+
- **The Live view is a floor, not a log.** Its centre is a 3D scene (three.js,
11+
loaded only where WebGL exists; a flat map otherwise or on request): each team
12+
at a table on its rug, the manager at the head with the team's board on the
13+
wall behind them, members with monitors that light up when they work, tickets
14+
on the table threaded to whoever holds them; contract conduits with packets
15+
flowing between tables, amber when a consumer waits; green or red rims for
16+
gates; several people work at once, each unmistakably (lit monitor, rising
17+
code, a pool of light, the pop-out saying what they just said); the
18+
pipeline's own phase agents stand on a stage under a screen naming the
19+
current phase. The floor moves with the run — a dispatch arc when a manager sends
20+
someone onto a ticket, a spark when a ticket changes hands, a drop when one
21+
appears, a burst when one finishes or fails, idle people glancing at whoever
22+
is working — and every change slides into a **feed** at the edge. Click a
23+
person or a ticket for its **dossier** (status, manager or reports, tickets,
24+
recent lines; every name a link), and the camera glides to it. Orbit, zoom,
25+
follow the worker, spin. The phase track is a journey the run walks, and the
26+
five side tabs are one activity column with the log by default.
27+
- **Seats the pipeline fills.** When a pipeline needs a seat a team did not name
28+
(a test phase, no tester), the composition, the preselect preview, the run
29+
setup panel, the charter log and the floor all say who takes it and where
30+
they came from (`seats` with `source`, `gaps` in words).
31+
1032
- **Send a request to teams.** The Teams page has a *Send a request* section:
1133
type a request and see the teams it selects and why, who staffs and manages
1234
each, what the selection does to the run, and the pipeline the composer would

‎docs/squads.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,20 @@ that decision: it does not ask the manager agent to invent a second team. Only
781781
a library with nothing to say (no team matched, nothing pinned) hands the
782782
question to the model.
783783

784+
### When the pipeline needs a seat the team does not have
785+
786+
A team is written once and a pipeline is chosen per run, so the two need not
787+
agree: the pipeline has a test phase and the team names no tester. The seat is
788+
**filled from the pipeline** — the test phase's agent, or the execute loop's
789+
worker or reviewer for those seats — and the fact is said everywhere the team
790+
is shown: the composition (`seats`, with a `source` of `team`, `pipeline` or
791+
`default`, and `gaps` in words), the run setup panel, the Teams page preview,
792+
the charter log line *team backend-go names no tester — the pipeline's
793+
go-tester takes its tester seat*, and the Live floor, where the borrowed tester
794+
sits at the team's table drawn as borrowed. Routing still prefers a specialist
795+
for the language of a task's own files over any seat; the seat is the answer
796+
when the files say nothing. Name the seat on the team to make it the team's own.
797+
784798
### The composer knows the teams
785799

786800
The dynamic pipeline composer used to decide phases and loop roles with no idea

‎docs/studio.md‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ the binary. `make web-check` runs the SPA's lint, typecheck, tests and build.
6565

6666
| Route | Page | What it does |
6767
|---|---|---|
68-
| `/` | **Live** | SSE-streamed run: phases, `@agent` activity, token stream, event log, HITL modal |
68+
| `/` | **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 |
6969
| `/board` | **Board** | Kanban — add, edit, delete, move, delegate, drag mid-run |
7070
| `/review` | **Review** | Pending changes from `permission: review`, as diffs, with per-file apply/reject |
7171
| `/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
8484

8585
---
8686

87+
## The Live floor
88+
89+
The centre of the Live view is a 3D floor, drawn with three.js: one round table
90+
per team on its own rug, the manager at the head with the team's board on the
91+
wall behind them (a column per state, a tile per ticket, the counts above), the
92+
members around the table, each with a monitor that lights up when the log says
93+
that agent is working and a pop-out over their head naming the ticket. Tickets
94+
lie on the table, colored by state, and a thread runs from each one to the
95+
monitor of whoever holds it, with beads travelling along it while they type.
96+
The frozen contract runs between tables as conduits with packets flowing from
97+
the provider to the consumer; a consumer waiting on a clause it has not been
98+
given turns its conduit amber. A gate paints the table's rim green or red.
99+
100+
Whoever is working is unmistakable: their monitor lights up, lines of code
101+
rise off the screen, a pool of light and a breathing ring mark the seat, and
102+
the pop-out over their head says which ticket and what they just said. Several
103+
people can be at it at once — one per ticket in flight — and idle people glance
104+
at whoever is. The pipeline's own people — the planner, splitter, architect,
105+
explorer and the rest, who sit at no table — stand on a **stage** at the left
106+
under a screen naming the phase the run is in and what is being said; the one
107+
speaking is lit, the rest wait in the wings until their phase.
108+
109+
The floor moves when the run does. A manager sending someone onto a ticket is
110+
an arc from the head seat with the ticket's name; a ticket moving from one
111+
person to another is a spark across the table with the reason; a ticket that
112+
appears drops onto the table, one that finishes or fails bursts; idle people
113+
glance at whoever is working. Every change also slides in as a card in the
114+
**feed** at the right edge — *T4 appeared on Backend*, *go-worker started on
115+
T2*, *T2 done ✓*, *Backend: gate green* — for twenty seconds, and each card is a
116+
link.
117+
118+
Everything is clickable. A person opens their **dossier**: seat and team, who
119+
manages them (or, for a manager, who they manage and what they do), whether they
120+
are working right now and on what, the last thing they said, the tickets they
121+
hold and have touched, and their recent lines from the log. A ticket opens its
122+
own: state, holder, everyone who has touched it, any handoff, and *open in
123+
Tasks*. Every name in a dossier is a link to the next one, so the floor can be
124+
explored by clicking around; the camera glides to whatever is selected. Drag to
125+
orbit, wheel to zoom, right-drag to pan, click a table to frame it, **follow**
126+
to keep whoever is working in the middle, **spin** for a slow tour, Esc or a
127+
click on empty floor to clear.
128+
129+
A run with no org chart shows the **pipeline crew** — the planner, splitter,
130+
worker, reviewer and tester the composition chose — at one table, and a single
131+
library team staffing a run shows as that team, with its manager, board and
132+
gate. A seat the team left empty and the pipeline fills (a tester on a team that
133+
names none) sits at the table too, drawn as borrowed and labelled *from the
134+
pipeline*.
135+
136+
Where WebGL is unavailable, or on request (the **3D / map** toggle), the same
137+
floor is drawn as a flat map with the same dossier and feed. Both honor
138+
`prefers-reduced-motion`.
139+
87140
## The review workflow
88141

89142
Set `permission: review` and agent writes stop being writes — they become proposals.

‎pkg/composer/composer.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ type TeamChoice struct {
119119
Reason string `json:"reason,omitempty"`
120120
Pinned bool `json:"pinned,omitempty"`
121121
Score int `json:"score,omitempty"`
122+
// Seats is how the four seats are actually staffed on this run once the
123+
// pipeline fills what the team left empty — see FillSeats. Gaps are the
124+
// borrowed working seats, in words.
125+
Seats []SeatFill `json:"seats,omitempty"`
126+
Gaps []string `json:"gaps,omitempty"`
122127
}
123128

124129
// Team modes — how the chosen teams shape the run.
@@ -238,6 +243,12 @@ func (c *Composition) Normalize() {
238243
t.Agents = cleanList(t.Agents)
239244
t.Skills = cleanList(t.Skills)
240245
t.Reason = strings.TrimSpace(t.Reason)
246+
for i := range t.Seats {
247+
t.Seats[i].Role = strings.ToLower(strings.TrimSpace(t.Seats[i].Role))
248+
t.Seats[i].Agent = strings.ToLower(strings.TrimSpace(t.Seats[i].Agent))
249+
t.Seats[i].Source = strings.ToLower(strings.TrimSpace(t.Seats[i].Source))
250+
}
251+
t.Gaps = cleanListPreserveCase(t.Gaps)
241252
teams = append(teams, t)
242253
}
243254
c.Teams = teams

‎pkg/composer/seats.go‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
package composer
2+
3+
import "strings"
4+
5+
// ── Filling a team's seats from the pipeline ─────────────────────────────
6+
//
7+
// A team is written once and a pipeline is chosen per run, so the two do not
8+
// have to agree: the pipeline has a test phase and the team names no tester;
9+
// the team is a worker and a manager and nothing else. Routing already copes
10+
// — a seat the team leaves empty falls to the run's default for that role —
11+
// but silently, and a user who built a team without a tester and then watched
12+
// "the tester" reject its work had no way to know who that was or why.
13+
//
14+
// FillSeats is the same fallback, written down: for each of the four seats,
15+
// who will actually sit in it on this run and where they came from. It is
16+
// what the composition, the run setup panel, the Teams page and the floor all
17+
// show, so the answer is the same everywhere.
18+
19+
// Seat sources.
20+
const (
21+
// SeatFromTeam is the team's own choice.
22+
SeatFromTeam = "team"
23+
// SeatFromPipeline is the pipeline's agent for that role — the execute
24+
// loop's worker or reviewer, or the test phase's agent.
25+
SeatFromPipeline = "pipeline"
26+
// SeatFromDefault is the harness default when neither named one.
27+
SeatFromDefault = "default"
28+
)
29+
30+
// SeatFill is one seat, staffed.
31+
type SeatFill struct {
32+
Role string `json:"role"`
33+
Agent string `json:"agent"`
34+
Source string `json:"source"`
35+
}
36+
37+
// Borrowed reports whether this seat is filled from outside the team.
38+
func (s SeatFill) Borrowed() bool { return s.Source != SeatFromTeam }
39+
40+
// SeatDefaults are the pipeline's agents for the three working seats.
41+
type SeatDefaults struct {
42+
Worker string
43+
Reviewer string
44+
Tester string
45+
}
46+
47+
// FillSeats staffs the four seats of a team.
48+
//
49+
// managerDefault says the manager given is the run default rather than the
50+
// team's own (see agents.ResolveManager); it is reported as such rather than
51+
// as a pipeline pick, because a manager is not a pipeline role.
52+
func FillSeats(worker, reviewer, tester, manager string, managerDefault bool, d SeatDefaults) []SeatFill {
53+
pick := func(role, own, pipe, fallback string) SeatFill {
54+
if own = strings.TrimSpace(own); own != "" {
55+
return SeatFill{Role: role, Agent: own, Source: SeatFromTeam}
56+
}
57+
if pipe = strings.TrimSpace(pipe); pipe != "" {
58+
return SeatFill{Role: role, Agent: pipe, Source: SeatFromPipeline}
59+
}
60+
return SeatFill{Role: role, Agent: fallback, Source: SeatFromDefault}
61+
}
62+
out := []SeatFill{
63+
pick("worker", worker, d.Worker, "worker"),
64+
pick("reviewer", reviewer, d.Reviewer, "reviewer"),
65+
pick("tester", tester, d.Tester, "tester"),
66+
}
67+
m := SeatFill{Role: "manager", Agent: strings.TrimSpace(manager), Source: SeatFromTeam}
68+
if m.Agent == "" {
69+
m.Agent = "triage"
70+
}
71+
if managerDefault {
72+
m.Source = SeatFromDefault
73+
}
74+
return append(out, m)
75+
}
76+
77+
// Gaps says, in words, which seats a team did not fill itself — one line per
78+
// borrowed working seat, in the form the charter phase emits and the panels
79+
// show. The manager is left out: every team has one by construction.
80+
func Gaps(teamID string, seats []SeatFill) []string {
81+
var out []string
82+
for _, s := range seats {
83+
if s.Role == "manager" || !s.Borrowed() {
84+
continue
85+
}
86+
from := "the pipeline's"
87+
if s.Source == SeatFromDefault {
88+
from = "the default"
89+
}
90+
out = append(out, "team "+teamID+" names no "+s.Role+" — "+from+" "+s.Agent+" takes its "+s.Role+" seat")
91+
}
92+
return out
93+
}
94+
95+
// TesterAgent finds the agent bound to the test phase of a composition, or "".
96+
func (c Composition) TesterAgent() string {
97+
for _, p := range c.Phases {
98+
if p.ID == "test" && p.Enabled && p.When != "never" {
99+
return p.Agent
100+
}
101+
}
102+
return ""
103+
}
104+
105+
// SeatDefaultsOf reads the pipeline seats a composition settled on.
106+
func (c Composition) SeatDefaultsOf() SeatDefaults {
107+
return SeatDefaults{
108+
Worker: c.Execute.DefaultRole,
109+
Reviewer: c.Execute.Reviewer,
110+
Tester: c.TesterAgent(),
111+
}
112+
}

‎pkg/composer/seats_test.go‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package composer
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
// A team is written once and a pipeline is chosen per run: the pipeline has a
9+
// test phase and the team names no tester. The seat is filled from the
10+
// pipeline, and the gap is said in words.
11+
func TestFillSeatsBorrowsWhatTheTeamLeftEmpty(t *testing.T) {
12+
d := SeatDefaults{Worker: "worker", Reviewer: "reviewer", Tester: "go-tester"}
13+
seats := FillSeats("go-worker", "", "", "backend-triage", false, d)
14+
want := []SeatFill{
15+
{Role: "worker", Agent: "go-worker", Source: SeatFromTeam},
16+
{Role: "reviewer", Agent: "reviewer", Source: SeatFromPipeline},
17+
{Role: "tester", Agent: "go-tester", Source: SeatFromPipeline},
18+
{Role: "manager", Agent: "backend-triage", Source: SeatFromTeam},
19+
}
20+
if len(seats) != len(want) {
21+
t.Fatalf("seats=%+v", seats)
22+
}
23+
for i := range want {
24+
if seats[i] != want[i] {
25+
t.Fatalf("seat %d = %+v, want %+v", i, seats[i], want[i])
26+
}
27+
}
28+
gaps := Gaps("backend-go", seats)
29+
if len(gaps) != 2 || !strings.Contains(gaps[1], "names no tester") || !strings.Contains(gaps[1], "go-tester takes its tester seat") {
30+
t.Fatalf("gaps=%v", gaps)
31+
}
32+
33+
// No pipeline pick either: the harness default, reported as such.
34+
seats = FillSeats("", "", "", "", true, SeatDefaults{})
35+
if seats[2] != (SeatFill{Role: "tester", Agent: "tester", Source: SeatFromDefault}) {
36+
t.Fatalf("default tester=%+v", seats[2])
37+
}
38+
if seats[3] != (SeatFill{Role: "manager", Agent: "triage", Source: SeatFromDefault}) {
39+
t.Fatalf("default manager=%+v", seats[3])
40+
}
41+
if got := Gaps("x", seats); len(got) != 3 || !strings.Contains(got[0], "the default worker") {
42+
t.Fatalf("gaps=%v", got)
43+
}
44+
}
45+
46+
func TestSeatDefaultsComeFromTheCompositionsBoundRoles(t *testing.T) {
47+
c := Composition{
48+
Execute: ExecuteChoice{DefaultRole: "react-worker", Reviewer: "react-reviewer"},
49+
Phases: []PhaseChoice{{ID: "execute", Agent: "react-worker", Enabled: true}, {ID: "test", Agent: "react-tester", Enabled: true}},
50+
}
51+
if d := c.SeatDefaultsOf(); d != (SeatDefaults{Worker: "react-worker", Reviewer: "react-reviewer", Tester: "react-tester"}) {
52+
t.Fatalf("defaults=%+v", d)
53+
}
54+
c.Phases[1].Enabled = false
55+
if c.TesterAgent() != "" {
56+
t.Fatalf("a disabled test phase lends no tester")
57+
}
58+
}

‎pkg/orchestrator/compose_teams.go‎

Lines changed: 36 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -240,10 +240,26 @@ func composeTeams(comp *composer.Composition, td teamDecision) {
240240
return
241241
}
242242
comp.Teams, comp.TeamMode, comp.TeamNote = td.Choices, td.Mode, td.Note
243-
if td.Mode != composer.TeamModeSingle || len(td.Choices) == 0 {
243+
if td.Mode == composer.TeamModeSingle && len(td.Choices) > 0 {
244+
adoptTeamStaffing(comp, td.Choices[0])
245+
}
246+
fillTeamSeats(comp)
247+
}
248+
249+
// fillTeamSeats writes, on every team, who actually sits in each seat once
250+
// the pipeline fills what the team left empty — and the gaps in words. Done
251+
// after adoption and the critical-phase repair so the pipeline roles it reads
252+
// are the ones the run will bind.
253+
func fillTeamSeats(comp *composer.Composition) {
254+
if comp == nil {
244255
return
245256
}
246-
adoptTeamStaffing(comp, td.Choices[0])
257+
d := comp.SeatDefaultsOf()
258+
for i := range comp.Teams {
259+
t := &comp.Teams[i]
260+
t.Seats = composer.FillSeats(t.Worker, t.Reviewer, t.Tester, t.Manager, t.ManagerDefault, d)
261+
t.Gaps = composer.Gaps(t.ID, t.Seats)
262+
}
247263
}
248264

249265
func adoptTeamStaffing(comp *composer.Composition, t composer.TeamChoice) {
@@ -348,12 +364,15 @@ func teamHandoff(comp *composer.Composition) {
348364
case composer.TeamModeSingle:
349365
t := comp.Teams[0]
350366
var staff []string
351-
for _, s := range []struct{ role, id string }{
352-
{"worker", t.Worker}, {"reviewer", t.Reviewer}, {"tester", t.Tester}, {"manager", t.Manager},
353-
} {
354-
if s.id != "" {
355-
staff = append(staff, s.role+"="+s.id)
367+
for _, s := range t.Seats {
368+
if s.Agent == "" {
369+
continue
370+
}
371+
entry := s.Role + "=" + s.Agent
372+
if s.Borrowed() && s.Role != "manager" {
373+
entry += " (" + s.Source + ")"
356374
}
375+
staff = append(staff, entry)
357376
}
358377
line := "Team " + t.ID + " staffs this run"
359378
if len(staff) > 0 {
@@ -396,6 +415,13 @@ func teamSeats(comp *composer.Composition, skills map[string]bool) {
396415
want := filterKnownSkills(t.Skills, skills)
397416
ids := []string{t.Worker, t.Reviewer, t.Tester, t.Manager}
398417
ids = append(ids, t.Agents...)
418+
// A borrowed seat is on the team for this run: whoever the pipeline
419+
// lends as tester loads the team's skills like the team's own would.
420+
for _, seat := range t.Seats {
421+
if seat.Borrowed() && seat.Role != "manager" {
422+
ids = append(ids, seat.Agent)
423+
}
424+
}
399425
for _, id := range ids {
400426
id = strings.ToLower(strings.TrimSpace(id))
401427
if id == "" {
@@ -495,6 +521,9 @@ func teamsMarkdown(c composer.Composition) string {
495521
b.WriteString(" — " + t.Reason)
496522
}
497523
b.WriteString("\n")
524+
for _, g := range t.Gaps {
525+
b.WriteString(" - " + g + "\n")
526+
}
498527
}
499528
b.WriteString("\n")
500529
return b.String()

0 commit comments

Comments
 (0)