diff --git a/docs/superpowers/plans/2026-05-17-agent-office-grid-redesign.md b/docs/superpowers/plans/2026-05-17-agent-office-grid-redesign.md new file mode 100644 index 0000000..ef3657e --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-agent-office-grid-redesign.md @@ -0,0 +1,1271 @@ +# 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) " +``` + +--- + +### 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( {}} />); + expect(screen.getByText('주식 트레이더')).toBeInTheDocument(); + }); + + it('working 상태일 때 dot에 working 클래스 부여', () => { + const { container } = render( + {}} /> + ); + const dot = container.querySelector('.ao-card-dot'); + expect(dot).toHaveClass('working'); + }); + + it('agentState 없으면 idle로 fallback', () => { + const { container } = render( + {}} /> + ); + const dot = container.querySelector('.ao-card-dot'); + expect(dot).toHaveClass('idle'); + }); + + it('notificationCount > 0이면 뱃지 표시', () => { + render( {}} />); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('notificationCount === 0이면 뱃지 없음', () => { + const { container } = render( + {}} /> + ); + expect(container.querySelector('.ao-card-badge')).toBeNull(); + }); + + it('notificationCount > 9이면 9+ 표시', () => { + render( {}} />); + expect(screen.getByText('9+')).toBeInTheDocument(); + }); + + it('클릭 시 onClick 호출', () => { + const onClick = vi.fn(); + const { container } = render( + + ); + fireEvent.click(container.querySelector('.ao-card')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('active prop 시 카드에 active 클래스 부여', () => { + const { container } = render( + {}} /> + ); + 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 ( + + ); +} +``` + +- [ ] **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) " +``` + +--- + +### 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 ( + + ); +} +``` + +- [ ] **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) " +``` + +--- + +### 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 ( +
+ {GRID_SLOTS.map((slot, idx) => { + if (slot.agentId === null) { + const placeholderKey = `placeholder-${idx}`; + return ( + onSelectPlaceholder(placeholderKey)} + /> + ); + } + return ( + onSelectAgent(slot.agentId)} + /> + ); + })} +
+ ); +} +``` + +- [ ] **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) " +``` + +--- + +### 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 ( +
+
+
+
+ 준비 중 +
+
+
준비 중
+
● 미고용 슬롯
+
+
+ +
+
+

+ 이 자리는 아직 비어 있어요.
+ 준비 중인 에이전트입니다. +

+
+
+ ); + } + + // variant === 'initial' + return ( +
+
+

+ 왼쪽 그리드에서
+ 에이전트를 선택하세요. +

+
+
+ ); +} +``` + +- [ ] **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) " +``` + +--- + +### 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 ( +
+
+
+
+ {meta.displayName} +
+
+
{meta.displayName}
+
● {stateText}
+
+
+ +
+ +
+ {TABS.map(tab => ( + + ))} +
+ +
+ {activeTab === 'Commands' && ( + + )} + {activeTab === 'Tasks' && ( + + )} + {activeTab === 'Tokens' && ( + + )} + {activeTab === 'Logs' && ( + + )} +
+
+ ); +} +``` + +- [ ] **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) " +``` + +--- + +### 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 ( +
+
+ Agent Office + + ● {connected ? 'Connected' : 'Disconnected'} + +
+
+ ); +} +``` + +- [ ] **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 = ; + } else if (selectedAgent.startsWith('placeholder-')) { + rightPanel = ; + } else { + rightPanel = ( + + ); + } + + return ( +
+ +
+
+ +
+ {rightPanel} +
+
+ ); +} + +export function Component() { + return ; +} +``` + +- [ ] **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) " +``` + +--- + +### 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) " +``` + +수정사항 없으면 건너뛰기. + +--- + +## 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`).