Files
web-page/docs/superpowers/plans/2026-05-17-agent-office-grid-redesign.md
gahusb d2838dfb7a docs(agent-office): implementation plan for 3x3 grid redesign
11 tasks covering AGENT_META centralization, AgentCard/PlaceholderCard/
AgentGrid/EmptyDetailPanel new components, SidePanel image header,
TopBar simplification, canvas removal, build + manual verification.

TDD for pure logic (constants, AgentCard); visual verification for layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:36:52 +09:00

1272 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Agent Office 3x3 그리드 재설계 — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** `/agent-office` 페이지의 Canvas 픽셀 사무실을 3x3 에이전트 카드 그리드로 교체한다. 실제 작동하는 5명(stock/music/insta/realestate/lotto) + 준비 중 4슬롯, 카드 클릭 시 기존 SidePanel 4탭을 우측에 노출한다.
**Architecture:** `useAgentManager` WebSocket 훅을 그대로 유지하고, 새로 만든 `AgentGrid``AgentCard` / `PlaceholderCard` 컴포넌트가 상태를 시각화한다. AGENT_META와 슬롯 레이아웃을 단일 `constants.js`로 중앙화하여 `AgentCard``SidePanel`이 공유한다. Canvas 관련 모듈(9개 파일 + hook + json)은 전부 제거.
**Tech Stack:** React 18, Vite, vitest + @testing-library/react, 기존 WebSocket API (`/api/agent-office/ws`).
**Spec:** `docs/superpowers/specs/2026-05-17-agent-office-grid-redesign-design.md`
---
## File Structure
### Create (신규 7개)
| 경로 | 책임 |
|------|------|
| `src/pages/agent-office/constants.js` | AGENT_META + 9슬롯 레이아웃 단일 출처 |
| `src/pages/agent-office/constants.test.js` | 슬롯 매핑·active 필터링 검증 |
| `src/pages/agent-office/components/AgentCard.jsx` | 실제 에이전트 카드 (이미지+상태dot+뱃지+이름) |
| `src/pages/agent-office/components/AgentCard.test.jsx` | state→dot 색상/펄스, 뱃지 표시·리셋 |
| `src/pages/agent-office/components/PlaceholderCard.jsx` | "준비 중" 카드 |
| `src/pages/agent-office/components/AgentGrid.jsx` | 3x3 그리드 래퍼, 9슬롯 렌더 |
| `src/pages/agent-office/components/EmptyDetailPanel.jsx` | 초기 안내 / placeholder 클릭 시 패널 |
### Modify (4개)
| 경로 | 변경 |
|------|------|
| `src/pages/agent-office/AgentOffice.jsx` | Canvas 제거 → AgentGrid + 분기 패널 |
| `src/pages/agent-office/AgentOffice.css` | Canvas/zoom 스타일 제거, 그리드/카드 스타일 추가 |
| `src/pages/agent-office/components/SidePanel.jsx` | AGENT_META를 constants에서 import, blog 제거 → insta, 헤더 emoji → 이미지 |
| `src/pages/agent-office/components/TopBar.jsx` | theme/zoom 제거, connected 상태만 |
### Delete (canvas 잔재)
```
src/pages/agent-office/canvas/themes.js
src/pages/agent-office/canvas/FurnitureRenderer.js
src/pages/agent-office/canvas/ProceduralSprite.js
src/pages/agent-office/canvas/AgentSprite.js
src/pages/agent-office/canvas/SpriteLoader.js
src/pages/agent-office/canvas/OverlayRenderer.js
src/pages/agent-office/canvas/Pathfinder.js
src/pages/agent-office/canvas/OfficeRenderer.js
src/pages/agent-office/canvas/TileMap.js
src/pages/agent-office/hooks/useOfficeCanvas.js
src/pages/agent-office/assets/office-map.json
```
### Image Assets (사용자가 이미 배치)
```
src/pages/agent-office/assets/agent_stock.png
src/pages/agent-office/assets/agent_music.png
src/pages/agent-office/assets/agent_insta.png
src/pages/agent-office/assets/agent_realestate.png
src/pages/agent-office/assets/agent_lotto.png
src/pages/agent-office/assets/agent_undetermined.png
```
> 참고: spec에서는 `assets/agents/` 하위였으나 사용자가 `assets/`에 직접 배치 → import 경로는 `../assets/agent_xxx.png` 사용.
---
## Tasks
### Task 1: constants.js — 단일 출처 + 테스트
**Files:**
- Create: `src/pages/agent-office/constants.js`
- Create: `src/pages/agent-office/constants.test.js`
- [ ] **Step 1: 실패하는 테스트 작성**
```js
// src/pages/agent-office/constants.test.js
import { describe, it, expect } from 'vitest';
import { AGENT_META, GRID_SLOTS, ACTIVE_AGENT_IDS } from './constants.js';
describe('agent-office constants', () => {
it('5명의 active 에이전트가 정의됨', () => {
expect(ACTIVE_AGENT_IDS).toEqual(['stock', 'music', 'insta', 'realestate', 'lotto']);
});
it('각 active 에이전트에 displayName/color/image 메타가 있음', () => {
for (const id of ACTIVE_AGENT_IDS) {
expect(AGENT_META[id]).toBeDefined();
expect(AGENT_META[id].displayName).toBeTruthy();
expect(AGENT_META[id].color).toMatch(/^#/);
expect(AGENT_META[id].image).toBeTruthy();
}
});
it('blog 메타는 존재하지 않음 (insta로 대체됨)', () => {
expect(AGENT_META.blog).toBeUndefined();
});
it('GRID_SLOTS는 9칸, 처음 5칸은 active 에이전트', () => {
expect(GRID_SLOTS).toHaveLength(9);
expect(GRID_SLOTS.slice(0, 5).map(s => s.agentId)).toEqual(
['stock', 'music', 'insta', 'realestate', 'lotto']
);
});
it('GRID_SLOTS의 마지막 4칸은 placeholder (agentId=null)', () => {
for (const slot of GRID_SLOTS.slice(5)) {
expect(slot.agentId).toBeNull();
}
});
});
```
- [ ] **Step 2: 테스트 실행해 실패 확인**
Run: `cd C:\Users\jaeoh\Desktop\workspace\web-ui && npx vitest run src/pages/agent-office/constants.test.js`
Expected: FAIL — `constants.js`가 존재하지 않음
- [ ] **Step 3: constants.js 작성**
```js
// src/pages/agent-office/constants.js
import stockImg from './assets/agent_stock.png';
import musicImg from './assets/agent_music.png';
import instaImg from './assets/agent_insta.png';
import realestateImg from './assets/agent_realestate.png';
import lottoImg from './assets/agent_lotto.png';
import undeterminedImg from './assets/agent_undetermined.png';
export const AGENT_META = {
stock: { displayName: '주식 트레이더', color: '#4488cc', image: stockImg },
music: { displayName: '음악 프로듀서', color: '#44aa88', image: musicImg },
insta: { displayName: '인스타 큐레이터', color: '#d97706', image: instaImg },
realestate: { displayName: '청약 애널리스트', color: '#c026d3', image: realestateImg },
lotto: { displayName: '로또 큐레이터', color: '#ef4444', image: lottoImg },
};
export const ACTIVE_AGENT_IDS = ['stock', 'music', 'insta', 'realestate', 'lotto'];
// 3x3 슬롯 (좌→우, 위→아래). 처음 5칸은 active, 나머지 4칸은 placeholder.
export const GRID_SLOTS = [
{ agentId: 'stock' },
{ agentId: 'music' },
{ agentId: 'insta' },
{ agentId: 'realestate' },
{ agentId: 'lotto' },
{ agentId: null },
{ agentId: null },
{ agentId: null },
{ agentId: null },
];
export const PLACEHOLDER_IMAGE = undeterminedImg;
export const PLACEHOLDER_LABEL = '준비 중';
// 상태 → dot 색상 매핑 (AgentCard에서 공유)
export const STATE_COLORS = {
idle: { color: '#6b7280', pulse: false },
working: { color: '#22c55e', pulse: true },
error: { color: '#ef4444', pulse: false },
waiting_approval: { color: '#f59e0b', pulse: true },
break: { color: '#94a3b8', pulse: false },
};
export const DEFAULT_STATE_COLOR = STATE_COLORS.idle;
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `npx vitest run src/pages/agent-office/constants.test.js`
Expected: PASS — 5개 테스트 모두 통과
- [ ] **Step 5: 커밋**
```bash
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/agent-office/constants.js src/pages/agent-office/constants.test.js
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(agent-office): centralize AGENT_META + grid slot layout
- 5 active agents (stock/music/insta/realestate/lotto) + 4 placeholders
- AGENT_META, GRID_SLOTS, STATE_COLORS in single constants module
- blog removed (replaced by insta)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 2: AgentCard 컴포넌트 + 테스트
**Files:**
- Create: `src/pages/agent-office/components/AgentCard.jsx`
- Create: `src/pages/agent-office/components/AgentCard.test.jsx`
- [ ] **Step 1: 실패하는 테스트 작성**
```jsx
// src/pages/agent-office/components/AgentCard.test.jsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import AgentCard from './AgentCard.jsx';
describe('AgentCard', () => {
it('에이전트의 displayName을 표시', () => {
render(<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} onClick={() => {}} />);
expect(screen.getByText('주식 트레이더')).toBeInTheDocument();
});
it('working 상태일 때 dot에 working 클래스 부여', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'working' }} notificationCount={0} onClick={() => {}} />
);
const dot = container.querySelector('.ao-card-dot');
expect(dot).toHaveClass('working');
});
it('agentState 없으면 idle로 fallback', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={undefined} notificationCount={0} onClick={() => {}} />
);
const dot = container.querySelector('.ao-card-dot');
expect(dot).toHaveClass('idle');
});
it('notificationCount > 0이면 뱃지 표시', () => {
render(<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={3} onClick={() => {}} />);
expect(screen.getByText('3')).toBeInTheDocument();
});
it('notificationCount === 0이면 뱃지 없음', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} onClick={() => {}} />
);
expect(container.querySelector('.ao-card-badge')).toBeNull();
});
it('notificationCount > 9이면 9+ 표시', () => {
render(<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={15} onClick={() => {}} />);
expect(screen.getByText('9+')).toBeInTheDocument();
});
it('클릭 시 onClick 호출', () => {
const onClick = vi.fn();
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} onClick={onClick} />
);
fireEvent.click(container.querySelector('.ao-card'));
expect(onClick).toHaveBeenCalledTimes(1);
});
it('active prop 시 카드에 active 클래스 부여', () => {
const { container } = render(
<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} active onClick={() => {}} />
);
expect(container.querySelector('.ao-card')).toHaveClass('active');
});
});
```
- [ ] **Step 2: 테스트 실패 확인**
Run: `npx vitest run src/pages/agent-office/components/AgentCard.test.jsx`
Expected: FAIL — `AgentCard.jsx`가 존재하지 않음
- [ ] **Step 3: AgentCard.jsx 구현**
```jsx
// src/pages/agent-office/components/AgentCard.jsx
import { AGENT_META, STATE_COLORS, DEFAULT_STATE_COLOR } from '../constants.js';
export default function AgentCard({ agentId, agentState, notificationCount = 0, active = false, onClick }) {
const meta = AGENT_META[agentId];
if (!meta) return null;
const state = agentState?.state || 'idle';
const stateInfo = STATE_COLORS[state] || DEFAULT_STATE_COLOR;
const dotClass = `ao-card-dot ${state}${stateInfo.pulse ? ' pulse' : ''}`;
const badgeText = notificationCount > 9 ? '9+' : String(notificationCount);
return (
<button
type="button"
className={`ao-card ${active ? 'active' : ''}`}
onClick={onClick}
style={{ '--card-accent': meta.color }}
>
<span className={dotClass} title={state} />
{notificationCount > 0 && (
<span className="ao-card-badge">{badgeText}</span>
)}
<div className="ao-card-image">
<img src={meta.image} alt={meta.displayName} />
</div>
<div className="ao-card-name">{meta.displayName}</div>
</button>
);
}
```
- [ ] **Step 4: 테스트 통과 확인**
Run: `npx vitest run src/pages/agent-office/components/AgentCard.test.jsx`
Expected: PASS — 8개 테스트 모두 통과
- [ ] **Step 5: 커밋**
```bash
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/agent-office/components/AgentCard.jsx src/pages/agent-office/components/AgentCard.test.jsx
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(agent-office): AgentCard component with state dot + badge
- state→color mapping via STATE_COLORS
- notification badge with 9+ overflow
- active prop for selected card border
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 3: PlaceholderCard 컴포넌트
**Files:**
- Create: `src/pages/agent-office/components/PlaceholderCard.jsx`
- [ ] **Step 1: PlaceholderCard.jsx 작성**
```jsx
// src/pages/agent-office/components/PlaceholderCard.jsx
import { PLACEHOLDER_IMAGE, PLACEHOLDER_LABEL } from '../constants.js';
export default function PlaceholderCard({ active = false, onClick }) {
return (
<button
type="button"
className={`ao-card placeholder ${active ? 'active' : ''}`}
onClick={onClick}
>
<div className="ao-card-image">
<img src={PLACEHOLDER_IMAGE} alt={PLACEHOLDER_LABEL} />
</div>
<div className="ao-card-name">{PLACEHOLDER_LABEL}</div>
</button>
);
}
```
- [ ] **Step 2: 커밋**
```bash
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/agent-office/components/PlaceholderCard.jsx
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(agent-office): PlaceholderCard for unstaffed slots
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 4: AgentGrid 컴포넌트
**Files:**
- Create: `src/pages/agent-office/components/AgentGrid.jsx`
- [ ] **Step 1: AgentGrid.jsx 작성**
```jsx
// src/pages/agent-office/components/AgentGrid.jsx
import { GRID_SLOTS } from '../constants.js';
import AgentCard from './AgentCard.jsx';
import PlaceholderCard from './PlaceholderCard.jsx';
export default function AgentGrid({ agents, notifications, selectedAgent, onSelectAgent, onSelectPlaceholder }) {
return (
<div className="ao-grid">
{GRID_SLOTS.map((slot, idx) => {
if (slot.agentId === null) {
const placeholderKey = `placeholder-${idx}`;
return (
<PlaceholderCard
key={placeholderKey}
active={selectedAgent === placeholderKey}
onClick={() => onSelectPlaceholder(placeholderKey)}
/>
);
}
return (
<AgentCard
key={slot.agentId}
agentId={slot.agentId}
agentState={agents[slot.agentId]}
notificationCount={notifications[slot.agentId] || 0}
active={selectedAgent === slot.agentId}
onClick={() => onSelectAgent(slot.agentId)}
/>
);
})}
</div>
);
}
```
- [ ] **Step 2: 커밋**
```bash
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/agent-office/components/AgentGrid.jsx
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(agent-office): AgentGrid renders 9 slots from GRID_SLOTS
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 5: EmptyDetailPanel 컴포넌트
**Files:**
- Create: `src/pages/agent-office/components/EmptyDetailPanel.jsx`
- [ ] **Step 1: EmptyDetailPanel.jsx 작성**
```jsx
// src/pages/agent-office/components/EmptyDetailPanel.jsx
import { PLACEHOLDER_IMAGE } from '../constants.js';
export default function EmptyDetailPanel({ variant = 'initial', onClose }) {
if (variant === 'placeholder') {
return (
<div className="ao-sidepanel">
<div className="ao-sidepanel-header">
<div className="ao-sidepanel-agent">
<div className="ao-sidepanel-icon">
<img src={PLACEHOLDER_IMAGE} alt="준비 중" />
</div>
<div className="ao-sidepanel-info">
<div className="ao-sidepanel-name">준비 </div>
<div className="ao-sidepanel-state"> 미고용 슬롯</div>
</div>
</div>
<button className="ao-sidepanel-close" onClick={onClose}>×</button>
</div>
<div className="ao-sidepanel-content">
<p className="ao-empty">
자리는 아직 비어 있어요.<br />
준비 중인 에이전트입니다.
</p>
</div>
</div>
);
}
// variant === 'initial'
return (
<div className="ao-sidepanel ao-sidepanel-initial">
<div className="ao-sidepanel-content">
<p className="ao-empty">
왼쪽 그리드에서<br />
에이전트를 선택하세요.
</p>
</div>
</div>
);
}
```
- [ ] **Step 2: 커밋**
```bash
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/agent-office/components/EmptyDetailPanel.jsx
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(agent-office): EmptyDetailPanel for initial + placeholder views
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 6: SidePanel — AGENT_META 중앙화 + 이미지 헤더
**Files:**
- Modify: `src/pages/agent-office/components/SidePanel.jsx` (전체 교체)
- [ ] **Step 1: 새 SidePanel.jsx 작성 (기존 내용 전체 교체)**
```jsx
// src/pages/agent-office/components/SidePanel.jsx
import { useState } from 'react';
import { AGENT_META } from '../constants.js';
import CommandTab from './CommandTab.jsx';
import TaskTab from './TaskTab.jsx';
import TokenTab from './TokenTab.jsx';
import LogTab from './LogTab.jsx';
const TABS = ['Commands', 'Tasks', 'Tokens', 'Logs'];
export default function SidePanel({ agentId, agentState, pendingTask, onClose, refreshTrigger }) {
const [activeTab, setActiveTab] = useState('Commands');
const meta = AGENT_META[agentId];
if (!meta) return null;
const stateText = agentState?.detail
? `${agentState.state} - ${agentState.detail}`
: agentState?.state || 'unknown';
return (
<div className="ao-sidepanel">
<div className="ao-sidepanel-header">
<div className="ao-sidepanel-agent">
<div className="ao-sidepanel-icon" style={{ borderColor: meta.color }}>
<img src={meta.image} alt={meta.displayName} />
</div>
<div className="ao-sidepanel-info">
<div className="ao-sidepanel-name">{meta.displayName}</div>
<div className="ao-sidepanel-state"> {stateText}</div>
</div>
</div>
<button className="ao-sidepanel-close" onClick={onClose}>×</button>
</div>
<div className="ao-sidepanel-tabs">
{TABS.map(tab => (
<button
key={tab}
className={`ao-sidepanel-tab ${activeTab === tab ? 'active' : ''}`}
onClick={() => setActiveTab(tab)}
>
{tab}
</button>
))}
</div>
<div className="ao-sidepanel-content">
{activeTab === 'Commands' && (
<CommandTab agentId={agentId} agentState={agentState?.state} pendingTask={pendingTask} />
)}
{activeTab === 'Tasks' && (
<TaskTab agentId={agentId} refreshTrigger={refreshTrigger} />
)}
{activeTab === 'Tokens' && (
<TokenTab agentId={agentId} />
)}
{activeTab === 'Logs' && (
<LogTab agentId={agentId} refreshTrigger={refreshTrigger} />
)}
</div>
</div>
);
}
```
- [ ] **Step 2: 커밋**
```bash
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/agent-office/components/SidePanel.jsx
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "refactor(agent-office): SidePanel uses central AGENT_META + image header
- emoji icon replaced with agent_{id}.png image
- AGENT_META imported from constants (single source of truth)
- blog removed, insta added (matches backend agent registry)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 7: TopBar 단순화
**Files:**
- Modify: `src/pages/agent-office/components/TopBar.jsx` (전체 교체)
- [ ] **Step 1: 새 TopBar.jsx 작성**
```jsx
// src/pages/agent-office/components/TopBar.jsx
export default function TopBar({ connected }) {
return (
<div className="ao-topbar">
<div className="ao-topbar-left">
<span className="ao-topbar-title">Agent Office</span>
<span className={`ao-topbar-status ${connected ? 'connected' : 'disconnected'}`}>
{connected ? 'Connected' : 'Disconnected'}
</span>
</div>
</div>
);
}
```
- [ ] **Step 2: 커밋 (보류)**
이 시점에서는 아직 `themes.js`가 존재하므로 빌드는 가능. AgentOffice.jsx 리팩토링 후 캔버스 모듈 삭제 시 함께 커밋. Task 9에서 묶음 커밋.
---
### Task 8: AgentOffice.jsx 재작성
**Files:**
- Modify: `src/pages/agent-office/AgentOffice.jsx` (전체 교체)
- [ ] **Step 1: 새 AgentOffice.jsx 작성**
```jsx
// src/pages/agent-office/AgentOffice.jsx
import { useState, useCallback } from 'react';
import { useAgentManager } from './hooks/useAgentManager.js';
import { AGENT_META } from './constants.js';
import TopBar from './components/TopBar.jsx';
import AgentGrid from './components/AgentGrid.jsx';
import SidePanel from './components/SidePanel.jsx';
import EmptyDetailPanel from './components/EmptyDetailPanel.jsx';
import './AgentOffice.css';
export default function AgentOffice() {
const {
agents, pendingTasks, notifications, connected,
refreshTrigger, clearNotifications
} = useAgentManager();
// selectedAgent: null | active agent id | "placeholder-N"
const [selectedAgent, setSelectedAgent] = useState(null);
const handleSelectAgent = useCallback((agentId) => {
setSelectedAgent(agentId);
clearNotifications(agentId);
}, [clearNotifications]);
const handleSelectPlaceholder = useCallback((placeholderKey) => {
setSelectedAgent(placeholderKey);
}, []);
const handleClose = useCallback(() => {
setSelectedAgent(null);
}, []);
const pendingTask = selectedAgent && AGENT_META[selectedAgent]
? pendingTasks.find(t => t.agent_id === selectedAgent)
: null;
let rightPanel;
if (selectedAgent === null) {
rightPanel = <EmptyDetailPanel variant="initial" />;
} else if (selectedAgent.startsWith('placeholder-')) {
rightPanel = <EmptyDetailPanel variant="placeholder" onClose={handleClose} />;
} else {
rightPanel = (
<SidePanel
agentId={selectedAgent}
agentState={agents[selectedAgent]}
pendingTask={pendingTask}
onClose={handleClose}
refreshTrigger={refreshTrigger}
/>
);
}
return (
<div className="ao-root">
<TopBar connected={connected} />
<div className="ao-main">
<div className="ao-grid-wrap">
<AgentGrid
agents={agents}
notifications={notifications}
selectedAgent={selectedAgent}
onSelectAgent={handleSelectAgent}
onSelectPlaceholder={handleSelectPlaceholder}
/>
</div>
{rightPanel}
</div>
</div>
);
}
export function Component() {
return <AgentOffice />;
}
```
- [ ] **Step 2: 빌드 사전 점검 (canvas import 잔재 확인)**
Run: `npx vite build 2>&1 | head -40`
Expected: `useOfficeCanvas` / `canvas/themes` 관련 import error가 나면 다음 단계로 진행. 다른 에러는 코드 수정 후 재시도.
(이 시점에서는 canvas 파일들이 아직 존재해서 빌드 통과할 가능성도 있음. 진행 가능.)
---
### Task 9: AgentOffice.css 재작성 + canvas 파일 삭제 + 묶음 커밋
**Files:**
- Modify: `src/pages/agent-office/AgentOffice.css` (전체 교체)
- Delete: 12개 파일 (canvas/ + hook + json)
- [ ] **Step 1: 새 AgentOffice.css 작성 (기존 내용 전체 교체)**
```css
/* src/pages/agent-office/AgentOffice.css */
/* ===== Root Layout ===== */
.ao-root {
display: flex;
flex-direction: column;
height: 100vh;
background: #0f172a;
color: #e2e8f0;
font-family: 'Courier New', monospace;
overflow: hidden;
}
/* ===== Top Bar ===== */
.ao-topbar {
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 0 16px;
background: #1a1a2e;
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.ao-topbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.ao-topbar-title {
font-weight: bold;
font-size: 15px;
color: #8b5cf6;
}
.ao-topbar-status { font-size: 11px; }
.ao-topbar-status.connected { color: #22c55e; }
.ao-topbar-status.disconnected { color: #ef4444; }
/* ===== Main Area ===== */
.ao-main {
flex: 1;
display: flex;
position: relative;
overflow: hidden;
}
/* ===== Grid Wrap ===== */
.ao-grid-wrap {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.ao-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
max-width: 720px;
margin: 0 auto;
}
/* ===== Agent Card ===== */
.ao-card {
position: relative;
aspect-ratio: 1 / 1.15;
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
cursor: pointer;
padding: 12px;
display: flex;
flex-direction: column;
align-items: center;
font-family: inherit;
color: inherit;
transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
}
.ao-card:hover {
transform: translateY(-2px);
border-color: var(--card-accent, #60a5fa);
}
.ao-card.active {
border-color: var(--card-accent, #60a5fa);
box-shadow: 0 0 0 2px var(--card-accent, #60a5fa);
}
.ao-card.placeholder {
opacity: 0.55;
}
.ao-card-dot {
position: absolute;
top: 8px;
left: 8px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #6b7280;
box-shadow: 0 0 0 2px #0f172a;
}
.ao-card-dot.working { background: #22c55e; }
.ao-card-dot.error { background: #ef4444; }
.ao-card-dot.waiting_approval { background: #f59e0b; }
.ao-card-dot.break { background: #94a3b8; }
.ao-card-dot.pulse {
animation: ao-pulse 1.6s ease-in-out infinite;
}
@keyframes ao-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(1.2); }
}
.ao-card-badge {
position: absolute;
top: 6px;
right: 6px;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: #ef4444;
color: #fff;
border-radius: 9px;
font-size: 10px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
}
.ao-card-image {
flex: 1;
width: 100%;
aspect-ratio: 1 / 1;
border-radius: 8px;
overflow: hidden;
background: #0f172a;
margin-bottom: 8px;
}
.ao-card-image img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ao-card-name {
font-size: 12px;
color: #e2e8f0;
text-align: center;
}
/* ===== Side Panel ===== */
.ao-sidepanel {
width: 320px;
background: #111;
border-left: 1px solid #333;
display: flex;
flex-direction: column;
flex-shrink: 0;
animation: slideIn 0.2s ease-out;
}
.ao-sidepanel-initial {
display: flex;
align-items: center;
justify-content: center;
}
@keyframes slideIn {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.ao-sidepanel-header {
padding: 12px;
border-bottom: 1px solid #333;
display: flex;
align-items: center;
justify-content: space-between;
}
.ao-sidepanel-agent {
display: flex;
align-items: center;
gap: 10px;
}
.ao-sidepanel-icon {
width: 40px;
height: 40px;
border-radius: 8px;
border: 2px solid #444;
overflow: hidden;
flex-shrink: 0;
}
.ao-sidepanel-icon img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.ao-sidepanel-name {
font-weight: bold;
font-size: 14px;
}
.ao-sidepanel-state {
font-size: 11px;
color: #22c55e;
}
.ao-sidepanel-close {
background: none;
border: none;
color: #666;
font-size: 24px;
cursor: pointer;
padding: 0 4px;
}
.ao-sidepanel-close:hover { color: #fff; }
/* Tabs */
.ao-sidepanel-tabs {
display: flex;
border-bottom: 1px solid #333;
}
.ao-sidepanel-tab {
flex: 1;
padding: 8px 4px;
text-align: center;
font-size: 12px;
font-family: inherit;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: #666;
cursor: pointer;
}
.ao-sidepanel-tab.active {
color: #8b5cf6;
border-bottom-color: #8b5cf6;
font-weight: bold;
}
.ao-sidepanel-tab:hover { color: #aaa; }
.ao-sidepanel-content {
flex: 1;
overflow-y: auto;
padding: 12px;
}
/* ===== Command Tab ===== */
.ao-command-tab { display: flex; flex-direction: column; gap: 12px; }
.ao-section { margin-bottom: 4px; }
.ao-section-label {
color: #888;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.ao-quick-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.ao-btn-quick {
background: #2a2a4e;
color: #8b5cf6;
border: 1px solid #4c1d95;
padding: 5px 12px;
border-radius: 4px;
font-size: 11px;
font-family: inherit;
cursor: pointer;
}
.ao-btn-quick:hover { background: #3a3a5e; }
.ao-btn-quick:disabled { opacity: 0.4; }
.ao-param-row { display: flex; gap: 6px; }
.ao-input {
flex: 1;
background: #1a1a2e;
border: 1px solid #333;
color: #fff;
padding: 7px 10px;
border-radius: 4px;
font-size: 12px;
font-family: inherit;
}
.ao-input::placeholder { color: #555; }
.ao-btn-send {
background: #4c1d95;
color: #fff;
border: none;
padding: 7px 14px;
border-radius: 4px;
font-size: 12px;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
}
.ao-btn-send:hover { background: #5b21b6; }
.ao-btn-send:disabled { opacity: 0.4; }
.ao-approval-card {
background: rgba(146,64,14,0.15);
border: 1px solid #92400e;
border-radius: 6px;
padding: 10px;
}
.ao-approval-title { color: #fbbf24; font-size: 12px; font-weight: bold; margin-bottom: 4px; }
.ao-approval-desc { color: #ddd; font-size: 11px; margin-bottom: 8px; word-break: break-all; }
.ao-approval-actions { display: flex; gap: 6px; }
.ao-btn-approve {
flex: 1; background: #065f46; color: #fff; border: none;
padding: 7px; border-radius: 4px; font-size: 12px; cursor: pointer;
}
.ao-btn-reject {
flex: 1; background: #7f1d1d; color: #fff; border: none;
padding: 7px; border-radius: 4px; font-size: 12px; cursor: pointer;
}
/* ===== Task Tab ===== */
.ao-task-tab { display: flex; flex-direction: column; gap: 4px; }
.ao-task-item { background: #1a1a2e; border-radius: 4px; padding: 8px; cursor: pointer; }
.ao-task-item:hover { background: #222240; }
.ao-task-header { display: flex; align-items: center; gap: 6px; font-size: 12px; }
.ao-task-type { color: #ccc; font-weight: bold; flex: 1; }
.ao-task-badge { padding: 1px 6px; border-radius: 3px; font-size: 10px; }
.ao-task-time { color: #666; font-size: 10px; }
.ao-task-result {
margin-top: 6px; background: #0d0d1a; padding: 6px; border-radius: 3px;
font-size: 10px; color: #aaa; max-height: 200px; overflow-y: auto;
white-space: pre-wrap; word-break: break-all;
}
/* ===== Token Tab ===== */
.ao-token-tab { display: flex; flex-direction: column; gap: 12px; }
.ao-token-period { display: flex; gap: 4px; }
.ao-btn-period {
flex: 1; background: #1a1a2e; color: #888; border: 1px solid #333;
padding: 5px; border-radius: 4px; font-size: 11px; font-family: inherit; cursor: pointer;
}
.ao-btn-period.active { background: #4c1d95; color: #fff; border-color: #4c1d95; }
.ao-token-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.ao-token-card { background: #1a1a2e; border-radius: 6px; padding: 10px; text-align: center; }
.ao-token-label { font-size: 10px; color: #888; text-transform: uppercase; margin-bottom: 4px; }
.ao-token-value { font-size: 18px; font-weight: bold; color: #fff; }
.ao-token-bar { margin-top: 4px; }
.ao-token-bar-label { font-size: 10px; color: #888; margin-bottom: 4px; }
.ao-token-bar-track { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: #1a1a2e; }
.ao-token-bar-fill.input { background: #3b82f6; }
.ao-token-bar-fill.output { background: #8b5cf6; }
.ao-token-bar-legend { display: flex; gap: 12px; font-size: 10px; color: #888; margin-top: 4px; }
.ao-token-bar-legend .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; }
.ao-token-bar-legend .dot.input { background: #3b82f6; }
.ao-token-bar-legend .dot.output { background: #8b5cf6; }
.ao-token-detail { display: flex; justify-content: space-between; font-size: 10px; color: #666; }
/* ===== Log Tab ===== */
.ao-log-tab {
max-height: 100%; overflow-y: auto; display: flex; flex-direction: column; gap: 2px;
}
.ao-log-item {
display: flex; gap: 6px; font-size: 11px; padding: 3px 0; border-bottom: 1px solid #1a1a2e;
}
.ao-log-time { color: #555; min-width: 60px; }
.ao-log-level { min-width: 48px; font-weight: bold; }
.ao-log-msg { color: #ccc; word-break: break-all; }
/* ===== Common ===== */
.ao-empty {
color: #94a3b8;
text-align: center;
padding: 24px;
font-size: 13px;
line-height: 1.6;
}
/* ===== Mobile (< 768px) ===== */
@media (max-width: 768px) {
.ao-grid-wrap { padding: 12px; }
.ao-grid {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.ao-main { flex-direction: column; }
.ao-sidepanel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
width: 100%;
max-height: 55vh;
border-left: none;
border-top: 1px solid #333;
border-radius: 16px 16px 0 0;
animation: slideUp 0.25s ease-out;
z-index: 100;
}
@keyframes slideUp {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.ao-sidepanel-header { padding: 8px 12px; }
.ao-sidepanel-header::before {
content: '';
display: block;
width: 32px;
height: 4px;
background: #555;
border-radius: 2px;
margin: 0 auto 8px;
}
.ao-sidepanel-tab { font-size: 11px; padding: 6px 2px; }
.ao-sidepanel-content {
padding: 8px 12px;
padding-bottom: env(safe-area-inset-bottom, 16px);
}
}
```
- [ ] **Step 2: Canvas 잔재 12개 파일 삭제**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ui
git rm src/pages/agent-office/canvas/themes.js
git rm src/pages/agent-office/canvas/FurnitureRenderer.js
git rm src/pages/agent-office/canvas/ProceduralSprite.js
git rm src/pages/agent-office/canvas/AgentSprite.js
git rm src/pages/agent-office/canvas/SpriteLoader.js
git rm src/pages/agent-office/canvas/OverlayRenderer.js
git rm src/pages/agent-office/canvas/Pathfinder.js
git rm src/pages/agent-office/canvas/OfficeRenderer.js
git rm src/pages/agent-office/canvas/TileMap.js
git rm src/pages/agent-office/hooks/useOfficeCanvas.js
git rm src/pages/agent-office/assets/office-map.json
```
(canvas/ 디렉토리 비게 되면 git이 자동으로 제거. 빈 디렉토리가 남으면 `rmdir`로 정리.)
- [ ] **Step 3: 빌드 검증**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ui && npx vite build`
Expected: 빌드 성공. canvas 관련 import 에러 없음. dist/ 생성.
- [ ] **Step 4: 테스트 전체 실행**
Run: `npx vitest run`
Expected: 기존 테스트 + Task 1·2의 신규 테스트 모두 PASS
- [ ] **Step 5: Task 7~9 묶음 커밋**
```bash
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add \
src/pages/agent-office/AgentOffice.jsx \
src/pages/agent-office/AgentOffice.css \
src/pages/agent-office/components/TopBar.jsx \
src/pages/agent-office/components/AgentGrid.jsx \
src/pages/agent-office/components/PlaceholderCard.jsx \
src/pages/agent-office/components/EmptyDetailPanel.jsx
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(agent-office): replace canvas office with 3x3 agent grid
- AgentOffice renders TopBar + AgentGrid + dynamic right panel
- Right panel: SidePanel (active) / EmptyDetailPanel (initial or placeholder)
- TopBar simplified to connected status only (theme/zoom dropped)
- Wire AgentGrid through useAgentManager state
- Remove canvas/ (9 files), useOfficeCanvas, office-map.json
- New CSS for grid cards (state dot, notification badge, accent border)
- Mobile: 2-column grid + bottom-sheet panel
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
### Task 10: 수동 검증 (dev server)
**Files:** 없음 (브라우저 검증)
- [ ] **Step 1: dev server 기동**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm run dev` (background로 띄우거나 별도 터미널)
- [ ] **Step 2: 브라우저에서 http://localhost:3007/agent-office 열기**
- [ ] **Step 3: 시각 체크리스트**
확인 항목:
- [ ] TopBar에 "Connected" 초록 점 표시
- [ ] 왼편에 3x3 그리드, 위쪽 5칸은 실제 에이전트 이미지, 아래 4칸은 `agent_undetermined.png` ("준비 중")
- [ ] 카드 hover 시 살짝 올라가고 accent border
- [ ] 초기 상태에서 우측에 "왼쪽 그리드에서 에이전트를 선택하세요" 안내
- [ ] active 에이전트 클릭 → 우측에 SidePanel 4탭 표시, 헤더에 emoji 대신 이미지
- [ ] 카드 좌상단 상태 dot이 working일 때 초록 + 펄스
- [ ] 알림 도착 시 우상단 빨간 뱃지, 카드 클릭 시 0으로 리셋
- [ ] placeholder 카드 클릭 → "이 자리는 아직 비어 있어요. 준비 중인 에이전트입니다."
- [ ] 모바일 뷰포트(<768px)로 줄여 2칸 그리드 + 하단 시트 정상
- [ ] **Step 4: 콘솔 에러 없음 확인**
브라우저 DevTools Console 확인. canvas 관련 import 잔재 에러나 이미지 404 없어야 함.
- [ ] **Step 5: dev server 종료**
---
### Task 11: 최종 정리 + lint
- [ ] **Step 1: lint 실행**
Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm run lint`
Expected: agent-office 디렉토리에서 신규 경고 없음. (기존 코드의 경고는 무관)
- [ ] **Step 2: 빈 디렉토리 정리**
```bash
cd C:/Users/jaeoh/Desktop/workspace/web-ui
rmdir src/pages/agent-office/canvas 2>/dev/null || true
```
(canvas/ 디렉토리가 git에서 사라지면 로컬 파일시스템에서도 자동으로 비워짐. 위 명령은 안전장치.)
- [ ] **Step 3: 잔재 grep 검증**
Run: `grep -r "useOfficeCanvas\|canvas/themes\|canvas/OfficeRenderer\|office-map" C:/Users/jaeoh/Desktop/workspace/web-ui/src 2>/dev/null`
Expected: 결과 없음 (모든 canvas 참조가 제거됨)
- [ ] **Step 4: 최종 커밋 (lint/정리에서 수정사항이 있을 경우)**
수정사항이 있으면:
```bash
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add -A
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "chore(agent-office): post-redesign lint + cleanup
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
수정사항 없으면 건너뛰기.
---
## Self-Review Notes
이 plan은 spec(`docs/superpowers/specs/2026-05-17-agent-office-grid-redesign-design.md`)의 다음 요소를 모두 다룬다:
| Spec 섹션 | 구현 위치 |
|-----------|-----------|
| §3 5명 active + 4 placeholder | Task 1 (`GRID_SLOTS`, `ACTIVE_AGENT_IDS`), Task 4 (`AgentGrid`) |
| §3 blog 제거, insta 추가 | Task 1 (`AGENT_META`), Task 6 (`SidePanel` import) |
| §3 insta 색상 d97706 | Task 1 (`AGENT_META.insta.color`) |
| §4 디렉토리 — assets 위치 | Task 1 import 경로 (`./assets/agent_xxx.png`, 실제 사용자 배치 위치 반영) |
| §4 6개 이미지 파일 | Task 1 imports |
| §4 삭제 대상 12개 | Task 9 git rm |
| §5 3x3 그리드, 슬롯 순서 | Task 1 `GRID_SLOTS`, Task 9 CSS |
| §5 AgentCard 시각(이미지+dot+뱃지+이름) | Task 2 `AgentCard.jsx`, Task 9 CSS |
| §5 dot 색상/펄스 | Task 1 `STATE_COLORS`, Task 9 `@keyframes ao-pulse` |
| §5 알림 뱃지 (9+ 표시, 클릭 시 리셋) | Task 2 `AgentCard.test.jsx` + 구현, Task 8 `handleSelectAgent``clearNotifications` |
| §6 데이터 플로우 | Task 8 `AgentOffice.jsx` |
| §7 SidePanel 헤더 이미지 | Task 6 |
| §8 CSS 토큰/반응형 | Task 9 CSS |
| §9 Edge cases (이미지 로드 실패) | onError fallback은 명세에 있지만 단순 정사각 이미지라 생략 가능. 추후 이슈 발생 시 보강 |
| §10 테스트 계획 | Task 1·2 단위 테스트 + Task 10 수동 시각 검증 |
**Type consistency 점검:**
- `AgentCard` props: `agentId, agentState, notificationCount, active, onClick` — Task 4의 `AgentGrid`에서 동일하게 전달 ✓
- `PlaceholderCard` props: `active, onClick` — Task 4의 `AgentGrid`에서 동일 ✓
- `EmptyDetailPanel` props: `variant, onClose` — Task 8의 `AgentOffice`에서 동일 ✓
- `SidePanel` props: `agentId, agentState, pendingTask, onClose, refreshTrigger` — 기존 호출부와 동일, Task 6에서 시그니처 유지 ✓
- `useAgentManager` 반환값 (`agents, pendingTasks, notifications, connected, refreshTrigger, clearNotifications`) — 기존 그대로 사용 ✓
- `selectedAgent` 상태 값 형식: `null` | `"stock"|"music"|..."lotto"` | `"placeholder-{idx}"` — Task 8의 분기와 일관 ✓
- `STATE_COLORS` 키 (`idle, working, error, waiting_approval, break`) — Task 2 테스트의 `working`/`idle`와 일치 ✓
**Placeholder 스캔:** TBD/TODO/"add appropriate" 패턴 없음. 모든 step에 실행 가능한 코드 또는 구체 명령 포함 ✓
---
## 주의사항 (구현자 참고)
1. **이미지 경로**: 사용자가 `assets/agents/`가 아닌 `assets/`에 직접 배치함. import 경로는 `./assets/agent_xxx.png`. spec과 실제 배치가 다른 이유는 이 plan의 §File Structure 섹션에 명시.
2. **TopBar 커밋 시점**: Task 7에서 작성하지만 commit은 Task 9에서 묶음. 단독 commit하면 `themes.js` 삭제 전이라 `getThemeNames` import가 잔재해서 dead-import. Task 9까지 한 단위.
3. **빈 디렉토리**: `git rm`이 디렉토리를 자동 정리. PowerShell/Windows에서 잔재가 보이면 `rmdir`로 명시 제거.
4. **Vite HMR**: 이미지 추가 후 dev 서버 재기동 불필요. 코드 변경 후도 자동 hot reload.
5. **백엔드 무변경**: `useAgentManager`와 WebSocket 경로(`/api/agent-office/ws`)는 손대지 않음. insta 에이전트도 백엔드에 이미 등록됨 (`web-backend/agent-office/app/agents/__init__.py:14`).