diff --git a/README.md b/README.md index 9464e28..2aba755 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ When every development cell is infected, World Lab automatically pauses playback World Lab provides browser-owned absolute run targets of **25, 50, 100, 200, 500, and 1,000**. The session-selected target defaults to 200, past/current targets are unavailable, and a bounded run pauses at the authoritative completed-turn target unless cancellation, failure, or full infection stops it first. -The compact command navbar keeps execution controls and current known cost in one persistent row and moves detailed experiment telemetry, playback speed, and infrequent actions into accessible hover/focus popovers. Its control slots remain stable while async state changes. Explicit agent-marker selection drives the inspector, while explicit H3 selection opens a dismissible map-local details card. Blackberry/teal/mint/celadon/vanilla semantic tokens define the dark application chrome without replacing domain-owned agent and alliance colors. +The persistent operator shell keeps execution controls, run target, playback speed, current turn, known cost, and run state visible while switching between Live and Agents workspaces. Live centers the map between an independently scrolling agent rail and semantic Scoreboard, Agent, Hex, and Run inspector tabs; a bounded activity dock separates public chat, events, and safe failure/recovery records. Agent configuration uses the same mounted execution controller and existing server-authoritative mutations, so workspace switching cannot duplicate or interrupt playback. Infrequent and destructive operations remain in the accessible overflow menu. Blackberry/teal/mint/celadon/vanilla semantic tokens define the dark application chrome without replacing domain-owned agent and alliance colors. The agent roster defaults to browser-local **Follow turn** behavior: the inspector follows the active request, or the next scheduled agent while paused. Selecting an agent manually disables following without hiding the roster's textual Acting/Next marker; the preference remains in that browser and is never exported. Public world chat and the event log are newest-first bounded feeds, and the shared Model and Export dialogs keep their headers/actions fixed while their bodies scroll within the viewport. diff --git a/ROADMAP.md b/ROADMAP.md index 00d63e4..4ac3e23 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -61,6 +61,8 @@ Tenth focused slice: add versioned configurable World Lab scenarios, actual H3 g Temporary experiment hardening adds explicitly opted-in browser-lifetime unattended recovery with one to three one-call retries and one attributed skip. It does not replace the future server-owned scheduler or simultaneous-tick recovery policy. +Eleventh focused slice: redesign World Lab as a persistent long-running experiment operator workspace with a single execution controller, Live and Agents workspaces, a compact agent rail, contextual Scoreboard/Agent/Hex/Run inspection, and a bounded tabbed activity dock. This reorganizes existing authoritative configuration and telemetry without adding gameplay mechanics or persistent run storage. + ## PR 5 — Goals and memory Persistent short- and long-term objectives, compact memories, plan revision, summaries, and longer simulation runs. diff --git a/apps/world-lab/src/app/styles.css b/apps/world-lab/src/app/styles.css index 059ff57..bca30b6 100644 --- a/apps/world-lab/src/app/styles.css +++ b/apps/world-lab/src/app/styles.css @@ -29,6 +29,242 @@ color: var(--text); } +/* Long-running experiment operator workspace */ +.command-navbar { + grid-template-columns: + minmax(285px, auto) minmax(150px, auto) minmax(0, 1fr) + auto; +} +.command-brand { + gap: 10px; +} +.workspace-switcher, +.tab-list { + display: flex; + align-items: center; + gap: 3px; +} +.workspace-switcher { + margin-left: 10px; + padding-left: 12px; + border-left: 1px solid var(--line); +} +.workspace-switcher button, +.tab-list button { + min-height: 34px; + padding: 6px 10px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: var(--text-muted); + cursor: pointer; +} +.workspace-switcher button:hover, +.tab-list button:hover:not(:disabled) { + color: var(--text); + background: var(--surface-hover); +} +.workspace-switcher button[aria-current='page'], +.tab-list button[aria-selected='true'] { + border-color: color-mix(in srgb, var(--blackberry-cream) 70%, var(--line)); + background: color-mix(in srgb, var(--blackberry-cream) 34%, var(--surface)); + color: var(--vanilla-custard); +} +.labeled-command { + min-height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 7px 11px; + white-space: nowrap; +} +.compact-speed-control { + min-height: 36px; + margin: 0; + padding: 0 7px; + display: flex; + align-items: center; + gap: 6px; + border: 1px solid var(--line); + border-radius: 4px; + color: var(--text-muted); + font-size: 0.72rem; +} +.compact-speed-control select { + border: 0; + background: transparent; +} +.destructive-actions { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid color-mix(in srgb, var(--danger) 45%, transparent); +} +.operator-workspace { + grid-template-columns: clamp(260px, 18vw, 292px) minmax(420px, 1fr) clamp( + 320px, + 24vw, + 380px + ); + gap: 1px; + background: var(--line); +} +.operator-inspector { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + overflow: hidden; +} +.inspector-heading, +.activity-dock-header, +.workspace-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} +.inspector-heading { + padding: 12px 14px 8px; +} +.operator-inspector > .tab-list { + padding: 0 10px 8px; + overflow-x: auto; +} +.inspector-tabpanel { + min-height: 0; + overflow-y: auto; + padding: 0 8px 12px; +} +.operator-facts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1px; + margin: 14px 0 0; + background: var(--line); +} +.operator-facts > div { + min-width: 0; + padding: 10px; + background: var(--surface); +} +.operator-facts dt { + color: var(--text-muted); + font-size: 0.68rem; + text-transform: uppercase; + letter-spacing: 0.07em; +} +.operator-facts dd { + margin: 4px 0 0; + color: var(--vanilla-custard); + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + overflow-wrap: anywhere; +} +.activity-dock { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + min-width: 0; + min-height: 0; + background: var(--surface); + border-top: 1px solid var(--line); +} +.activity-dock-header { + min-height: 48px; + padding: 6px 12px; + border-bottom: 1px solid var(--line); +} +.activity-dock > .panel { + min-height: 0; + height: 100%; + overflow: auto; + border: 0; +} +.activity-dock > .panel > .dock-heading { + display: none; +} +.recovery-panel ol { + max-height: 100%; + margin: 0; + padding: 8px 14px; + display: grid; + gap: 5px; + list-style: none; + overflow: auto; +} +.recovery-panel li { + display: grid; + grid-template-columns: minmax(180px, auto) 1fr auto; + gap: 12px; + padding: 7px 10px; + border-left: 3px solid var(--danger); + background: var(--surface-raised); +} +.agents-workspace { + min-height: 0; + overflow: hidden; + display: grid; + grid-template-columns: clamp(260px, 20vw, 310px) minmax(0, 1fr); + background: var(--line); + gap: 1px; +} +.agents-configuration { + min-width: 0; + min-height: 0; + padding: 16px; + overflow: auto; + background: var(--background); +} +.workspace-heading { + margin-bottom: 14px; + padding-bottom: 14px; + border-bottom: 1px solid var(--line); +} +.agents-configuration .model-console { + position: static; +} +.agents-configuration .agent-inspector { + margin-top: 14px; +} +@media (max-width: 1280px) { + .command-navbar { + grid-template-columns: + minmax(250px, auto) minmax(130px, auto) minmax(0, 1fr) + auto; + } + .operator-workspace { + grid-template-columns: 248px minmax(390px, 1fr) 320px; + } + .command-controls .cancel-request-slot, + .compact-speed-control > span { + display: none; + } +} +@media (max-width: 1050px) { + .operator-workspace { + grid-template-columns: 220px minmax(0, 1fr) 300px; + } + .status-popover, + .failed-turn-controls.inactive { + display: none; + } + .command-navbar { + grid-template-columns: auto minmax(0, 1fr) auto; + } +} +@media (max-width: 820px) { + .operator-workspace { + grid-template-columns: minmax(0, 1fr); + } + .operator-workspace .agent-roster, + .operator-workspace .operator-inspector { + display: none; + } + .agents-workspace { + grid-template-columns: 220px minmax(0, 1fr); + } + .command-navbar { + overflow-x: auto; + } +} + * { box-sizing: border-box; } @@ -1870,3 +2106,52 @@ dt, min-width: 570px; } } + +/* Final operator-shell layout precedence. */ +.command-navbar { + grid-template-columns: + minmax(285px, auto) minmax(150px, auto) minmax(110px, auto) + minmax(0, 1fr) auto; +} +.operator-workspace { + grid-template-columns: clamp(260px, 18vw, 292px) minmax(420px, 1fr) clamp( + 320px, + 24vw, + 380px + ); +} +.activity-dock { + grid-template-columns: minmax(0, 1fr); +} +@media (max-width: 1280px) { + .command-navbar { + grid-template-columns: + minmax(250px, auto) minmax(130px, auto) minmax(100px, auto) + minmax(0, 1fr) auto; + } + .operator-workspace { + grid-template-columns: 248px minmax(390px, 1fr) 320px; + } + .command-brand { + display: flex; + } +} +@media (max-width: 1050px) { + .command-navbar { + grid-template-columns: auto auto minmax(0, 1fr) auto; + } + .operator-workspace { + grid-template-columns: 220px minmax(0, 1fr) 300px; + } +} +@media (max-width: 820px) { + .command-brand { + display: flex; + } + .command-controls { + min-width: max-content; + } + .operator-workspace { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/apps/world-lab/src/components/world-lab.test.tsx b/apps/world-lab/src/components/world-lab.test.tsx index fd42ae0..ed60a27 100644 --- a/apps/world-lab/src/components/world-lab.test.tsx +++ b/apps/world-lab/src/components/world-lab.test.tsx @@ -946,8 +946,18 @@ afterEach(() => { vi.unstubAllGlobals(); }); +async function openOverflow(user: ReturnType) { + const menu = await screen.findByLabelText('More World Lab actions'); + if (!menu.closest('details')?.hasAttribute('open')) await user.click(menu); +} + +async function openAgentsWorkspace(user: ReturnType) { + await user.click(await screen.findByRole('button', { name: 'Agents' })); +} + describe('WorldLab', () => { it('renders all controls, status, H3 readiness, and eight visible markers', async () => { + const user = userEvent.setup(); render(); expect( await screen.findByText( @@ -966,6 +976,7 @@ describe('WorldLab', () => { ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Start' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'Single turn' })).toBeEnabled(); + fireEvent.click(screen.getByLabelText('More World Lab actions')); expect(screen.getByRole('button', { name: 'Reset world' })).toBeEnabled(); expect( screen.getByRole('button', { name: 'Restore default personalities' }), @@ -1074,7 +1085,9 @@ describe('WorldLab', () => { : jsonResponse(snapshot), ), ); + const user = userEvent.setup(); render(); + await openAgentsWorkspace(user); const trigger = await screen.findByRole('button', { name: accessible }); expect(trigger.querySelector('.setup-label')).toHaveTextContent(expected); }, @@ -1096,6 +1109,7 @@ describe('WorldLab', () => { ); const user = userEvent.setup(); render(); + await openAgentsWorkspace(user); let setupStatus = await screen.findByRole('button', { name: /12 of 12 agents ready/, }); @@ -1113,6 +1127,7 @@ describe('WorldLab', () => { '12/12 ready', ); await user.click(screen.getByRole('button', { name: 'Close World Setup' })); + await openOverflow(user); await user.click(screen.getByRole('button', { name: 'Reset world' })); setupStatus = await screen.findByRole('button', { name: /8 of 8 agents ready/, @@ -1144,6 +1159,7 @@ describe('WorldLab', () => { ); const user = userEvent.setup(); render(); + await openAgentsWorkspace(user); let setupStatus = await screen.findByRole('button', { name: /8 of 8 agents ready/, }); @@ -1323,6 +1339,7 @@ describe('WorldLab', () => { 'fetch', vi.fn(() => jsonResponse(allied)), ); + const user = userEvent.setup(); render(); const markers = await screen.findAllByRole('button', { name: /Select agent (Ember|Rook)/, @@ -1336,12 +1353,13 @@ describe('WorldLab', () => { expect( screen.getByTestId('world-map').getAttribute('data-controller-colors'), ).toContain(allianceColor); + await user.click(screen.getByRole('tab', { name: 'Scoreboard' })); expect( screen.getByLabelText('Alliance and territory panel'), ).toHaveTextContent('Ember (1), Rook (0)'); expect( screen.getAllByText('Ember and Rook formed an alliance.'), - ).toHaveLength(2); + ).toHaveLength(1); }); it('deduplicates rendered H3 features before reporting readiness', async () => { @@ -1514,6 +1532,7 @@ describe('WorldLab', () => { expect( window.localStorage.getItem('agentborne.world-lab.follow-turn'), ).toBe('false'); + await openOverflow(user); await user.click(screen.getByRole('button', { name: 'Export' })); expect( screen.getByRole('dialog', { name: 'Experiment export' }), @@ -1575,6 +1594,7 @@ describe('WorldLab', () => { await user.click( await screen.findByRole('button', { name: 'Single turn' }), ); + await user.click(screen.getByRole('tab', { name: 'Event log' })); expect( await screen.findByText(/Waited.*direct message accepted/), ).toBeInTheDocument(); @@ -1650,6 +1670,7 @@ describe('WorldLab', () => { expect( await screen.findByLabelText('Direct-message history'), ).toHaveTextContent('Sent Rook'); + await openOverflow(user); await user.click(screen.getByRole('button', { name: 'Reset world' })); expect( await screen.findByText('No direct messages for this agent yet.'), @@ -1676,6 +1697,7 @@ describe('WorldLab', () => { await user.click( await screen.findByRole('button', { name: 'Single turn' }), ); + await user.click(screen.getByRole('tab', { name: 'Event log' })); expect( await screen.findByText('Infection · ' + world.agents[0]!.currentCell), ).toBeInTheDocument(); @@ -1716,13 +1738,16 @@ describe('WorldLab', () => { await user.click( await screen.findByRole('button', { name: 'Select agent Ember' }), ); + await user.click(screen.getByRole('tab', { name: 'Scoreboard' })); expect( await screen.findByRole('heading', { name: 'Territory scoreboard' }), ).toBeInTheDocument(); const scoreboard = screen.getByLabelText('Territory scoreboard'); expect(scoreboard).toHaveTextContent('Ember0'); expect(scoreboard).toHaveTextContent('Rook1'); + await user.click(screen.getByRole('tab', { name: 'Event log' })); expect(screen.getByText(/Rook captured .* from Ember/)).toBeInTheDocument(); + await user.click(screen.getByRole('tab', { name: 'Agent' })); expect(screen.getByLabelText('Recent territory changes')).toHaveTextContent( 'Lost', ); @@ -1765,10 +1790,49 @@ describe('WorldLab', () => { await user.click(await screen.findByRole('button', { name: 'Start' })); expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument(); await user.selectOptions(screen.getByLabelText('Playback speed'), '250'); + await user.click(screen.getByRole('button', { name: 'Agents' })); + expect( + screen.getByRole('region', { name: 'Agent management workspace' }), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Pause' })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Live' })); + expect(screen.getByTestId('world-map')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Pause' })); expect(screen.getByRole('button', { name: 'Start' })).toBeInTheDocument(); }); + it('routes agent and hex selections to semantic inspector tabs and keeps scoreboard reachable', async () => { + const user = userEvent.setup(); + render(); + await screen.findByRole('button', { name: 'Start' }); + expect(screen.getByRole('tab', { name: 'Agent' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + await user.click( + (await screen.findAllByRole('button', { name: /Select agent/ }))[0]!, + ); + expect(screen.getByRole('tab', { name: 'Agent' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + await user.click(screen.getByRole('tab', { name: 'Scoreboard' })); + expect(screen.getByLabelText('Territory scoreboard')).toBeInTheDocument(); + }); + + it('bounds recovery activity and exposes newest failures through the dock tab', async () => { + const user = userEvent.setup(); + render(); + await screen.findByRole('button', { name: 'Start' }); + await user.click(screen.getByRole('tab', { name: 'Failures & recovery' })); + expect( + screen.getByText('No failures or recovery actions recorded.'), + ).toBeInTheDocument(); + expect( + screen.queryByLabelText('Failures and recovery log'), + ).not.toBeInTheDocument(); + }); + it('resets turn history and UI selections', async () => { const changed = afterInfection(); vi.stubGlobal( @@ -1786,9 +1850,11 @@ describe('WorldLab', () => { '1', ), ); + await user.click(screen.getByRole('tab', { name: 'Event log' })); expect( await screen.findByText('Infection · ' + world.agents[0]!.currentCell), ).toBeInTheDocument(); + await openOverflow(user); await user.click(screen.getByRole('button', { name: 'Reset world' })); expect(confirm).toHaveBeenCalledWith( expect.stringContaining('unexported telemetry'), @@ -1820,6 +1886,11 @@ describe('WorldLab', () => { expect(screen.getByLabelText('Selected hex details')).toHaveTextContent( target, ); + expect(screen.getByRole('tab', { name: 'Hex' })).toHaveAttribute( + 'aria-selected', + 'true', + ); + fireEvent.click(screen.getByRole('tab', { name: 'Agent' })); expect(screen.getByRole('heading', { name: /Ember/ })).toBeInTheDocument(); act(() => mapLibreMock.mapBackgroundClick?.()); expect( @@ -1889,6 +1960,7 @@ describe('WorldLab', () => { ); const user = userEvent.setup(); render(); + await openAgentsWorkspace(user); const summary = await screen.findByText('Model: Alpha'); await user.click(summary); const modelConsole = within(summary.closest('.model-console')!); @@ -2029,6 +2101,7 @@ describe('WorldLab', () => { ); const user = userEvent.setup(); render(); + await openAgentsWorkspace(user); await user.click(await screen.findByText('Model: Alpha')); await user.click( screen.getByRole('button', { name: 'Test selected model' }), @@ -2069,6 +2142,7 @@ describe('WorldLab', () => { ); const user = userEvent.setup(); render(); + await openAgentsWorkspace(user); await user.click(await screen.findByText('Model: retired/model')); expect( screen.getByText(/Showing the last successful catalog/), @@ -2150,6 +2224,7 @@ describe('WorldLab', () => { expect( document.querySelector('.cancel-request-slot button'), ).toBeDisabled(); + fireEvent.click(screen.getByRole('button', { name: 'Agents' })); fireEvent.click(screen.getByText('Model: Alpha')); expect(screen.getByLabelText('Global model')).toBeEnabled(); await act(async () => void (await vi.advanceTimersByTimeAsync(10_000))); @@ -2197,33 +2272,26 @@ describe('WorldLab', () => { expect(screen.queryByText('Reconciling request…')).not.toBeInTheDocument(); }); - it('collapses and expands the bounded public chat dock', async () => { + it('collapses and expands the bounded activity dock', async () => { vi.stubGlobal( 'fetch', vi.fn(() => jsonResponse(afterPublicMessage())), ); const user = userEvent.setup(); render(); - const chat = await screen.findByLabelText('Public world chat'); + await screen.findByLabelText('Public world chat'); expect(document.querySelector('main')).toHaveClass('world-lab-shell'); - await user.click( - screen.getByRole('button', { name: 'Collapse Public world chat' }), - ); + await user.click(screen.getByRole('button', { name: 'Collapse activity' })); expect(document.querySelector('main')).toHaveClass('chat-collapsed'); - expect(chat).not.toHaveTextContent(HOSTILE_MESSAGE); expect( - screen.getByRole('heading', { name: 'Public world chat' }), - ).toBeVisible(); - expect(screen.getByRole('heading', { name: 'Event log' })).toBeVisible(); + screen.queryByLabelText('Public world chat'), + ).not.toBeInTheDocument(); expect(screen.queryByLabelText('World event log')).not.toBeInTheDocument(); - expect(document.querySelector('.bottom-dock .event-panel')).toHaveClass( - 'dock-collapsed', - ); - await user.click( - screen.getByRole('button', { name: 'Expand Public world chat' }), - ); + await user.click(screen.getByRole('button', { name: 'Expand activity' })); expect(document.querySelector('main')).not.toHaveClass('chat-collapsed'); - expect(chat).toHaveTextContent(HOSTILE_MESSAGE); + expect(screen.getByLabelText('Public world chat')).toHaveTextContent( + HOSTILE_MESSAGE, + ); }); it('pauses chat auto-scroll and offers a jump when new messages arrive above the bottom', async () => { @@ -2385,6 +2453,7 @@ describe('WorldLab', () => { screen.getByRole('textbox', { name: 'Personality directive' }), ).toBeDisabled(); expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled(); + await openOverflow(user); expect( screen.getByRole('button', { name: 'Restore default personalities' }), ).toBeDisabled(); @@ -2396,6 +2465,7 @@ describe('WorldLab', () => { ); await user.click(screen.getByRole('button', { name: 'Apply' })); expect(screen.getByRole('button', { name: 'Applying…' })).toBeDisabled(); + await openOverflow(user); expect(screen.getByRole('button', { name: 'Reset world' })).toBeDisabled(); resolveUpdate( new Response( @@ -2428,6 +2498,7 @@ describe('WorldLab', () => { ); const user = userEvent.setup(); render(); + await openOverflow(user); await user.click( await screen.findByRole('button', { name: 'Reset world' }), ); @@ -2468,6 +2539,7 @@ describe('WorldLab', () => { await user.click( await screen.findByRole('button', { name: 'Select agent Ember' }), ); + await openOverflow(user); const restore = await screen.findByRole('button', { name: 'Restore default personalities', }); @@ -2541,7 +2613,8 @@ describe('WorldLab', () => { it('supports agent selection and previews server-owned export', async () => { const user = userEvent.setup(); render(); - await user.click(await screen.findByRole('button', { name: 'Export' })); + await openOverflow(user); + await user.click(screen.getByRole('button', { name: 'Export' })); await user.click(screen.getByRole('button', { name: 'Clear' })); await user.click(screen.getByRole('checkbox', { name: /Ember/ })); expect(screen.getByRole('checkbox', { name: /Ember/ })).toBeChecked(); @@ -2593,7 +2666,8 @@ describe('WorldLab', () => { it('keeps export out of the details panel and dismisses its modal without losing settings', async () => { const user = userEvent.setup(); render(); - const exportButton = await screen.findByRole('button', { name: 'Export' }); + await openOverflow(user); + const exportButton = screen.getByRole('button', { name: 'Export' }); expect( screen.queryByRole('dialog', { name: 'Experiment export' }), ).toBeNull(); @@ -2604,6 +2678,7 @@ describe('WorldLab', () => { screen.queryByRole('dialog', { name: 'Experiment export' }), ).toBeNull(); expect(exportButton).toHaveFocus(); + await openOverflow(user); await user.click(exportButton); expect(screen.getByLabelText('Export level')).toHaveValue('standard'); const reopenedDialog = screen.getByRole('dialog', { @@ -2619,6 +2694,7 @@ describe('WorldLab', () => { expect( screen.queryByRole('dialog', { name: 'Experiment export' }), ).toBeNull(); + await openOverflow(user); await user.click(exportButton); fireEvent.keyDown(window, { key: 'Escape' }); expect( @@ -2629,7 +2705,8 @@ describe('WorldLab', () => { it('offers every tier, turn selector, outcome/action filters, and dependent Custom switches', async () => { const user = userEvent.setup(); render(); - await user.click(await screen.findByRole('button', { name: 'Export' })); + await openOverflow(user); + await user.click(screen.getByRole('button', { name: 'Export' })); const level = screen.getByLabelText('Export level'); expect(level).toHaveTextContent('Minimal'); expect(level).toHaveTextContent('Standard'); @@ -2711,7 +2788,8 @@ describe('WorldLab', () => { .spyOn(HTMLAnchorElement.prototype, 'click') .mockImplementation(() => undefined); render(); - await user.click(await screen.findByRole('button', { name: 'Export' })); + await openOverflow(user); + await user.click(screen.getByRole('button', { name: 'Export' })); expect(screen.getByRole('button', { name: 'Copy JSON' })).toBeDisabled(); expect( screen.getByRole('button', { name: 'Download JSON' }), @@ -2775,6 +2853,7 @@ describe('WorldLab', () => { ).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Start' })).toBeDisabled(); expect(screen.getByRole('button', { name: 'Single turn' })).toBeEnabled(); + fireEvent.click(screen.getByLabelText('More World Lab actions')); expect(screen.getByRole('button', { name: 'Reset world' })).toBeEnabled(); }); }); diff --git a/apps/world-lab/src/components/world-lab.tsx b/apps/world-lab/src/components/world-lab.tsx index 8205c20..61c5502 100644 --- a/apps/world-lab/src/components/world-lab.tsx +++ b/apps/world-lab/src/components/world-lab.tsx @@ -69,6 +69,7 @@ const apiBase = const followTurnStorageKey = 'agentborne.world-lab.follow-turn'; const runTargetStorageKey = 'agentborne.world-lab.run-target'; const unattendedRecoveryStorageKey = 'agentborne.world-lab.unattended-recovery'; +const activityDockStorageKey = 'agentborne.world-lab.activity-dock'; export const runTargets = [25, 50, 100, 200, 500, 1000] as const; function unattendedFailureEligible( @@ -89,6 +90,13 @@ function unattendedFailureEligible( } export function WorldLab() { + const [workspaceView, setWorkspaceView] = useState<'live' | 'agents'>('live'); + const [inspectorTab, setInspectorTab] = useState< + 'scoreboard' | 'agent' | 'hex' | 'run' + >('agent'); + const [activityTab, setActivityTab] = useState< + 'chat' | 'events' | 'recovery' + >('chat'); const [snapshot, setSnapshot] = useState(null); const [selectedCell, setSelectedCell] = useState(null); const [selectedAgentId, setSelectedAgentId] = useState(null); @@ -150,6 +158,22 @@ export function WorldLab() { runningRef.current = running; }, [running]); + useEffect(() => { + const collapsed = window.localStorage.getItem(activityDockStorageKey); + const hydrationTask = window.setTimeout( + () => setChatCollapsed(collapsed === 'collapsed'), + 0, + ); + return () => window.clearTimeout(hydrationTask); + }, []); + + useEffect(() => { + window.localStorage.setItem( + activityDockStorageKey, + chatCollapsed ? 'collapsed' : 'expanded', + ); + }, [chatCollapsed]); + useEffect(() => { const saved = window.localStorage.getItem(unattendedRecoveryStorageKey); const hydrationTask = window.setTimeout(() => { @@ -844,6 +868,7 @@ export function WorldLab() { const selectAgentForInspection = (agentId: AgentId) => { setFollowTurn(false); setSelectedAgentId(agentId); + setInspectorTab('agent'); }; const selectedHex = snapshot.world.hexes.find( ({ cell }) => cell === selectedCell, @@ -922,6 +947,22 @@ export function WorldLab() {

Developer simulation

World Lab

+
+

+ {snapshot.providerMode === 'openrouter' + ? `${new Set(snapshot.resolvedModels.map(({ modelId }) => modelId ?? 'unassigned')).size} active model assignment${snapshot.resolvedModels.length === 1 ? '' : 's'}` + : 'Deterministic test model'} +

- {snapshot.providerMode === 'openrouter' ? ( - - ) : ( -

Deterministic test model

- )}
World setup - + + +
@@ -1373,56 +1404,255 @@ export function WorldLab() { /> )} -
- +
+ +
+ { + setSelectedCell(cell); + setInspectorTab('hex'); + }} + onClearCellSelection={() => setSelectedCell(null)} + onSelectAgent={(agentId) => { + setSelectedCell(null); + selectAgentForInspection(agentId); + }} + /> +
+ + Development location:{' '} + {snapshot.scenario.locationLabel ?? + `${snapshot.scenario.center.latitude}, ${snapshot.scenario.center.longitude}`} + + + H3 resolution {snapshot.scenario.resolution} ·{' '} + {snapshot.scenario.exactCellCount} cells ·{' '} + {snapshot.scenario.areaSquareKilometers.toFixed(2)} km² + +
+
+ + +
+
+
+
+ {(['chat', 'events', 'recovery'] as const).map((tab) => ( + + ))} +
+ +
+ {!chatCollapsed && activityTab === 'chat' && ( + + )} + {!chatCollapsed && activityTab === 'events' && ( + + )} + {!chatCollapsed && activityTab === 'recovery' && ( + + )} +
+ + ) : ( + -
- setSelectedCell(null)} - onSelectAgent={(agentId) => { - setSelectedCell(null); - selectAgentForInspection(agentId); - }} - /> - {selectedHex && ( - setSelectedCell(null)} + onSelectAgent={selectAgentForInspection} + onOpenWorldSetup={() => setSetupOpen(true)} + > + {snapshot.providerMode === 'openrouter' ? ( + + ) : ( +

+ Deterministic test model assignments are active. +

)} -
- - Development location:{' '} - {snapshot.scenario.locationLabel ?? - `${snapshot.scenario.center.latitude}, ${snapshot.scenario.center.longitude}`} - - - H3 resolution {snapshot.scenario.resolution} ·{' '} - {snapshot.scenario.exactCellCount} cells ·{' '} - {snapshot.scenario.areaSquareKilometers.toFixed(2)} km² - -
-
- - -
-
- - -
+ + )} ); } -function Spinner() { - return