Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@

### Added

- **A command center and a floor that lives.** On the Live floor the harness's
agents wait inside a glass command center instead of crowding a table; whoever
is on walks out through its sliding door onto a pad, speaks, and walks back.
Everyone else has a mood — watching the worker, a coffee break, a stretch, a
walk round the table, a nap after a long wait — shown as a pose, a badge and a
line in their dossier. A table whose tickets are all done celebrates (beers, a
kick-about, a dance), and the whole floor joins in when the run ships. The
flat map shows the same moods; after a run the 3D floor keeps living at an
ambient 30 fps for a few minutes, then settles.
- **Dynamic or Strict teams, on the run bar.** Dynamic is the default and says
so: the dispatcher picks the teams, how they work and who manages them, and
names its pick for the request being typed. Picking teams switches to Strict —
Expand Down
5 changes: 3 additions & 2 deletions docs/squads.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,9 @@ screen:

Picking a team switches to Strict; the ✕ on the button, or *Dynamic* in its
menu, goes back. The choice governs one run: the server restores the saved pins
when it ends. The dispatcher says what it decided on the run log, and sits at
the harness table on the floor:
when it ends. The dispatcher says what it decided on the run log, and waits in
the floor's command center (the harness's own room) with the phase agents —
stepping out onto its pad while it speaks:

```text
compose dispatcher dynamic — picked from the request and the workspace: 2 teams build in parallel …
Expand Down
2 changes: 1 addition & 1 deletion docs/studio.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ the binary. `make web-check` runs the SPA's lint, typecheck, tests and build.

| Route | Page | What it does |
|---|---|---|
| `/` | **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 |
| `/` | **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 harness in a **command center** whose crew walks out onto a pad when it is their turn; idle people who watch, take coffee breaks, stroll and nap, and a done table that celebrates with beers, football and a dance), 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 |
Expand Down
13 changes: 13 additions & 0 deletions web/src/components/Live/floor/FloorDossier.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { teamColor } from '@/components/Board/teamColor';
import { isTypingTarget } from '@/hooks/useKeyboard';
import { agentTrail, workTables, DISPATCHER_ID, 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';
import { MOOD_GLYPH, MOOD_LABEL, floorShipped, moodFor, seedOf, tableParties } from './floorLife';

// ── The dossier: who is this, what are they doing ────────────────────────
//
Expand Down Expand Up @@ -173,6 +174,7 @@ function AgentDossier({
</blockquote>
)}
{held && held.title && <div className="mt-1 truncate text-[10.5px] text-gray-500 dark:text-gray-400" title={held.title}>{held.title}</div>}
{!active && <MoodLine id={agent.id} since={agent.lastAt} running={running} partying={tableParties(team, floorShipped(floor, running))} now={now} />}
</section>

{/* Management */}
Expand Down Expand Up @@ -347,6 +349,16 @@ function TicketDossier({
}

/** A phase agent on the pipeline's stage: what phase, whether speaking, what was said. */
/** What someone idle is up to meanwhile — the same mood the stage draws. */
function MoodLine({ id, since, running, partying, now }: { id: string; since?: number; running: boolean; partying: boolean; now: number }) {
const mood = moodFor({ active: false, running, partying, since: since ?? now, now, seed: seedOf(id) });
return (
<div className="mt-1 text-[10.5px] text-gray-500 dark:text-gray-400" data-testid="dossier-mood">
meanwhile: <span aria-hidden="true">{MOOD_GLYPH[mood]}</span> {MOOD_LABEL[mood]}
</div>
);
}

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;
Expand All @@ -371,6 +383,7 @@ function StageDossier({ seat, floor, events, running, now, onSelect }: { seat: F
{seat.lastMessage && (
<blockquote className="mt-1 line-clamp-3 rounded border-l-2 border-brand-400 pl-2 text-[11px] italic text-gray-600 dark:text-gray-300">{seat.lastMessage}</blockquote>
)}
{!active && <MoodLine id={seat.id} since={seat.lastAt} running={running} partying={floorShipped(floor, running)} now={now} />}
</section>
<section className="rounded-md bg-gray-50 px-2 py-1.5 text-[11px] text-gray-600 dark:bg-gray-800/60 dark:text-gray-300" data-testid="dossier-management">
{seat.id === DISPATCHER_ID
Expand Down
100 changes: 100 additions & 0 deletions web/src/components/Live/floor/RigBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { ReactNode } from 'react';
import type { ThreeEvent } from '@react-three/fiber';
import { HIP_Y, HEAD_Y, SHOULDER_Y, type Rig, type RigLook } from './floorRig';

// The figure's meshes; floorRig poses them. See floorRig for the local space.

type Pick = {
onClick: (e: ThreeEvent<MouseEvent>) => void;
onPointerOver: (e: ThreeEvent<PointerEvent>) => void;
onPointerOut: () => void;
};

/** The figure's meshes. Everything moves through the refs in `rig`. */
export default function RigBody({ rig, look, pick, children }: { rig: Rig; look: RigLook; pick: Pick; children?: ReactNode }) {
const opacity = look.ghost ? 0.72 : 1;
const transparent = !!look.ghost;
const pants = '#334155';
return (
<group ref={rig.root}>
{/* Legs, on hip pivots. */}
{([-1, 1] as const).map((side) => (
<group key={side} ref={side < 0 ? rig.legL : rig.legR} position={[side * 0.1, HIP_Y, 0]}>
<mesh position={[0, -0.2, 0]} castShadow>
<capsuleGeometry args={[0.07, 0.26, 4, 8]} />
<meshStandardMaterial color={pants} roughness={0.7} transparent={transparent} opacity={opacity} />
</mesh>
<mesh position={[0, -0.39, -0.05]} castShadow>
<boxGeometry args={[0.11, 0.06, 0.18]} />
<meshStandardMaterial color="#111827" roughness={0.6} />
</mesh>
</group>
))}
<group ref={rig.body}>
<mesh position={[0, 0.8, 0]} castShadow {...pick}>
<capsuleGeometry args={[0.2, 0.38, 6, 14]} />
<meshStandardMaterial color={look.shirt} roughness={0.55} transparent={transparent} opacity={opacity} />
</mesh>
{/* Arms, on shoulder pivots; the right hand holds the cup or the beer. */}
{([-1, 1] as const).map((side) => (
<group key={side} ref={side < 0 ? rig.armL : rig.armR} position={[side * 0.26, SHOULDER_Y, 0]}>
<mesh position={[0, -0.19, 0]} castShadow>
<capsuleGeometry args={[0.06, 0.24, 4, 8]} />
<meshStandardMaterial color={look.shirt} roughness={0.55} transparent={transparent} opacity={opacity} />
</mesh>
<mesh position={[0, -0.36, 0]}>
<sphereGeometry args={[0.06, 10, 10]} />
<meshStandardMaterial color={look.skin} roughness={0.5} />
</mesh>
{side > 0 && (
<>
<group ref={rig.beer} position={[0, -0.42, -0.02]} visible={false}>
<mesh>
<cylinderGeometry args={[0.065, 0.06, 0.15, 12]} />
<meshStandardMaterial color="#f59e0b" emissive="#b45309" emissiveIntensity={0.35} transparent opacity={0.9} roughness={0.2} />
</mesh>
<mesh position={[0, 0.09, 0]}>
<cylinderGeometry args={[0.07, 0.068, 0.04, 12]} />
<meshStandardMaterial color="#fffbeb" roughness={0.9} />
</mesh>
</group>
<mesh ref={rig.cup} position={[0, -0.42, -0.02]} visible={false}>
<cylinderGeometry args={[0.05, 0.042, 0.1, 12]} />
<meshStandardMaterial color="#f9a8d4" roughness={0.6} />
</mesh>
</>
)}
</group>
))}
<group ref={rig.head} position={[0, HEAD_Y, 0]}>
<mesh castShadow {...pick}>
<sphereGeometry args={[0.19, 20, 16]} />
<meshStandardMaterial color={look.skin} roughness={0.5} transparent={transparent} opacity={opacity} />
</mesh>
{/* Eyes, so a turned head reads as a glance and a nap as a nap. */}
<mesh position={[-0.06, 0.03, -0.165]}>
<sphereGeometry args={[0.025, 8, 8]} />
<meshBasicMaterial color="#111827" />
</mesh>
<mesh position={[0.06, 0.03, -0.165]}>
<sphereGeometry args={[0.025, 8, 8]} />
<meshBasicMaterial color="#111827" />
</mesh>
{look.hair && (
<mesh position={[0, 0.035, 0.025]} castShadow>
<sphereGeometry args={[0.205, 18, 10, 0, Math.PI * 2, 0, Math.PI / 2.3]} />
<meshStandardMaterial color={look.hair} roughness={0.85} />
</mesh>
)}
{look.crown && (
<mesh position={[0, 0.29, 0]} rotation={[Math.PI / 2, 0, 0]}>
<torusGeometry args={[0.13, 0.03, 10, 24]} />
<meshStandardMaterial color="#fbbf24" emissive="#fbbf24" emissiveIntensity={0.6} metalness={0.6} roughness={0.3} />
</mesh>
)}
</group>
</group>
{children}
</group>
);
}
42 changes: 42 additions & 0 deletions web/src/components/Live/floor/TeamFloor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,48 @@ describe('TeamFloor', () => {
expect(within(dossier).getByTestId('dossier-trail')).toHaveTextContent('three tickets, two teams');
});

it('keeps the command center’s crew inside as chips, and sends whoever is on out to the pad', () => {
const log: RunEvent[] = [{ phase: 'plan', kind: 'agent_start', agent: 'planner', message: 'reading the request', time: at(1) }];
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 + 2000,
});
render(<TeamFloor floor={floor} running events={log} now={T0 + 2000} />);
const harness = screen.getByTestId('island-harness');
expect(within(harness).getByText(/Command center/)).toBeInTheDocument();
// Out on the pad: named, with a bubble. Inside: a chip, no name tag.
expect(within(within(harness).getByTestId('agent-planner')).getByText('planner')).toBeInTheDocument();
expect(within(within(harness).getByTestId('agent-splitter')).queryByText('splitter')).toBeNull();
});

it('throws a party at a table whose every ticket is done', () => {
const done: SquadsView = {
...chart,
squads: [{ ...chart.squads![0], done: 2, in_flight: 0, complete: true }, chart.squads![1]],
};
const doneTasks = [task('T1', { status: 'done', column: 'done' }), task('T2', { status: 'done', column: 'done' }), tasks[2]];
const floor = buildFloor({ squads: done, tasks: doneTasks, events: [], composition: null, running: true, now: T0 });
render(<TeamFloor floor={floor} running now={T0} />);
expect(screen.getByTestId('party-backend-go')).toBeInTheDocument();
expect(screen.queryByTestId('party-frontend-react')).toBeNull();
const mood = within(screen.getByTestId('island-backend-go')).getByTestId('agent-go-worker').getAttribute('data-mood');
expect(['cheers', 'football', 'dance']).toContain(mood);
});

it('lets people nap once the run has stopped, and says so in the dossier', () => {
const floor = buildFloor({ squads: chart, tasks, events, composition: null, running: false, now: T0 + 5000 });
render(<TeamFloor floor={floor} running={false} events={events} now={T0 + 120_000} />);
const worker = within(screen.getByTestId('island-backend-go')).getByTestId('agent-go-worker');
expect(worker).toHaveAttribute('data-mood', 'nap');
expect(within(worker).getByTestId('mood-go-worker')).toHaveTextContent('💤');
fireEvent.click(worker);
expect(within(screen.getByTestId('floor-dossier')).getByTestId('dossier-mood')).toHaveTextContent('having a nap');
});

it('explains an empty floor', () => {
const floor = buildFloor({ squads: null, tasks: [], events: [], composition: null, running: false, now: T0 });
render(<TeamFloor floor={floor} running={false} />);
Expand Down
Loading
Loading