diff --git a/docs/superpowers/plans/2026-04-27-agent-office-v2.md b/docs/superpowers/plans/2026-04-27-agent-office-v2.md new file mode 100644 index 0000000..20c03da --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-agent-office-v2.md @@ -0,0 +1,3163 @@ +# Agent Office v2 — Pixel Office UX 대규모 업데이트 구현 계획 + +> **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:** 대시보드 칼럼 중심 UI를 전체 화면 픽셀 오피스 캔버스 중심으로 전환하여 가상 오피스 몰입감 제공 + +**Architecture:** Canvas 2D 게임 루프 기반 렌더링 엔진 + BFS 경로 탐색 이동 시스템 + 3테마 프리셋. 에이전트 클릭 시 320px 사이드 패널(4탭)로 상세 정보 표시. 기존 백엔드 WebSocket 프로토콜 100% 호환. + +**Tech Stack:** React (기존), Canvas 2D API, requestAnimationFrame 게임 루프, BFS 경로 탐색, CSS transitions/transforms + +**Spec:** `docs/superpowers/specs/2026-04-27-agent-office-v2-design.md` + +**작업 대상 저장소:** `web-ui` (프론트엔드) — `C:\Users\jaeoh\Desktop\workspace\web-ui\` + +--- + +## Phase 1: 캔버스 엔진 기초 + +### Task 1: 테마 데이터 정의 + +**Files:** +- Create: `src/pages/agent-office/canvas/themes.js` + +- [ ] **Step 1: 테마 데이터 파일 생성** + +```javascript +// src/pages/agent-office/canvas/themes.js + +export const THEMES = { + modern: { + name: 'Modern', + wall: { color: '#1a1a2e', border: '#333', accent: '#8b5cf6' }, + floor: { color1: '#2a2a3e', color2: '#323248', grid: 'rgba(255,255,255,0.03)' }, + furniture: { desk: '#3a3a5a', chair: '#4c1d95', monitor: '#5555aa', monitorScreen: '#1a3a5a', shelf: '#2a2a4e', table: '#3a3a5a', sofa: '#2a2a4e', coffee: '#3a3a2a' }, + decor: { plant: '#22c55e', pot: '#4a3a2a', lamp: '#fbbf24', ledStrip: '#8b5cf6' }, + lighting: { ambient: 'rgba(139,92,246,0.05)', glow: 'rgba(139,92,246,0.15)' }, + text: { primary: '#ffffff', secondary: '#aaaaaa', label: '#888888' }, + ui: { panelBg: '#111111', headerBg: '#1a1a2e', border: '#333333', accent: '#8b5cf6' } + }, + retro: { + name: 'Retro', + wall: { color: '#2a1a0a', border: '#6a4a2a', accent: '#cc8844' }, + floor: { color1: '#4a3a1a', color2: '#3a2a10', grid: 'rgba(255,255,255,0.02)' }, + furniture: { desk: '#6a4a1a', chair: '#8a5a2a', monitor: '#555555', monitorScreen: '#1a3a1a', shelf: '#5a3a1a', table: '#5a4a2a', sofa: '#5a3a2a', coffee: '#4a3a1a' }, + decor: { plant: '#44aa44', pot: '#6a4a2a', lamp: '#ffcc66', brick: '#8a5a2a' }, + lighting: { ambient: 'rgba(255,200,100,0.05)', glow: 'rgba(255,200,100,0.2)' }, + text: { primary: '#ffe0b0', secondary: '#aa8866', label: '#887766' }, + ui: { panelBg: '#1a1008', headerBg: '#2a1a0a', border: '#4a3a2a', accent: '#cc8844' } + }, + minimal: { + name: 'Minimal', + wall: { color: '#fafafa', border: '#dddddd', accent: '#3b82f6' }, + floor: { color1: '#e8e8e8', color2: '#f0f0f0', grid: 'rgba(0,0,0,0.04)' }, + furniture: { desk: '#ffffff', chair: '#e0e0e0', monitor: '#333333', monitorScreen: '#e0e8f0', shelf: '#f5f5f5', table: '#ffffff', sofa: '#e8e8e8', coffee: '#f0f0f0' }, + decor: { plant: '#86efac', pot: '#ffffff', lamp: '#fbbf24', window: '#e0f0ff' }, + lighting: { ambient: 'rgba(59,130,246,0.03)', glow: 'rgba(255,255,255,0.1)' }, + text: { primary: '#1a1a1a', secondary: '#666666', label: '#999999' }, + ui: { panelBg: '#ffffff', headerBg: '#fafafa', border: '#e0e0e0', accent: '#3b82f6' } + } +}; + +export function getTheme(name) { + return THEMES[name] || THEMES.modern; +} + +export function getThemeNames() { + return Object.entries(THEMES).map(([key, val]) => ({ key, name: val.name })); +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add src/pages/agent-office/canvas/themes.js +git commit -m "feat(agent-office): add theme data definitions (modern/retro/minimal)" +``` + +--- + +### Task 2: 오피스 맵 데이터 확장 (32x20) + +**Files:** +- Rewrite: `src/pages/agent-office/assets/office-map.json` + +- [ ] **Step 1: 32x20 맵 데이터 작성** + +```json +{ + "cols": 32, + "rows": 20, + "tileSize": 32, + "floor": [ + [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0], + [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], + [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], + [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], + [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], + [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], + [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], + [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], + [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], + [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], + [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], + [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], + [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], + [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], + [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], + [0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], + [0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], + [0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], + [0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0], + [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0] + ], + "furniture": [ + {"type": "desk_monitor", "col": 3, "row": 3, "agent": "stock", "monitors": 3}, + {"type": "desk_monitor", "col": 10, "row": 3, "agent": "music", "monitors": 1, "accent": "instrument"}, + {"type": "desk_monitor", "col": 17, "row": 3, "agent": "blog", "monitors": 2, "accent": "papers"}, + {"type": "desk_monitor", "col": 24, "row": 3, "agent": "realestate", "monitors": 2, "accent": "briefcase"}, + {"type": "desk_monitor", "col": 14, "row": 7, "agent": "lotto", "monitors": 1, "accent": "dice"}, + {"type": "meeting_table","col": 13, "row": 11,"width": 6, "height": 2}, + {"type": "sofa", "col": 2, "row": 17}, + {"type": "coffee_machine","col": 5, "row": 16}, + {"type": "bookshelf", "col": 27, "row": 16, "height": 3}, + {"type": "plant", "col": 1, "row": 1}, + {"type": "plant", "col": 30, "row": 1}, + {"type": "plant", "col": 1, "row": 14}, + {"type": "plant", "col": 30, "row": 14}, + {"type": "water_cooler", "col": 8, "row": 17} + ], + "waypoints": { + "desk_stock": {"col": 3, "row": 4}, + "desk_music": {"col": 10, "row": 4}, + "desk_blog": {"col": 17, "row": 4}, + "desk_realestate": {"col": 24, "row": 4}, + "desk_lotto": {"col": 14, "row": 8}, + "meeting": {"col": 16, "row": 13}, + "break_room": {"col": 4, "row": 17}, + "coffee": {"col": 6, "row": 17}, + "water_cooler": {"col": 8, "row": 18} + }, + "blocked": [ + [3,3],[4,3],[5,3], + [10,3],[11,3], + [17,3],[18,3],[19,3], + [24,3],[25,3],[26,3], + [14,7],[15,7], + [13,11],[14,11],[15,11],[16,11],[17,11],[18,11], + [13,12],[14,12],[15,12],[16,12],[17,12],[18,12], + [2,17],[3,17], + [5,16],[6,16], + [27,16],[27,17],[27,18], + [8,17] + ], + "tileTypes": { + "0": "wall", + "1": "floor", + "2": "floor_break" + } +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add src/pages/agent-office/assets/office-map.json +git commit -m "feat(agent-office): expand office map to 32x20 with 5 agents and break room" +``` + +--- + +### Task 3: BFS 경로 탐색 엔진 + +**Files:** +- Create: `src/pages/agent-office/canvas/Pathfinder.js` + +- [ ] **Step 1: Pathfinder 모듈 작성** + +```javascript +// src/pages/agent-office/canvas/Pathfinder.js + +/** + * BFS 4방향 경로 탐색 (대각선 없음) + * blocked 타일과 벽 타일을 회피하여 최단 경로 반환 + */ +export class Pathfinder { + constructor(cols, rows) { + this.cols = cols; + this.rows = rows; + this.blocked = new Set(); + } + + /** blocked 타일 세팅 (wall + furniture footprint) */ + setBlocked(blockedList) { + this.blocked.clear(); + for (const [col, row] of blockedList) { + this.blocked.add(`${col},${row}`); + } + } + + /** wall 타일도 blocked로 추가 (floor 배열에서 0인 셀) */ + setWalls(floorGrid) { + for (let r = 0; r < this.rows; r++) { + for (let c = 0; c < this.cols; c++) { + if (floorGrid[r][c] === 0) { + this.blocked.add(`${c},${r}`); + } + } + } + } + + isBlocked(col, row) { + if (col < 0 || col >= this.cols || row < 0 || row >= this.rows) return true; + return this.blocked.has(`${col},${row}`); + } + + /** + * BFS 최단 경로 + * @returns {Array<{col, row}>} 시작점 제외, 도착점 포함 경로. 경로 없으면 빈 배열. + */ + findPath(startCol, startRow, goalCol, goalRow) { + if (startCol === goalCol && startRow === goalRow) return []; + + const key = (c, r) => `${c},${r}`; + const startKey = key(startCol, startRow); + const goalKey = key(goalCol, goalRow); + + const queue = [{ col: startCol, row: startRow }]; + const visited = new Set([startKey]); + const parent = new Map(); + + const dirs = [ + { dc: 0, dr: -1 }, // up + { dc: 0, dr: 1 }, // down + { dc: -1, dr: 0 }, // left + { dc: 1, dr: 0 } // right + ]; + + while (queue.length > 0) { + const current = queue.shift(); + + for (const { dc, dr } of dirs) { + const nc = current.col + dc; + const nr = current.row + dr; + const nk = key(nc, nr); + + if (visited.has(nk) || this.isBlocked(nc, nr)) continue; + // 골 지점은 blocked여도 이동 가능 (에이전트가 자기 자리에 앉으려면) + if (nk !== goalKey && this.blocked.has(nk)) continue; + + visited.add(nk); + parent.set(nk, key(current.col, current.row)); + queue.push({ col: nc, row: nr }); + + if (nc === goalCol && nr === goalRow) { + return this._reconstructPath(parent, startKey, goalKey); + } + } + } + + return []; // 경로 없음 + } + + _reconstructPath(parent, startKey, goalKey) { + const path = []; + let current = goalKey; + while (current !== startKey) { + const [c, r] = current.split(',').map(Number); + path.unshift({ col: c, row: r }); + current = parent.get(current); + } + return path; + } + + /** idle 배회용: start 주변 반경 내 랜덤 walkable 타일 */ + getRandomNearbyFloor(col, row, radius = 4) { + const candidates = []; + for (let dr = -radius; dr <= radius; dr++) { + for (let dc = -radius; dc <= radius; dc++) { + const nc = col + dc; + const nr = row + dr; + if (nc === col && nr === row) continue; + if (!this.isBlocked(nc, nr)) { + candidates.push({ col: nc, row: nr }); + } + } + } + if (candidates.length === 0) return null; + return candidates[Math.floor(Math.random() * candidates.length)]; + } +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add src/pages/agent-office/canvas/Pathfinder.js +git commit -m "feat(agent-office): add BFS pathfinder for agent movement" +``` + +--- + +### Task 4: 타일맵 렌더러 재작성 (테마 지원) + +**Files:** +- Rewrite: `src/pages/agent-office/canvas/TileMap.js` + +- [ ] **Step 1: TileMap 재작성** + +```javascript +// src/pages/agent-office/canvas/TileMap.js + +/** + * 타일맵 렌더러 — 바닥, 벽, 그리드를 테마 팔레트로 렌더링 + * 가구는 FurnitureRenderer가 별도 처리 + */ +export class TileMap { + constructor(mapData) { + this.cols = mapData.cols; + this.rows = mapData.rows; + this.tileSize = mapData.tileSize; + this.floor = mapData.floor; + this.tileTypes = mapData.tileTypes; + } + + /** + * 바닥 + 벽 렌더링 + * @param {CanvasRenderingContext2D} ctx + * @param {object} theme - themes.js 에서 가져온 테마 객체 + * @param {number} scale - 줌 레벨 + * @param {number} offsetX - 패닝 X 오프셋 + * @param {number} offsetY - 패닝 Y 오프셋 + */ + render(ctx, theme, scale, offsetX, offsetY) { + const ts = this.tileSize * scale; + + for (let r = 0; r < this.rows; r++) { + for (let c = 0; c < this.cols; c++) { + const tileType = this.floor[r][c]; + const x = c * ts + offsetX; + const y = r * ts + offsetY; + + // 화면 밖이면 스킵 + if (x + ts < 0 || y + ts < 0 || x > ctx.canvas.width || y > ctx.canvas.height) continue; + + if (tileType === 0) { + // 벽 + ctx.fillStyle = theme.wall.color; + ctx.fillRect(x, y, ts, ts); + // 벽 하단 경계선 + ctx.fillStyle = theme.wall.border; + ctx.fillRect(x, y + ts - scale, ts, scale); + } else { + // 바닥 + const isBreak = this.tileTypes[String(tileType)] === 'floor_break'; + ctx.fillStyle = isBreak ? theme.floor.color2 : theme.floor.color1; + ctx.fillRect(x, y, ts, ts); + + // 체커보드 패턴 + if ((r + c) % 2 === 0) { + ctx.fillStyle = theme.floor.grid; + ctx.fillRect(x, y, ts, ts); + } + + // 그리드 선 + ctx.strokeStyle = theme.floor.grid; + ctx.lineWidth = scale * 0.5; + ctx.strokeRect(x, y, ts, ts); + } + } + } + } + + /** 화면 좌표 → 타일 좌표 변환 */ + screenToTile(screenX, screenY, scale, offsetX, offsetY) { + const ts = this.tileSize * scale; + const col = Math.floor((screenX - offsetX) / ts); + const row = Math.floor((screenY - offsetY) / ts); + return { col, row }; + } + + /** 타일 좌표 → 화면 좌표 (타일 중앙) */ + tileToScreen(col, row, scale, offsetX, offsetY) { + const ts = this.tileSize * scale; + return { + x: col * ts + offsetX + ts / 2, + y: row * ts + offsetY + ts / 2 + }; + } +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add src/pages/agent-office/canvas/TileMap.js +git commit -m "refactor(agent-office): rewrite TileMap with theme support and viewport culling" +``` + +--- + +### Task 5: 가구 렌더러 (테마 기반 프로시저럴) + +**Files:** +- Create: `src/pages/agent-office/canvas/FurnitureRenderer.js` + +- [ ] **Step 1: FurnitureRenderer 작성** + +```javascript +// src/pages/agent-office/canvas/FurnitureRenderer.js + +/** + * 가구 프로시저럴 렌더러 — 테마 팔레트 기반 + * 각 가구 타입별 draw 함수, Y-sort를 위한 zY 반환 + */ +export class FurnitureRenderer { + constructor(furnitureList, tileSize) { + this.furnitureList = furnitureList; + this.tileSize = tileSize; + } + + /** + * 모든 가구를 Y-sort 순서로 반환 (에이전트와 함께 정렬하기 위함) + * @returns {Array<{type, col, row, zY, draw: Function}>} + */ + getRenderables(theme, scale, offsetX, offsetY) { + const ts = this.tileSize * scale; + return this.furnitureList.map(f => ({ + ...f, + zY: f.row, + draw: (ctx) => this._drawFurniture(ctx, f, theme, ts, offsetX, offsetY) + })); + } + + _drawFurniture(ctx, f, theme, ts, ox, oy) { + const x = f.col * ts + ox; + const y = f.row * ts + oy; + + switch (f.type) { + case 'desk_monitor': this._drawDesk(ctx, f, theme, ts, x, y); break; + case 'meeting_table': this._drawMeetingTable(ctx, f, theme, ts, x, y); break; + case 'sofa': this._drawSofa(ctx, theme, ts, x, y); break; + case 'coffee_machine':this._drawCoffeeMachine(ctx, theme, ts, x, y); break; + case 'bookshelf': this._drawBookshelf(ctx, f, theme, ts, x, y); break; + case 'plant': this._drawPlant(ctx, theme, ts, x, y); break; + case 'water_cooler': this._drawWaterCooler(ctx, theme, ts, x, y); break; + } + } + + _drawDesk(ctx, f, theme, ts, x, y) { + // 책상 상판 + const dw = ts * 2; + const dh = ts * 0.6; + ctx.fillStyle = theme.furniture.desk; + ctx.fillRect(x, y + ts * 0.2, dw, dh); + // 책상 다리 + ctx.fillStyle = theme.wall.border; + ctx.fillRect(x + ts * 0.1, y + dh + ts * 0.2, ts * 0.15, ts * 0.3); + ctx.fillRect(x + dw - ts * 0.25, y + dh + ts * 0.2, ts * 0.15, ts * 0.3); + + // 모니터들 + const monCount = f.monitors || 1; + const monW = ts * 0.5; + const monH = ts * 0.4; + const totalW = monCount * monW + (monCount - 1) * ts * 0.1; + let monX = x + (dw - totalW) / 2; + + for (let i = 0; i < monCount; i++) { + // 모니터 프레임 + ctx.fillStyle = theme.furniture.monitor; + ctx.fillRect(monX, y - monH + ts * 0.2, monW, monH); + // 화면 + ctx.fillStyle = theme.furniture.monitorScreen; + ctx.fillRect(monX + ts * 0.05, y - monH + ts * 0.25, monW - ts * 0.1, monH - ts * 0.1); + // 모니터 받침대 + ctx.fillStyle = theme.furniture.monitor; + ctx.fillRect(monX + monW * 0.35, y + ts * 0.2 - ts * 0.05, monW * 0.3, ts * 0.08); + monX += monW + ts * 0.1; + } + + // 의자 (책상 아래) + ctx.fillStyle = theme.furniture.chair; + ctx.fillRect(x + dw * 0.35, y + ts, dw * 0.3, ts * 0.5); + ctx.fillRect(x + dw * 0.3, y + ts * 0.8, dw * 0.4, ts * 0.25); + + // 에이전트별 악센트 소품 + if (f.accent === 'instrument') { + // 음표 모양 + ctx.fillStyle = theme.ui.accent; + ctx.fillRect(x + dw + ts * 0.2, y + ts * 0.3, ts * 0.1, ts * 0.5); + ctx.beginPath(); + ctx.arc(x + dw + ts * 0.2, y + ts * 0.8, ts * 0.15, 0, Math.PI * 2); + ctx.fill(); + } else if (f.accent === 'papers') { + // 서류 더미 + ctx.fillStyle = '#ffffff'; + ctx.fillRect(x + dw + ts * 0.1, y + ts * 0.3, ts * 0.35, ts * 0.45); + ctx.fillStyle = theme.text.label; + for (let i = 0; i < 3; i++) { + ctx.fillRect(x + dw + ts * 0.15, y + ts * 0.38 + i * ts * 0.1, ts * 0.25, ts * 0.02); + } + } else if (f.accent === 'briefcase') { + ctx.fillStyle = '#8B4513'; + ctx.fillRect(x + dw + ts * 0.1, y + ts * 0.5, ts * 0.4, ts * 0.3); + ctx.fillStyle = '#D4A06A'; + ctx.fillRect(x + dw + ts * 0.2, y + ts * 0.45, ts * 0.2, ts * 0.08); + } else if (f.accent === 'dice') { + ctx.fillStyle = '#ef4444'; + ctx.fillRect(x + dw + ts * 0.15, y + ts * 0.4, ts * 0.3, ts * 0.3); + ctx.fillStyle = '#ffffff'; + ctx.beginPath(); + ctx.arc(x + dw + ts * 0.3, y + ts * 0.55, ts * 0.05, 0, Math.PI * 2); + ctx.fill(); + } + } + + _drawMeetingTable(ctx, f, theme, ts, x, y) { + const w = (f.width || 4) * ts; + const h = (f.height || 2) * ts; + // 테이블 상판 + ctx.fillStyle = theme.furniture.table; + ctx.fillRect(x + ts * 0.1, y + ts * 0.1, w - ts * 0.2, h - ts * 0.2); + // 테이블 그림자 + ctx.fillStyle = 'rgba(0,0,0,0.15)'; + ctx.fillRect(x + ts * 0.15, y + h - ts * 0.1, w - ts * 0.25, ts * 0.1); + // 의자들 (상하 4개씩) + for (let i = 0; i < 4; i++) { + const cx = x + ts * 0.5 + i * (w - ts) / 3; + ctx.fillStyle = theme.furniture.chair; + ctx.fillRect(cx, y - ts * 0.3, ts * 0.4, ts * 0.35); + ctx.fillRect(cx, y + h - ts * 0.05, ts * 0.4, ts * 0.35); + } + } + + _drawSofa(ctx, theme, ts, x, y) { + ctx.fillStyle = theme.furniture.sofa; + ctx.fillRect(x, y, ts * 2, ts * 0.8); + // 등받이 + ctx.fillStyle = theme.furniture.sofa; + ctx.fillRect(x, y - ts * 0.3, ts * 2, ts * 0.35); + // 쿠션 구분선 + ctx.strokeStyle = theme.wall.border; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x + ts, y); + ctx.lineTo(x + ts, y + ts * 0.8); + ctx.stroke(); + } + + _drawCoffeeMachine(ctx, theme, ts, x, y) { + ctx.fillStyle = theme.furniture.coffee; + ctx.fillRect(x + ts * 0.15, y, ts * 0.7, ts * 0.8); + // 디스펜서 + ctx.fillStyle = theme.furniture.monitor; + ctx.fillRect(x + ts * 0.25, y + ts * 0.15, ts * 0.5, ts * 0.3); + // 커피 잔 + ctx.fillStyle = '#ffffff'; + ctx.fillRect(x + ts * 0.3, y + ts * 0.55, ts * 0.2, ts * 0.15); + // 스팀 + ctx.strokeStyle = 'rgba(255,255,255,0.3)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x + ts * 0.4, y + ts * 0.5); + ctx.quadraticCurveTo(x + ts * 0.45, y + ts * 0.35, x + ts * 0.4, y + ts * 0.2); + ctx.stroke(); + } + + _drawBookshelf(ctx, f, theme, ts, x, y) { + const h = (f.height || 3) * ts; + ctx.fillStyle = theme.furniture.shelf; + ctx.fillRect(x, y, ts * 0.9, h); + // 선반 및 책 + const bookColors = ['#aa4444', '#4444aa', '#44aa44', '#aaaa44', '#aa44aa', '#44aaaa']; + const shelfCount = f.height || 3; + for (let i = 0; i < shelfCount; i++) { + const sy = y + i * ts + ts * 0.1; + // 선반 판 + ctx.fillStyle = theme.furniture.shelf; + ctx.fillRect(x, sy + ts * 0.7, ts * 0.9, ts * 0.05); + // 책들 + for (let b = 0; b < 4; b++) { + ctx.fillStyle = bookColors[(i * 4 + b) % bookColors.length]; + ctx.fillRect(x + ts * 0.05 + b * ts * 0.2, sy + ts * 0.1, ts * 0.15, ts * 0.6); + } + } + } + + _drawPlant(ctx, theme, ts, x, y) { + // 화분 + ctx.fillStyle = theme.decor.pot; + ctx.fillRect(x + ts * 0.25, y + ts * 0.6, ts * 0.5, ts * 0.35); + ctx.fillRect(x + ts * 0.2, y + ts * 0.55, ts * 0.6, ts * 0.1); + // 잎 + ctx.fillStyle = theme.decor.plant; + ctx.beginPath(); + ctx.ellipse(x + ts * 0.5, y + ts * 0.35, ts * 0.3, ts * 0.25, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.ellipse(x + ts * 0.35, y + ts * 0.25, ts * 0.15, ts * 0.2, -0.3, 0, Math.PI * 2); + ctx.fill(); + ctx.beginPath(); + ctx.ellipse(x + ts * 0.65, y + ts * 0.25, ts * 0.15, ts * 0.2, 0.3, 0, Math.PI * 2); + ctx.fill(); + } + + _drawWaterCooler(ctx, theme, ts, x, y) { + // 본체 + ctx.fillStyle = theme.furniture.shelf; + ctx.fillRect(x + ts * 0.2, y + ts * 0.3, ts * 0.6, ts * 0.6); + // 물통 + ctx.fillStyle = 'rgba(100,180,255,0.5)'; + ctx.fillRect(x + ts * 0.3, y, ts * 0.4, ts * 0.35); + ctx.fillStyle = 'rgba(100,180,255,0.3)'; + ctx.beginPath(); + ctx.arc(x + ts * 0.5, y, ts * 0.2, 0, Math.PI * 2); + ctx.fill(); + } +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add src/pages/agent-office/canvas/FurnitureRenderer.js +git commit -m "feat(agent-office): add procedural furniture renderer with theme support" +``` + +--- + +### Task 6: 게임 루프 + 줌/팬 시스템 (OfficeRenderer 재작성) + +**Files:** +- Rewrite: `src/pages/agent-office/canvas/OfficeRenderer.js` + +- [ ] **Step 1: OfficeRenderer 재작성** + +```javascript +// src/pages/agent-office/canvas/OfficeRenderer.js + +import mapData from '../assets/office-map.json'; +import { TileMap } from './TileMap.js'; +import { FurnitureRenderer } from './FurnitureRenderer.js'; +import { Pathfinder } from './Pathfinder.js'; +import { AgentSprite } from './AgentSprite.js'; +import { OverlayRenderer } from './OverlayRenderer.js'; +import { getTheme } from './themes.js'; + +const AGENT_META = { + stock: { displayName: '주식 트레이더', emoji: '📈', color: '#4488cc' }, + music: { displayName: '음악 프로듀서', emoji: '🎵', color: '#44aa88' }, + blog: { displayName: '블로그 마케터', emoji: '✍️', color: '#d97706' }, + realestate: { displayName: '청약 애널리스트', emoji: '🏢', color: '#c026d3' }, + lotto: { displayName: '로또 큐레이터', emoji: '🎱', color: '#ef4444' } +}; + +export class OfficeRenderer { + constructor(canvas) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d'); + + // 맵 & 렌더러 + this.tileMap = new TileMap(mapData); + this.furnitureRenderer = new FurnitureRenderer(mapData.furniture, mapData.tileSize); + this.pathfinder = new Pathfinder(mapData.cols, mapData.rows); + this.overlayRenderer = new OverlayRenderer(); + + // blocked 타일 설정 + this.pathfinder.setWalls(mapData.floor); + this.pathfinder.setBlocked(mapData.blocked); + + // 테마 & 뷰포트 + this.theme = getTheme(localStorage.getItem('agent-office-theme') || 'modern'); + this.zoom = 2; + this.panX = 0; + this.panY = 0; + this._isPanning = false; + this._panStart = { x: 0, y: 0 }; + + // 에이전트 + this.agents = new Map(); + this._initAgents(); + + // 게임 루프 + this._lastTime = 0; + this._animId = null; + + // 이벤트 + this._setupInputHandlers(); + } + + _initAgents() { + for (const [id, meta] of Object.entries(AGENT_META)) { + const waypoint = mapData.waypoints[`desk_${id}`]; + if (!waypoint) continue; + const sprite = new AgentSprite(id, meta, waypoint.col, waypoint.row, this.pathfinder); + sprite.deskCol = waypoint.col; + sprite.deskRow = waypoint.row; + this.agents.set(id, sprite); + } + } + + /** 줌/팬/클릭 이벤트 핸들러 */ + _setupInputHandlers() { + // 마우스 휠 줌 + this.canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + const oldZoom = this.zoom; + if (e.deltaY < 0) { + this.zoom = Math.min(this.zoom + 0.5, 4); + } else { + this.zoom = Math.max(this.zoom - 0.5, 1); + } + // 마우스 위치 기준 줌 + if (this.zoom !== oldZoom) { + const rect = this.canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + const ratio = this.zoom / oldZoom; + this.panX = mx - (mx - this.panX) * ratio; + this.panY = my - (my - this.panY) * ratio; + } + }, { passive: false }); + + // 마우스 드래그 패닝 + this.canvas.addEventListener('mousedown', (e) => { + if (e.button === 0) { + this._isPanning = true; + this._panStart = { x: e.clientX - this.panX, y: e.clientY - this.panY }; + } + }); + window.addEventListener('mousemove', (e) => { + if (this._isPanning) { + this.panX = e.clientX - this._panStart.x; + this.panY = e.clientY - this._panStart.y; + } + }); + window.addEventListener('mouseup', () => { + this._isPanning = false; + }); + + // 터치 (모바일) + let lastTouchDist = 0; + let lastTouchCenter = { x: 0, y: 0 }; + this.canvas.addEventListener('touchstart', (e) => { + if (e.touches.length === 1) { + this._isPanning = true; + this._panStart = { x: e.touches[0].clientX - this.panX, y: e.touches[0].clientY - this.panY }; + } else if (e.touches.length === 2) { + this._isPanning = false; + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + lastTouchDist = Math.hypot(dx, dy); + lastTouchCenter = { + x: (e.touches[0].clientX + e.touches[1].clientX) / 2, + y: (e.touches[0].clientY + e.touches[1].clientY) / 2 + }; + } + }, { passive: false }); + this.canvas.addEventListener('touchmove', (e) => { + e.preventDefault(); + if (e.touches.length === 1 && this._isPanning) { + this.panX = e.touches[0].clientX - this._panStart.x; + this.panY = e.touches[0].clientY - this._panStart.y; + } else if (e.touches.length === 2) { + const dx = e.touches[0].clientX - e.touches[1].clientX; + const dy = e.touches[0].clientY - e.touches[1].clientY; + const dist = Math.hypot(dx, dy); + const oldZoom = this.zoom; + this.zoom = Math.min(4, Math.max(1, this.zoom * (dist / lastTouchDist))); + lastTouchDist = dist; + const rect = this.canvas.getBoundingClientRect(); + const cx = lastTouchCenter.x - rect.left; + const cy = lastTouchCenter.y - rect.top; + const ratio = this.zoom / oldZoom; + this.panX = cx - (cx - this.panX) * ratio; + this.panY = cy - (cy - this.panY) * ratio; + } + }, { passive: false }); + this.canvas.addEventListener('touchend', () => { + this._isPanning = false; + }); + } + + /** 클릭 히트 테스트 — AgentOffice에서 호출 */ + hitTest(clientX, clientY) { + const rect = this.canvas.getBoundingClientRect(); + const screenX = clientX - rect.left; + const screenY = clientY - rect.top; + const { col, row } = this.tileMap.screenToTile(screenX, screenY, this.zoom, this.panX, this.panY); + + // 에이전트 히트 (역순, 최상위 우선) + for (const [id, sprite] of [...this.agents.entries()].reverse()) { + const dx = Math.abs(sprite.x - col); + const dy = Math.abs(sprite.y - row); + if (dx < 1.2 && dy < 1.5) { + return { type: 'agent', id }; + } + } + return { type: 'empty' }; + } + + /** 에이전트 상태 업데이트 (WebSocket에서 호출) */ + updateAgentState(agentId, state, detail) { + const sprite = this.agents.get(agentId); + if (!sprite) return; + sprite.onStateChange(state, detail, mapData.waypoints); + } + + /** 에이전트 알림 배지 설정 */ + setAgentNotification(agentId, count) { + const sprite = this.agents.get(agentId); + if (sprite) sprite.notificationCount = count; + } + + /** 테마 변경 */ + setTheme(themeName) { + this.theme = getTheme(themeName); + localStorage.setItem('agent-office-theme', themeName); + } + + /** 줌 레벨 설정 */ + setZoom(level) { + const cx = this.canvas.width / 2; + const cy = this.canvas.height / 2; + const oldZoom = this.zoom; + this.zoom = Math.min(4, Math.max(1, level)); + const ratio = this.zoom / oldZoom; + this.panX = cx - (cx - this.panX) * ratio; + this.panY = cy - (cy - this.panY) * ratio; + } + + /** 카메라를 맵 중앙에 맞추기 */ + centerCamera() { + const mapW = mapData.cols * mapData.tileSize * this.zoom; + const mapH = mapData.rows * mapData.tileSize * this.zoom; + this.panX = (this.canvas.width - mapW) / 2; + this.panY = (this.canvas.height - mapH) / 2; + } + + /** 게임 루프 시작 */ + start() { + this.centerCamera(); + this._lastTime = performance.now(); + this._loop(this._lastTime); + } + + /** 게임 루프 중지 */ + stop() { + if (this._animId) { + cancelAnimationFrame(this._animId); + this._animId = null; + } + } + + _loop(timestamp) { + const dt = Math.min((timestamp - this._lastTime) / 1000, 0.1); // cap dt to avoid spiral + this._lastTime = timestamp; + + this._update(dt); + this._render(); + + this._animId = requestAnimationFrame((t) => this._loop(t)); + } + + _update(dt) { + for (const sprite of this.agents.values()) { + sprite.update(dt); + } + } + + _render() { + const ctx = this.ctx; + const dpr = window.devicePixelRatio || 1; + + // 캔버스 크기 조정 + const displayW = this.canvas.clientWidth; + const displayH = this.canvas.clientHeight; + if (this.canvas.width !== displayW * dpr || this.canvas.height !== displayH * dpr) { + this.canvas.width = displayW * dpr; + this.canvas.height = displayH * dpr; + ctx.scale(dpr, dpr); + } + + ctx.imageSmoothingEnabled = false; + ctx.clearRect(0, 0, displayW, displayH); + + // 배경 + ctx.fillStyle = this.theme.wall.color; + ctx.fillRect(0, 0, displayW, displayH); + + // 1. 타일맵 (바닥 + 벽) + this.tileMap.render(ctx, this.theme, this.zoom, this.panX, this.panY); + + // 2. Y-sorted: 가구 + 에이전트 + const renderables = []; + + // 가구 + const furnitureItems = this.furnitureRenderer.getRenderables(this.theme, this.zoom, this.panX, this.panY); + renderables.push(...furnitureItems); + + // 에이전트 + for (const sprite of this.agents.values()) { + renderables.push({ + zY: sprite.y, + draw: (ctx2) => sprite.draw(ctx2, this.zoom, this.panX, this.panY, mapData.tileSize) + }); + } + + // Y좌표 정렬 + renderables.sort((a, b) => a.zY - b.zY); + for (const item of renderables) { + item.draw(ctx); + } + + // 3. 오버레이 (항상 최상위) + for (const sprite of this.agents.values()) { + this.overlayRenderer.draw(ctx, sprite, this.theme, this.zoom, this.panX, this.panY, mapData.tileSize); + } + } + + /** 리사이즈 처리 */ + resize() { + // 다음 프레임에서 자동 조정됨 (_render에서 크기 체크) + } + + destroy() { + this.stop(); + // 이벤트 리스너는 canvas와 함께 GC됨 + } +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add src/pages/agent-office/canvas/OfficeRenderer.js +git commit -m "refactor(agent-office): rewrite OfficeRenderer with game loop, zoom/pan, Y-sorting" +``` + +--- + +## Phase 2: 에이전트 캐릭터 시스템 + +### Task 7: 프로시저럴 스프라이트 고도화 (16x32px) + +**Files:** +- Rewrite: `src/pages/agent-office/canvas/SpriteSheet.js` → renamed to `ProceduralSprite.js` +- Create: `src/pages/agent-office/canvas/ProceduralSprite.js` + +- [ ] **Step 1: ProceduralSprite 작성 (16x32 해상도, 4방향, 5상태)** + +```javascript +// src/pages/agent-office/canvas/ProceduralSprite.js + +/** + * 프로시저럴 픽셀 캐릭터 렌더러 (16×32px 기본 해상도) + * Phase 1: 코드로 캐릭터를 그림 + * Phase 2: SpriteLoader가 PNG 스프라이트로 대체 + */ + +const AGENT_COLORS = { + stock: { body: '#4488cc', hair: '#2255aa', accent: '#66aaee' }, + music: { body: '#44aa88', hair: '#228866', accent: '#66ccaa' }, + blog: { body: '#d97706', hair: '#b45e04', accent: '#f59e0b' }, + realestate: { body: '#c026d3', hair: '#9b1dab', accent: '#e044f0' }, + lotto: { body: '#ef4444', hair: '#cc2222', accent: '#ff6666' } +}; + +/** 애니메이션 프레임 설정 */ +const ANIM_CONFIG = { + idle: { frames: 2, speed: 0.8 }, + walk: { frames: 4, speed: 0.15, cycle: [0, 1, 2, 1] }, + type: { frames: 2, speed: 0.3 }, + wait: { frames: 2, speed: 0.5 }, + break_anim:{ frames: 2, speed: 1.0 } +}; + +export class ProceduralSprite { + /** + * 캐릭터 1프레임 렌더링 + * @param {CanvasRenderingContext2D} ctx + * @param {string} agentId + * @param {string} state - idle|walk|type|wait|break_anim + * @param {string} direction - down|up|right|left + * @param {number} frame - 현재 애니메이션 프레임 인덱스 + * @param {number} x - 캔버스 X 좌표 (캐릭터 중앙 하단) + * @param {number} y - 캔버스 Y 좌표 (캐릭터 중앙 하단) + * @param {number} scale - 렌더링 스케일 + */ + static draw(ctx, agentId, state, direction, frame, x, y, scale) { + const colors = AGENT_COLORS[agentId] || AGENT_COLORS.stock; + const px = scale; // 1 pixel = scale 크기 + const w = 16 * px; + const h = 32 * px; + const bx = x - w / 2; // 좌상단 기준 + const by = y - h; + + ctx.save(); + + // 좌우 반전 (left = right 플립) + if (direction === 'left') { + ctx.translate(x, 0); + ctx.scale(-1, 1); + ctx.translate(-x, 0); + } + + // 그림자 + ctx.fillStyle = 'rgba(0,0,0,0.2)'; + ctx.beginPath(); + ctx.ellipse(x, y, w * 0.35, px * 2, 0, 0, Math.PI * 2); + ctx.fill(); + + // 상태별 오프셋 + let bodyOffsetY = 0; + let legSpread = 0; + let armAngle = 0; + + if (state === 'walk') { + const walkFrame = ANIM_CONFIG.walk.cycle[frame % 4]; + legSpread = (walkFrame - 1) * px * 2; + bodyOffsetY = walkFrame === 1 ? -px : 0; + } else if (state === 'type') { + armAngle = frame % 2 === 0 ? 1 : -1; + bodyOffsetY = frame % 2 === 0 ? 0 : -px * 0.5; + } else if (state === 'wait') { + bodyOffsetY = Math.sin(frame * Math.PI) * px; + } else if (state === 'idle') { + bodyOffsetY = frame % 2 === 0 ? 0 : -px * 0.5; + } else if (state === 'break_anim') { + bodyOffsetY = frame % 2 === 0 ? 0 : px * 0.5; // 졸기 + } + + const by2 = by + bodyOffsetY; + + // 다리 + ctx.fillStyle = '#2a2a3e'; + // 왼쪽 다리 + ctx.fillRect(bx + px * 4 - legSpread, by2 + px * 24, px * 3, px * 8); + // 오른쪽 다리 + ctx.fillRect(bx + px * 9 + legSpread, by2 + px * 24, px * 3, px * 8); + // 신발 + ctx.fillStyle = '#333'; + ctx.fillRect(bx + px * 3 - legSpread, by2 + px * 30, px * 5, px * 2); + ctx.fillRect(bx + px * 8 + legSpread, by2 + px * 30, px * 5, px * 2); + + // 몸통 + ctx.fillStyle = colors.body; + ctx.fillRect(bx + px * 3, by2 + px * 12, px * 10, px * 13); + + // 팔 + if (state === 'type') { + // 타이핑: 팔 앞으로 뻗음 + ctx.fillStyle = colors.body; + ctx.fillRect(bx + px * 1, by2 + px * 13, px * 3, px * 8 + armAngle * px); + ctx.fillRect(bx + px * 12, by2 + px * 13, px * 3, px * 8 - armAngle * px); + // 손 + ctx.fillStyle = '#ffcc99'; + ctx.fillRect(bx + px * 1, by2 + px * 20 + armAngle * px, px * 3, px * 3); + ctx.fillRect(bx + px * 12, by2 + px * 20 - armAngle * px, px * 3, px * 3); + } else { + // 기본 팔 + ctx.fillStyle = colors.body; + ctx.fillRect(bx + px * 1, by2 + px * 13, px * 3, px * 10); + ctx.fillRect(bx + px * 12, by2 + px * 13, px * 3, px * 10); + // 손 + ctx.fillStyle = '#ffcc99'; + ctx.fillRect(bx + px * 1, by2 + px * 22, px * 3, px * 3); + ctx.fillRect(bx + px * 12, by2 + px * 22, px * 3, px * 3); + } + + // 머리 + ctx.fillStyle = '#ffcc99'; // 피부색 + ctx.fillRect(bx + px * 4, by2 + px * 2, px * 8, px * 10); + + // 머리카락 + ctx.fillStyle = colors.hair; + ctx.fillRect(bx + px * 3, by2 + px * 1, px * 10, px * 4); + if (direction === 'down' || direction === 'left' || direction === 'right') { + // 앞머리 + ctx.fillRect(bx + px * 4, by2 + px * 3, px * 2, px * 2); + } + + // 눈 + if (direction !== 'up') { + ctx.fillStyle = '#222'; + if (state === 'break_anim' && frame % 2 === 1) { + // 졸기: 눈 감음 + ctx.fillRect(bx + px * 5, by2 + px * 6, px * 2, px); + ctx.fillRect(bx + px * 9, by2 + px * 6, px * 2, px); + } else { + ctx.fillRect(bx + px * 5, by2 + px * 5, px * 2, px * 2); + ctx.fillRect(bx + px * 9, by2 + px * 5, px * 2, px * 2); + } + } + + // break 소품: 커피잔 + if (state === 'break_anim') { + ctx.fillStyle = '#ffffff'; + ctx.fillRect(bx + px * 14, by2 + px * 16, px * 3, px * 4); + ctx.fillStyle = '#8B4513'; + ctx.fillRect(bx + px * 14.5, by2 + px * 16.5, px * 2, px * 2); + } + + ctx.restore(); + } + + static getAnimConfig(state) { + const mapped = state === 'working' ? 'type' + : state === 'waiting' ? 'wait' + : state === 'reporting' ? 'type' + : state === 'break' ? 'break_anim' + : state === 'walk' ? 'walk' + : 'idle'; + return { ...(ANIM_CONFIG[mapped] || ANIM_CONFIG.idle), mapped }; + } +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add src/pages/agent-office/canvas/ProceduralSprite.js +git commit -m "feat(agent-office): add 16x32 procedural sprite with 5 states and 4 directions" +``` + +--- + +### Task 8: AgentSprite 재작성 (BFS 이동 + 배회) + +**Files:** +- Rewrite: `src/pages/agent-office/canvas/AgentSprite.js` + +- [ ] **Step 1: AgentSprite 재작성** + +```javascript +// src/pages/agent-office/canvas/AgentSprite.js + +import { ProceduralSprite } from './ProceduralSprite.js'; + +const WALK_SPEED = 3; // tiles per second +const WANDER_DELAY_MIN = 3; +const WANDER_DELAY_MAX = 8; +const WANDER_LIMIT_MIN = 3; +const WANDER_LIMIT_MAX = 6; +const REST_DELAY_MIN = 2; +const REST_DELAY_MAX = 20; + +export class AgentSprite { + constructor(id, meta, col, row, pathfinder) { + this.id = id; + this.meta = meta; + this.pathfinder = pathfinder; + + // 위치 (타일 좌표, 실수) + this.x = col; + this.y = row; + this.deskCol = col; + this.deskRow = row; + + // 상태 + this.state = 'idle'; // FSM 상태 (from backend) + this.detail = ''; + this.notificationCount = 0; + + // 애니메이션 + this.animState = 'idle'; // 렌더링용 상태 + this.direction = 'down'; + this.animFrame = 0; + this.animTimer = 0; + + // 이동 + this.path = []; // BFS 경로 [{col, row}, ...] + this.moveProgress = 0; // 0~1 현재 타일 → 다음 타일 + this.moveFrom = { col, row }; + this.moveTo_target = null; + + // 배회 + this._wandering = false; + this._wanderTimer = 0; + this._wanderCount = 0; + this._wanderLimit = 0; + this._restTimer = 0; + this._isResting = false; + this._isAtDesk = true; + } + + /** 매 프레임 호출 */ + update(dt) { + // 이동 처리 + if (this.path.length > 0) { + this._updateMovement(dt); + } else if (this._wandering) { + this._updateWander(dt); + } + + // 애니메이션 프레임 업데이트 + this._updateAnimation(dt); + } + + _updateMovement(dt) { + this.animState = 'walk'; + this.moveProgress += WALK_SPEED * dt; + + if (this.moveProgress >= 1) { + // 현재 구간 완료 + const arrived = this.path.shift(); + this.x = arrived.col; + this.y = arrived.row; + this.moveFrom = { col: arrived.col, row: arrived.row }; + this.moveProgress = 0; + + if (this.path.length === 0) { + // 최종 목적지 도착 + this._onArrival(); + } else { + // 다음 구간의 방향 설정 + this._updateDirection(this.path[0]); + } + } else { + // 보간 + const next = this.path[0]; + this.x = this.moveFrom.col + (next.col - this.moveFrom.col) * this.moveProgress; + this.y = this.moveFrom.row + (next.row - this.moveFrom.row) * this.moveProgress; + } + } + + _onArrival() { + const atDesk = Math.abs(this.x - this.deskCol) < 0.5 && Math.abs(this.y - this.deskRow) < 0.5; + this._isAtDesk = atDesk; + + if (this.state === 'working' || this.state === 'reporting') { + this.animState = 'type'; + this.direction = 'up'; // 모니터를 바라봄 + } else if (this.state === 'waiting') { + this.animState = 'wait'; + } else if (this.state === 'break') { + this.animState = 'break_anim'; + } else { + // idle 도착 — 배회 계속 또는 자리에서 쉬기 + if (this._wandering && this._wanderCount < this._wanderLimit) { + // 다음 배회 타이머 설정 + this._wanderTimer = WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN); + } else if (this._wandering) { + // 배회 끝, 휴식 + this._wandering = false; + this._isResting = true; + this._restTimer = REST_DELAY_MIN + Math.random() * (REST_DELAY_MAX - REST_DELAY_MIN); + } + this.animState = 'idle'; + } + } + + _updateWander(dt) { + if (this._isResting) { + this._restTimer -= dt; + if (this._restTimer <= 0) { + this._isResting = false; + this._startWandering(); + } + return; + } + + this._wanderTimer -= dt; + if (this._wanderTimer <= 0) { + // 랜덤 인접 타일로 이동 + const target = this.pathfinder.getRandomNearbyFloor( + Math.round(this.x), Math.round(this.y), 4 + ); + if (target) { + const path = this.pathfinder.findPath( + Math.round(this.x), Math.round(this.y), target.col, target.row + ); + if (path.length > 0 && path.length <= 6) { + this.path = path; + this.moveFrom = { col: Math.round(this.x), row: Math.round(this.y) }; + this.moveProgress = 0; + this._updateDirection(path[0]); + this._wanderCount++; + } + } + // 실패해도 타이머 리셋 + this._wanderTimer = WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN); + } + } + + _updateDirection(nextTile) { + const dx = nextTile.col - Math.round(this.x); + const dy = nextTile.row - Math.round(this.y); + if (Math.abs(dx) > Math.abs(dy)) { + this.direction = dx > 0 ? 'right' : 'left'; + } else { + this.direction = dy > 0 ? 'down' : 'up'; + } + } + + _updateAnimation(dt) { + const config = ProceduralSprite.getAnimConfig( + this.animState === 'walk' ? 'walk' : this.state + ); + this.animTimer += dt; + if (this.animTimer >= config.speed) { + this.animTimer = 0; + this.animFrame = (this.animFrame + 1) % config.frames; + } + } + + /** 백엔드 상태 변경 시 호출 */ + onStateChange(newState, detail, waypoints) { + const prevState = this.state; + this.state = newState; + this.detail = detail || ''; + + // 배회 중단 + this._wandering = false; + this._isResting = false; + + switch (newState) { + case 'working': + case 'reporting': + case 'waiting': + // 자리에 없으면 자리로 이동 + if (!this._isAtDesk) { + this._moveToDesk(); + } else { + this.animState = newState === 'waiting' ? 'wait' : 'type'; + this.direction = 'up'; + } + break; + + case 'break': { + // 휴게실로 이동 + const breakWp = waypoints.break_room || waypoints.coffee; + if (breakWp) { + this._navigateTo(breakWp.col, breakWp.row); + } + break; + } + + case 'idle': + if (prevState === 'break') { + // 휴게실에서 자리로 복귀 + this._moveToDesk(); + } + // 복귀 후 배회 시작 (도착 콜백에서 처리) + this._startWanderingAfterDelay(3); + break; + } + } + + _moveToDesk() { + this._navigateTo(this.deskCol, this.deskRow); + } + + _navigateTo(goalCol, goalRow) { + const startCol = Math.round(this.x); + const startRow = Math.round(this.y); + const path = this.pathfinder.findPath(startCol, startRow, goalCol, goalRow); + if (path.length > 0) { + this.path = path; + this.moveFrom = { col: startCol, row: startRow }; + this.moveProgress = 0; + this._updateDirection(path[0]); + } + } + + _startWanderingAfterDelay(delay) { + this._wandering = true; + this._wanderCount = 0; + this._wanderLimit = WANDER_LIMIT_MIN + Math.floor(Math.random() * (WANDER_LIMIT_MAX - WANDER_LIMIT_MIN)); + this._wanderTimer = delay; + this._isResting = false; + } + + _startWandering() { + this._startWanderingAfterDelay(WANDER_DELAY_MIN + Math.random() * (WANDER_DELAY_MAX - WANDER_DELAY_MIN)); + } + + isAtDesk() { + return this._isAtDesk; + } + + /** 렌더링 */ + draw(ctx, zoom, panX, panY, tileSize) { + const ts = tileSize * zoom; + const screenX = this.x * ts + panX + ts / 2; + const screenY = this.y * ts + panY + ts; + const spriteScale = zoom * 1.5; // 캐릭터 약간 크게 + + ProceduralSprite.draw( + ctx, this.id, + this.animState === 'walk' ? 'walk' : this.state, + this.direction, this.animFrame, + screenX, screenY, spriteScale + ); + } +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add src/pages/agent-office/canvas/AgentSprite.js +git commit -m "refactor(agent-office): rewrite AgentSprite with BFS movement and idle wandering" +``` + +--- + +### Task 9: SpriteLoader (Phase 2 준비, 폴백 지원) + +**Files:** +- Create: `src/pages/agent-office/canvas/SpriteLoader.js` + +- [ ] **Step 1: SpriteLoader 작성** + +```javascript +// src/pages/agent-office/canvas/SpriteLoader.js + +import { ProceduralSprite } from './ProceduralSprite.js'; + +/** + * 스프라이트 로더 — PNG 스프라이트시트가 있으면 사용, 없으면 프로시저럴 폴백 + * + * 스프라이트시트 규격 (Phase 2): + * - 프레임 크기: 16×32px + * - 행: 방향 (0=down, 1=up, 2=right) + * - 열: 상태별 프레임 (idle 2 | walk 4 | type 2 | wait 2 | break 2 = 12열) + */ +export class SpriteLoader { + constructor() { + this.sprites = new Map(); // agentId → { image: Image, loaded: boolean } + } + + /** PNG 스프라이트시트 로드 시도 */ + async tryLoad(agentId, url) { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + this.sprites.set(agentId, { image: img, loaded: true }); + resolve(true); + }; + img.onerror = () => { + resolve(false); // 폴백 사용 + }; + img.src = url; + }); + } + + hasSprite(agentId) { + return this.sprites.has(agentId) && this.sprites.get(agentId).loaded; + } + + /** + * 에이전트 1프레임 그리기 (스프라이트 또는 프로시저럴) + */ + draw(ctx, agentId, state, direction, frame, x, y, scale) { + if (this.hasSprite(agentId)) { + this._drawFromSheet(ctx, agentId, state, direction, frame, x, y, scale); + } else { + ProceduralSprite.draw(ctx, agentId, state, direction, frame, x, y, scale); + } + } + + _drawFromSheet(ctx, agentId, state, direction, frame, x, y, scale) { + const { image } = this.sprites.get(agentId); + const frameW = 16; + const frameH = 32; + + // 방향 → 행 + const dirRow = direction === 'up' ? 1 : direction === 'right' || direction === 'left' ? 2 : 0; + + // 상태 → 열 오프셋 + const stateOffsets = { idle: 0, walk: 2, type: 6, wait: 8, break_anim: 10 }; + const mappedState = state === 'working' ? 'type' : state === 'waiting' ? 'wait' + : state === 'reporting' ? 'type' : state === 'break' ? 'break_anim' + : state === 'walk' ? 'walk' : 'idle'; + const colOffset = stateOffsets[mappedState] || 0; + + const srcX = (colOffset + frame) * frameW; + const srcY = dirRow * frameH; + const destW = frameW * scale; + const destH = frameH * scale; + + ctx.save(); + if (direction === 'left') { + ctx.translate(x, 0); + ctx.scale(-1, 1); + ctx.translate(-x, 0); + } + ctx.drawImage(image, srcX, srcY, frameW, frameH, x - destW / 2, y - destH, destW, destH); + ctx.restore(); + } +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add src/pages/agent-office/canvas/SpriteLoader.js +git commit -m "feat(agent-office): add SpriteLoader with procedural fallback for Phase 2" +``` + +--- + +## Phase 3: 오버레이 시스템 + +### Task 10: 오버레이 렌더러 (이름, 배지, 말풍선) + +**Files:** +- Create: `src/pages/agent-office/canvas/OverlayRenderer.js` + +- [ ] **Step 1: OverlayRenderer 작성** + +```javascript +// src/pages/agent-office/canvas/OverlayRenderer.js + +/** + * 캔버스 위 오버레이 렌더링: + * - 이름 라벨 (항상) + * - 상태 배지 (항상) + * - 말풍선 (waiting 상태에서만) + * - 알림 배지 (notification > 0 일 때) + */ + +const STATE_BADGE = { + idle: { text: 'idle', bg: '#374151', fg: '#9ca3af' }, + working: { text: 'working', bg: '#1e3a5f', fg: '#60a5fa' }, + waiting: { text: 'waiting', bg: '#92400e', fg: '#fbbf24' }, + reporting: { text: 'reporting', bg: '#1e3a5f', fg: '#60a5fa' }, + break: { text: 'break', bg: '#065f46', fg: '#34d399' } +}; + +export class OverlayRenderer { + constructor() { + this._bubbleAlpha = new Map(); // agentId → alpha (fade in/out) + } + + draw(ctx, sprite, theme, zoom, panX, panY, tileSize) { + const ts = tileSize * zoom; + const centerX = sprite.x * ts + panX + ts / 2; + const topY = sprite.y * ts + panY - ts * 0.3; + + const fontSize = Math.max(10, 11 * zoom / 2); + const smallFontSize = Math.max(8, 9 * zoom / 2); + + // 1. 이름 라벨 + ctx.font = `bold ${fontSize}px 'Courier New', monospace`; + ctx.textAlign = 'center'; + ctx.fillStyle = sprite.meta.color; + ctx.fillText(sprite.meta.displayName, centerX, topY + ts * 1.85); + + // 2. 상태 배지 + const badge = STATE_BADGE[sprite.state] || STATE_BADGE.idle; + const badgeText = badge.text; + ctx.font = `${smallFontSize}px 'Courier New', monospace`; + const badgeW = ctx.measureText(badgeText).width + 8; + const badgeH = smallFontSize + 4; + const badgeX = centerX - badgeW / 2; + const badgeY = topY + ts * 1.9; + + ctx.fillStyle = badge.bg; + this._roundRect(ctx, badgeX, badgeY, badgeW, badgeH, 3); + ctx.fill(); + ctx.fillStyle = badge.fg; + ctx.textAlign = 'center'; + ctx.fillText(badgeText, centerX, badgeY + badgeH - 3); + + // 3. 말풍선 (waiting 상태에서만) + if (sprite.state === 'waiting') { + this._drawBubble(ctx, sprite, centerX, topY - ts * 0.2, zoom); + } + + // 4. 알림 배지 + if (sprite.notificationCount > 0) { + this._drawNotificationBadge(ctx, centerX + ts * 0.5, topY + ts * 0.2, sprite.notificationCount, zoom); + } + } + + _drawBubble(ctx, sprite, x, y, zoom) { + const text = '승인 대기!'; + const fontSize = Math.max(10, 11 * zoom / 2); + ctx.font = `bold ${fontSize}px 'Courier New', monospace`; + const tw = ctx.measureText(text).width; + const pw = tw + 16; + const ph = fontSize + 12; + const px = x - pw / 2; + const py = y - ph; + + // 말풍선 배경 + ctx.fillStyle = '#fbbf24'; + this._roundRect(ctx, px, py, pw, ph, 6); + ctx.fill(); + + // 꼬리 삼각형 + ctx.beginPath(); + ctx.moveTo(x - 5, py + ph); + ctx.lineTo(x + 5, py + ph); + ctx.lineTo(x, py + ph + 6); + ctx.closePath(); + ctx.fill(); + + // 텍스트 + ctx.fillStyle = '#000000'; + ctx.textAlign = 'center'; + ctx.fillText(text, x, py + ph - 5); + } + + _drawNotificationBadge(ctx, x, y, count, zoom) { + const r = Math.max(7, 8 * zoom / 2); + ctx.fillStyle = '#ef4444'; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = '#ffffff'; + ctx.font = `bold ${r}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(count > 9 ? '9+' : String(count), x, y); + ctx.textBaseline = 'alphabetic'; + } + + _roundRect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); + } +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add src/pages/agent-office/canvas/OverlayRenderer.js +git commit -m "feat(agent-office): add overlay renderer with labels, badges, and speech bubbles" +``` + +--- + +## Phase 4: 사이드 패널 (4탭) + +### Task 11: TopBar 컴포넌트 + +**Files:** +- Create: `src/pages/agent-office/components/TopBar.jsx` + +- [ ] **Step 1: TopBar 작성** + +```jsx +// src/pages/agent-office/components/TopBar.jsx +import { getThemeNames } from '../canvas/themes.js'; + +export default function TopBar({ connected, theme, onThemeChange, zoom, onZoomChange }) { + const themes = getThemeNames(); + + return ( +
+
+ Agent Office + + ● {connected ? 'Connected' : 'Disconnected'} + +
+
+ +
+ + {zoom}x + +
+
+
+ ); +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add src/pages/agent-office/components/TopBar.jsx +git commit -m "feat(agent-office): add TopBar component with theme and zoom controls" +``` + +--- + +### Task 12: CommandTab 컴포넌트 + +**Files:** +- Create: `src/pages/agent-office/components/CommandTab.jsx` + +- [ ] **Step 1: CommandTab 작성 (기존 AgentColumn 명령 기능 추출)** + +```jsx +// src/pages/agent-office/components/CommandTab.jsx +import { useState } from 'react'; +import { sendAgentCommand, approveAgentTask } from '../../../api'; + +const QUICK_ACTIONS = { + stock: [{ action: 'fetch_news', label: 'Fetch News' }, { action: 'test_telegram', label: 'Test Telegram' }], + music: [{ action: 'credits', label: 'Check Credits' }], + blog: [{ action: 'list_trend_keywords', label: 'List Keywords' }], + realestate: [{ action: 'dashboard', label: 'Dashboard' }], + lotto: [{ action: 'status', label: 'Status' }, { action: 'curate_now', label: 'Curate Now' }] +}; + +const PARAM_ACTIONS = { + stock: { action: 'add_alert', label: 'Add Alert', placeholder: '{"symbol":"005930","target_price":70000,"direction":"above"}' }, + music: { action: 'compose', label: 'Compose', placeholder: 'jazzy lo-fi piano beat' }, + blog: { action: 'research', label: 'Research', placeholder: 'keyword to research' }, + realestate: { action: 'fetch_matches', label: 'Fetch Matches', placeholder: '' }, + lotto: null +}; + +export default function CommandTab({ agentId, agentState, pendingTask, onCommandResult }) { + const [customAction, setCustomAction] = useState(''); + const [customParams, setCustomParams] = useState(''); + const [paramInput, setParamInput] = useState(''); + const [loading, setLoading] = useState(false); + + const quickActions = QUICK_ACTIONS[agentId] || []; + const paramAction = PARAM_ACTIONS[agentId]; + + const handleQuickAction = async (action) => { + setLoading(true); + try { + const result = await sendAgentCommand(agentId, action, {}); + onCommandResult?.(result); + } finally { + setLoading(false); + } + }; + + const handleParamAction = async () => { + if (!paramAction || !paramInput.trim()) return; + setLoading(true); + try { + let params = {}; + if (paramAction.action === 'compose') { + params = { prompt: paramInput }; + } else if (paramAction.action === 'research') { + params = { keyword: paramInput }; + } else { + try { params = JSON.parse(paramInput); } catch { params = { value: paramInput }; } + } + const result = await sendAgentCommand(agentId, paramAction.action, params); + onCommandResult?.(result); + setParamInput(''); + } finally { + setLoading(false); + } + }; + + const handleCustomCommand = async () => { + if (!customAction.trim()) return; + setLoading(true); + try { + let params = {}; + if (customParams.trim()) { + try { params = JSON.parse(customParams); } catch { params = { value: customParams }; } + } + const result = await sendAgentCommand(agentId, customAction, params); + onCommandResult?.(result); + setCustomAction(''); + setCustomParams(''); + } finally { + setLoading(false); + } + }; + + const handleApproval = async (approved) => { + if (!pendingTask) return; + setLoading(true); + try { + await approveAgentTask(agentId, pendingTask.id, approved); + } finally { + setLoading(false); + } + }; + + return ( +
+ {/* 승인 대기 UI */} + {agentState === 'waiting' && pendingTask && ( +
+
Awaiting Approval
+
{pendingTask.task_type}: {pendingTask.detail || JSON.stringify(pendingTask.input_data)}
+
+ + +
+
+ )} + + {/* Quick Actions */} +
+
Quick Actions
+
+ {quickActions.map(qa => ( + + ))} +
+
+ + {/* Parameterized Action */} + {paramAction && ( +
+
{paramAction.label}
+
+ setParamInput(e.target.value)} + placeholder={paramAction.placeholder} + onKeyDown={e => e.key === 'Enter' && handleParamAction()} + /> + +
+
+ )} + + {/* Custom Command */} +
+
Custom Command
+ setCustomAction(e.target.value)} + placeholder="Action name" + /> + setCustomParams(e.target.value)} + placeholder='Parameters (JSON)' + style={{ marginTop: 4 }} + /> + +
+
+ ); +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add src/pages/agent-office/components/CommandTab.jsx +git commit -m "feat(agent-office): add CommandTab with quick actions, params, and approval UI" +``` + +--- + +### Task 13: TaskTab 컴포넌트 + +**Files:** +- Create: `src/pages/agent-office/components/TaskTab.jsx` + +- [ ] **Step 1: TaskTab 작성** + +```jsx +// src/pages/agent-office/components/TaskTab.jsx +import { useState, useEffect } from 'react'; +import { getAgentTasks } from '../../../api'; + +const STATUS_STYLE = { + succeeded: { bg: '#065f46', fg: '#34d399' }, + failed: { bg: '#7f1d1d', fg: '#fca5a5' }, + working: { bg: '#1e3a5f', fg: '#60a5fa' }, + pending: { bg: '#92400e', fg: '#fbbf24' }, + approved: { bg: '#065f46', fg: '#34d399' }, + rejected: { bg: '#7f1d1d', fg: '#fca5a5' } +}; + +function formatTime(ts) { + if (!ts) return ''; + const d = new Date(ts); + const now = new Date(); + const isToday = d.toDateString() === now.toDateString(); + const time = d.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' }); + return isToday ? time : `${d.getMonth() + 1}/${d.getDate()} ${time}`; +} + +export default function TaskTab({ agentId, refreshTrigger }) { + const [tasks, setTasks] = useState([]); + const [expanded, setExpanded] = useState(null); + + useEffect(() => { + let cancelled = false; + getAgentTasks(agentId, 20).then(data => { + if (!cancelled) setTasks(data || []); + }); + return () => { cancelled = true; }; + }, [agentId, refreshTrigger]); + + return ( +
+ {tasks.length === 0 &&
No tasks yet
} + {tasks.map(task => { + const style = STATUS_STYLE[task.status] || STATUS_STYLE.pending; + return ( +
setExpanded(expanded === task.id ? null : task.id)}> +
+ {task.task_type} + {task.status} + {formatTime(task.created_at)} +
+ {expanded === task.id && task.result_data && ( +
{JSON.stringify(JSON.parse(task.result_data), null, 2)}
+ )} +
+ ); + })} +
+ ); +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add src/pages/agent-office/components/TaskTab.jsx +git commit -m "feat(agent-office): add TaskTab component with expandable task history" +``` + +--- + +### Task 14: TokenTab 컴포넌트 + +**Files:** +- Create: `src/pages/agent-office/components/TokenTab.jsx` + +- [ ] **Step 1: TokenTab 작성** + +```jsx +// src/pages/agent-office/components/TokenTab.jsx +import { useState, useEffect } from 'react'; +import { getAgentTokenUsage } from '../../../api'; + +export default function TokenTab({ agentId }) { + const [usage, setUsage] = useState(null); + const [days, setDays] = useState(1); + + useEffect(() => { + let cancelled = false; + getAgentTokenUsage(agentId, days).then(data => { + if (!cancelled) setUsage(data); + }); + return () => { cancelled = true; }; + }, [agentId, days]); + + if (!usage) return
Loading...
; + + const inputTokens = usage.input_tokens || 0; + const outputTokens = usage.output_tokens || 0; + const cacheRead = usage.cache_read || 0; + const cacheWrite = usage.cache_write || 0; + const total = inputTokens + outputTokens; + const cacheHitRate = inputTokens > 0 ? Math.round((cacheRead / inputTokens) * 100) : 0; + + return ( +
+
+ {[1, 7, 30].map(d => ( + + ))} +
+ +
+
+
Input Tokens
+
{inputTokens.toLocaleString()}
+
+
+
Output Tokens
+
{outputTokens.toLocaleString()}
+
+
+
Total
+
{total.toLocaleString()}
+
+
+
Cache Hit Rate
+
{cacheHitRate}%
+
+
+ + {/* Simple bar chart */} +
+
Input vs Output
+
+
0 ? `${(inputTokens / total) * 100}%` : '0%' }} + /> +
0 ? `${(outputTokens / total) * 100}%` : '0%' }} + /> +
+
+ Input + Output +
+
+ + {cacheRead > 0 && ( +
+ Cache Read: {cacheRead.toLocaleString()} + Cache Write: {cacheWrite.toLocaleString()} +
+ )} +
+ ); +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add src/pages/agent-office/components/TokenTab.jsx +git commit -m "feat(agent-office): add TokenTab with usage stats and cache hit rate" +``` + +--- + +### Task 15: LogTab 컴포넌트 + +**Files:** +- Create: `src/pages/agent-office/components/LogTab.jsx` + +- [ ] **Step 1: LogTab 작성** + +```jsx +// src/pages/agent-office/components/LogTab.jsx +import { useState, useEffect, useRef } from 'react'; +import { getAgentLogs } from '../../../api'; + +const LEVEL_STYLE = { + info: { color: '#60a5fa' }, + warning: { color: '#fbbf24' }, + error: { color: '#ef4444' } +}; + +export default function LogTab({ agentId, refreshTrigger }) { + const [logs, setLogs] = useState([]); + const scrollRef = useRef(null); + + useEffect(() => { + let cancelled = false; + getAgentLogs(agentId, 50).then(data => { + if (!cancelled) setLogs(data || []); + }); + return () => { cancelled = true; }; + }, [agentId, refreshTrigger]); + + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight; + } + }, [logs]); + + return ( +
+ {logs.length === 0 &&
No logs yet
} + {logs.map((log, i) => { + const style = LEVEL_STYLE[log.level] || LEVEL_STYLE.info; + const time = new Date(log.created_at).toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + return ( +
+ {time} + [{log.level}] + {log.message} +
+ ); + })} +
+ ); +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add src/pages/agent-office/components/LogTab.jsx +git commit -m "feat(agent-office): add LogTab with auto-scroll and level coloring" +``` + +--- + +### Task 16: SidePanel 컨테이너 (4탭 통합) + +**Files:** +- Create: `src/pages/agent-office/components/SidePanel.jsx` + +- [ ] **Step 1: SidePanel 작성** + +```jsx +// src/pages/agent-office/components/SidePanel.jsx +import { useState } from 'react'; +import CommandTab from './CommandTab.jsx'; +import TaskTab from './TaskTab.jsx'; +import TokenTab from './TokenTab.jsx'; +import LogTab from './LogTab.jsx'; + +const AGENT_META = { + stock: { displayName: '주식 트레이더', emoji: '📈', color: '#4488cc' }, + music: { displayName: '음악 프로듀서', emoji: '🎵', color: '#44aa88' }, + blog: { displayName: '블로그 마케터', emoji: '✍️', color: '#d97706' }, + realestate: { displayName: '청약 애널리스트', emoji: '🏢', color: '#c026d3' }, + lotto: { displayName: '로또 큐레이터', emoji: '🎱', color: '#ef4444' } +}; + +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 ( +
+ {/* Header */} +
+
+
+ {meta.emoji} +
+
+
{meta.displayName}
+
● {stateText}
+
+
+ +
+ + {/* Tabs */} +
+ {TABS.map(tab => ( + + ))} +
+ + {/* Tab Content */} +
+ {activeTab === 'Commands' && ( + + )} + {activeTab === 'Tasks' && ( + + )} + {activeTab === 'Tokens' && ( + + )} + {activeTab === 'Logs' && ( + + )} +
+
+ ); +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add src/pages/agent-office/components/SidePanel.jsx +git commit -m "feat(agent-office): add SidePanel container with 4-tab layout" +``` + +--- + +## Phase 5: 페이지 통합 + +### Task 17: useAgentManager 확장 + +**Files:** +- Modify: `src/pages/agent-office/hooks/useAgentManager.js` + +- [ ] **Step 1: lotto 에이전트 추가 + 상태 구조 개선** + +기존 `useAgentManager.js` 전체를 다음으로 교체: + +```javascript +// src/pages/agent-office/hooks/useAgentManager.js +import { useState, useEffect, useRef, useCallback } from 'react'; + +const WS_RECONNECT_DELAY = 3000; + +export function useAgentManager() { + const [agents, setAgents] = useState({}); // { agentId: { state, detail, task_id } } + const [pendingTasks, setPendingTasks] = useState([]); // [{id, agent_id, task_type, input_data}] + const [notifications, setNotifications] = useState({}); // { agentId: count } + const [connected, setConnected] = useState(false); + const [refreshTrigger, setRefreshTrigger] = useState(0); // 탭 데이터 리프레시용 + + const wsRef = useRef(null); + const reconnectRef = useRef(null); + + const connect = useCallback(() => { + const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'; + const ws = new WebSocket(`${protocol}://${window.location.host}/api/agent-office/ws`); + wsRef.current = ws; + + ws.onopen = () => setConnected(true); + + ws.onmessage = (e) => { + const msg = JSON.parse(e.data); + + switch (msg.type) { + case 'init': + // 에이전트 초기 상태 세팅 + const agentMap = {}; + for (const a of msg.agents) { + agentMap[a.agent_id] = { state: a.state, detail: a.detail || '', task_id: a.task_id }; + } + setAgents(agentMap); + setPendingTasks(msg.pending || []); + break; + + case 'agent_state': + setAgents(prev => ({ + ...prev, + [msg.agent]: { state: msg.state, detail: msg.detail || '', task_id: msg.task_id } + })); + // idle 전환 시 데이터 리프레시 + if (msg.state === 'idle') { + setRefreshTrigger(n => n + 1); + } + break; + + case 'task_complete': + setRefreshTrigger(n => n + 1); + break; + + case 'notification': + setNotifications(prev => ({ + ...prev, + [msg.agent]: (prev[msg.agent] || 0) + 1 + })); + break; + + case 'command_result': + // 사이드 패널에서 처리 + break; + } + }; + + ws.onclose = () => { + setConnected(false); + reconnectRef.current = setTimeout(connect, WS_RECONNECT_DELAY); + }; + + ws.onerror = () => ws.close(); + }, []); + + useEffect(() => { + connect(); + return () => { + if (wsRef.current) wsRef.current.close(); + if (reconnectRef.current) clearTimeout(reconnectRef.current); + }; + }, [connect]); + + const sendCommand = useCallback((agent, action, params = {}) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'command', agent, action, params })); + } + }, []); + + const sendApproval = useCallback((agent, taskId, approved) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(JSON.stringify({ type: 'approval', agent, task_id: taskId, approved })); + } + }, []); + + const clearNotifications = useCallback((agentId) => { + setNotifications(prev => ({ ...prev, [agentId]: 0 })); + }, []); + + return { + agents, + pendingTasks, + notifications, + connected, + refreshTrigger, + sendCommand, + sendApproval, + clearNotifications + }; +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add src/pages/agent-office/hooks/useAgentManager.js +git commit -m "refactor(agent-office): extend useAgentManager with lotto agent and refresh triggers" +``` + +--- + +### Task 18: useOfficeCanvas 재작성 + +**Files:** +- Rewrite: `src/pages/agent-office/hooks/useOfficeCanvas.js` + +- [ ] **Step 1: useOfficeCanvas 재작성** + +```javascript +// src/pages/agent-office/hooks/useOfficeCanvas.js +import { useRef, useEffect, useCallback } from 'react'; +import { OfficeRenderer } from '../canvas/OfficeRenderer.js'; + +export function useOfficeCanvas() { + const canvasRef = useRef(null); + const rendererRef = useRef(null); + + useEffect(() => { + if (!canvasRef.current) return; + + const renderer = new OfficeRenderer(canvasRef.current); + rendererRef.current = renderer; + renderer.start(); + + const handleResize = () => renderer.resize(); + window.addEventListener('resize', handleResize); + + return () => { + window.removeEventListener('resize', handleResize); + renderer.destroy(); + rendererRef.current = null; + }; + }, []); + + const updateAgentState = useCallback((agentId, state, detail) => { + rendererRef.current?.updateAgentState(agentId, state, detail); + }, []); + + const setAgentNotification = useCallback((agentId, count) => { + rendererRef.current?.setAgentNotification(agentId, count); + }, []); + + const setTheme = useCallback((themeName) => { + rendererRef.current?.setTheme(themeName); + }, []); + + const setZoom = useCallback((level) => { + rendererRef.current?.setZoom(level); + }, []); + + const hitTest = useCallback((clientX, clientY) => { + return rendererRef.current?.hitTest(clientX, clientY) || { type: 'empty' }; + }, []); + + const getZoom = useCallback(() => { + return rendererRef.current?.zoom || 2; + }, []); + + return { + canvasRef, + updateAgentState, + setAgentNotification, + setTheme, + setZoom, + hitTest, + getZoom + }; +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add src/pages/agent-office/hooks/useOfficeCanvas.js +git commit -m "refactor(agent-office): rewrite useOfficeCanvas hook for new renderer API" +``` + +--- + +### Task 19: AgentOffice.jsx 재작성 (전체 화면 캔버스 + 사이드 패널) + +**Files:** +- Rewrite: `src/pages/agent-office/AgentOffice.jsx` + +- [ ] **Step 1: AgentOffice 전체 재작성** + +```jsx +// src/pages/agent-office/AgentOffice.jsx +import { useState, useEffect, useCallback } from 'react'; +import { useAgentManager } from './hooks/useAgentManager.js'; +import { useOfficeCanvas } from './hooks/useOfficeCanvas.js'; +import TopBar from './components/TopBar.jsx'; +import SidePanel from './components/SidePanel.jsx'; +import './AgentOffice.css'; + +export default function AgentOffice() { + const { + agents, pendingTasks, notifications, connected, + refreshTrigger, clearNotifications + } = useAgentManager(); + + const { + canvasRef, updateAgentState, setAgentNotification, + setTheme, setZoom, hitTest, getZoom + } = useOfficeCanvas(); + + const [selectedAgent, setSelectedAgent] = useState(null); + const [theme, setThemeState] = useState(localStorage.getItem('agent-office-theme') || 'modern'); + const [zoom, setZoomState] = useState(2); + + // WebSocket 상태 → 캔버스 동기화 + useEffect(() => { + for (const [id, agentState] of Object.entries(agents)) { + updateAgentState(id, agentState.state, agentState.detail); + } + }, [agents, updateAgentState]); + + // 알림 → 캔버스 동기화 + useEffect(() => { + for (const [id, count] of Object.entries(notifications)) { + setAgentNotification(id, count); + } + }, [notifications, setAgentNotification]); + + // 캔버스 클릭 핸들러 + const handleCanvasClick = useCallback((e) => { + const result = hitTest(e.clientX, e.clientY); + if (result.type === 'agent') { + setSelectedAgent(result.id); + clearNotifications(result.id); + setAgentNotification(result.id, 0); + } else { + setSelectedAgent(null); + } + }, [hitTest, clearNotifications, setAgentNotification]); + + // 테마 변경 + const handleThemeChange = useCallback((name) => { + setThemeState(name); + setTheme(name); + }, [setTheme]); + + // 줌 변경 + const handleZoomChange = useCallback((level) => { + setZoomState(level); + setZoom(level); + }, [setZoom]); + + // 선택된 에이전트의 pending task + const pendingTask = selectedAgent + ? pendingTasks.find(t => t.agent_id === selectedAgent) + : null; + + return ( +
+ + +
+ + + {selectedAgent && ( + setSelectedAgent(null)} + refreshTrigger={refreshTrigger} + /> + )} +
+
+ ); +} +``` + +- [ ] **Step 2: 커밋** + +```bash +git add src/pages/agent-office/AgentOffice.jsx +git commit -m "refactor(agent-office): rewrite AgentOffice with full-screen canvas and side panel" +``` + +--- + +### Task 20: CSS 전체 재작성 + +**Files:** +- Rewrite: `src/pages/agent-office/AgentOffice.css` + +- [ ] **Step 1: CSS 재작성** + +```css +/* src/pages/agent-office/AgentOffice.css */ + +/* ===== Root Layout ===== */ +.ao-root { + display: flex; + flex-direction: column; + height: 100vh; + background: #0d0d1a; + color: #ffffff; + 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; } +.ao-topbar-right { + display: flex; + align-items: center; + gap: 10px; +} +.ao-topbar-select { + background: #2a2a3e; + color: #aaa; + border: 1px solid #444; + padding: 3px 8px; + border-radius: 4px; + font-size: 12px; + font-family: inherit; +} +.ao-topbar-zoom { + display: flex; + align-items: center; + gap: 4px; +} +.ao-topbar-zoom button { + background: #2a2a3e; + color: #aaa; + border: 1px solid #444; + width: 24px; + height: 24px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; +} +.ao-topbar-zoom button:disabled { + opacity: 0.3; + cursor: default; +} +.ao-topbar-zoom span { + color: #888; + font-size: 12px; + min-width: 28px; + text-align: center; +} + +/* ===== Main Area ===== */ +.ao-main { + flex: 1; + display: flex; + position: relative; + overflow: hidden; +} +.ao-canvas { + flex: 1; + cursor: grab; + display: block; +} +.ao-canvas:active { + cursor: grabbing; +} + +/* ===== 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; +} +@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: 36px; + height: 36px; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} +.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; } + +/* Approval */ +.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: #555; + text-align: center; + padding: 24px; + font-size: 13px; +} + +/* ===== Mobile (< 768px) ===== */ +@media (max-width: 768px) { + .ao-topbar-right { gap: 6px; } + .ao-topbar-select { font-size: 11px; padding: 2px 6px; } + + .ao-main { + flex-direction: column; + } + + .ao-canvas { + flex: 1; + } + + /* Side panel → bottom sheet */ + .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: 커밋** + +```bash +git add src/pages/agent-office/AgentOffice.css +git commit -m "refactor(agent-office): rewrite CSS for full-screen canvas layout with mobile bottom sheet" +``` + +--- + +### Task 21: 레거시 파일 정리 + +**Files:** +- Delete: `src/pages/agent-office/components/AgentColumn.jsx` +- Delete: `src/pages/agent-office/components/CommandColumn.jsx` +- Delete: `src/pages/agent-office/components/ChatPanel.jsx` +- Delete: `src/pages/agent-office/components/DocumentPanel.jsx` +- Delete: `src/pages/agent-office/canvas/SpriteSheet.js` + +- [ ] **Step 1: 레거시 파일 삭제** + +```bash +rm src/pages/agent-office/components/AgentColumn.jsx +rm src/pages/agent-office/components/CommandColumn.jsx +rm src/pages/agent-office/components/ChatPanel.jsx +rm src/pages/agent-office/components/DocumentPanel.jsx +rm src/pages/agent-office/canvas/SpriteSheet.js +``` + +- [ ] **Step 2: 빌드 확인** + +```bash +npm run build +``` + +Expected: 빌드 성공 (삭제된 파일을 import하는 곳이 없어야 함) + +- [ ] **Step 3: 커밋** + +```bash +git add -A +git commit -m "chore(agent-office): remove legacy dashboard components replaced by v2 UI" +``` + +--- + +### Task 22: 통합 테스트 (브라우저 수동) + +- [ ] **Step 1: 개발 서버 시작** + +```bash +npm run dev +``` + +- [ ] **Step 2: 브라우저에서 http://localhost:3007/agent-office 접속 후 확인 항목** + +1. 전체 화면에 픽셀 오피스 캔버스가 표시되는가 +2. 5명의 에이전트(stock, music, blog, realestate, lotto)가 맵에 있는가 +3. 마우스 휠로 줌 인/아웃이 되는가 (1x~4x) +4. 드래그로 패닝이 되는가 +5. 에이전트 클릭 시 사이드 패널이 열리는가 +6. 사이드 패널 4탭 (Commands, Tasks, Tokens, Logs)이 전환되는가 +7. Quick Action 버튼이 에이전트별로 다른가 +8. 빈 공간 클릭 시 사이드 패널이 닫히는가 +9. 테마 드롭다운으로 Modern/Retro/Minimal 전환이 되는가 +10. 상단 바에 연결 상태가 표시되는가 + +- [ ] **Step 3: 모바일 확인 (DevTools → 모바일 뷰)** + +1. 캔버스가 전체 화면을 차지하는가 +2. 에이전트 탭 시 바텀 시트가 올라오는가 +3. 바텀 시트 닫기가 동작하는가 + +- [ ] **Step 4: 문제 수정 후 커밋** + +```bash +git add -A +git commit -m "fix(agent-office): address integration issues from manual testing" +``` + +--- + +## Phase 6: 최종 검증 + +### Task 23: 백엔드 agent_move 메시지 확인 + +**Files:** +- Check: `web-backend/agent-office/app/agents/base.py` +- Check: `web-backend/agent-office/app/websocket_manager.py` + +- [ ] **Step 1: base.py의 transition 메서드에서 break 상태 시 agent_move 전송 확인** + +`base.py`의 `transition()` 메서드를 읽고, `break` 상태 전환 시 `agent_move` WebSocket 메시지가 broadcast되는지 확인. +만약 누락되어 있다면 `transition()` 내부에 다음을 추가: + +```python +# break 전환 시 프론트엔드에 이동 알림 +if new_state == "break": + await self._ws_manager.broadcast_move(self.agent_id, "break_room") +elif new_state in ("working", "reporting", "waiting"): + await self._ws_manager.broadcast_move(self.agent_id, "desk") +``` + +단, 현재 프론트엔드는 `agent_state` 메시지만으로 이동을 처리하도록 설계했으므로 (`AgentSprite.onStateChange`가 상태에 따라 자동 이동), `agent_move`는 선택적. 프론트엔드가 `agent_state`만 사용하여 정상 동작하면 백엔드 수정 불필요. + +- [ ] **Step 2: 확인 결과에 따라 커밋 (변경 있을 때만)** + +--- + +### Task 24: CLAUDE.md 업데이트 + +**Files:** +- Modify: `web-ui` 저장소의 CLAUDE.md (해당사항 있으면) + +- [ ] **Step 1: 프론트엔드 파일 구조 변경 반영** + +Agent Office 섹션이 있다면, v2 파일 구조 (canvas/, components/, hooks/) 반영. + +- [ ] **Step 2: 커밋** + +```bash +git add CLAUDE.md +git commit -m "docs: update CLAUDE.md with Agent Office v2 file structure" +``` + +--- + +## Summary + +| Phase | Tasks | 설명 | +|-------|-------|------| +| 1. 캔버스 엔진 | 1-6 | 테마, 맵, BFS, 타일맵, 가구, 게임루프 | +| 2. 에이전트 시스템 | 7-9 | 프로시저럴 스프라이트, AgentSprite, SpriteLoader | +| 3. 오버레이 | 10 | 이름, 배지, 말풍선, 알림 | +| 4. 사이드 패널 | 11-16 | TopBar, CommandTab, TaskTab, TokenTab, LogTab, SidePanel | +| 5. 페이지 통합 | 17-22 | Hook 재작성, AgentOffice 재작성, CSS, 레거시 정리, 테스트 | +| 6. 최종 검증 | 23-24 | 백엔드 확인, 문서 업데이트 |