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

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

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

41 KiB
Raw Blame History

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 훅을 그대로 유지하고, 새로 만든 AgentGridAgentCard / PlaceholderCard 컴포넌트가 상태를 시각화한다. AGENT_META와 슬롯 레이아웃을 단일 constants.js로 중앙화하여 AgentCardSidePanel이 공유한다. 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: 실패하는 테스트 작성

// 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 작성
// 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: 커밋
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/agent-office/constants.js src/pages/agent-office/constants.test.js
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(agent-office): centralize AGENT_META + grid slot layout

- 5 active agents (stock/music/insta/realestate/lotto) + 4 placeholders
- AGENT_META, GRID_SLOTS, STATE_COLORS in single constants module
- blog removed (replaced by insta)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 2: AgentCard 컴포넌트 + 테스트

Files:

  • Create: src/pages/agent-office/components/AgentCard.jsx

  • Create: src/pages/agent-office/components/AgentCard.test.jsx

  • Step 1: 실패하는 테스트 작성

// src/pages/agent-office/components/AgentCard.test.jsx
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import AgentCard from './AgentCard.jsx';

describe('AgentCard', () => {
  it('에이전트의 displayName을 표시', () => {
    render(<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} onClick={() => {}} />);
    expect(screen.getByText('주식 트레이더')).toBeInTheDocument();
  });

  it('working 상태일 때 dot에 working 클래스 부여', () => {
    const { container } = render(
      <AgentCard agentId="stock" agentState={{ state: 'working' }} notificationCount={0} onClick={() => {}} />
    );
    const dot = container.querySelector('.ao-card-dot');
    expect(dot).toHaveClass('working');
  });

  it('agentState 없으면 idle로 fallback', () => {
    const { container } = render(
      <AgentCard agentId="stock" agentState={undefined} notificationCount={0} onClick={() => {}} />
    );
    const dot = container.querySelector('.ao-card-dot');
    expect(dot).toHaveClass('idle');
  });

  it('notificationCount > 0이면 뱃지 표시', () => {
    render(<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={3} onClick={() => {}} />);
    expect(screen.getByText('3')).toBeInTheDocument();
  });

  it('notificationCount === 0이면 뱃지 없음', () => {
    const { container } = render(
      <AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} onClick={() => {}} />
    );
    expect(container.querySelector('.ao-card-badge')).toBeNull();
  });

  it('notificationCount > 9이면 9+ 표시', () => {
    render(<AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={15} onClick={() => {}} />);
    expect(screen.getByText('9+')).toBeInTheDocument();
  });

  it('클릭 시 onClick 호출', () => {
    const onClick = vi.fn();
    const { container } = render(
      <AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} onClick={onClick} />
    );
    fireEvent.click(container.querySelector('.ao-card'));
    expect(onClick).toHaveBeenCalledTimes(1);
  });

  it('active prop 시 카드에 active 클래스 부여', () => {
    const { container } = render(
      <AgentCard agentId="stock" agentState={{ state: 'idle' }} notificationCount={0} active onClick={() => {}} />
    );
    expect(container.querySelector('.ao-card')).toHaveClass('active');
  });
});
  • Step 2: 테스트 실패 확인

Run: npx vitest run src/pages/agent-office/components/AgentCard.test.jsx Expected: FAIL — AgentCard.jsx가 존재하지 않음

  • Step 3: AgentCard.jsx 구현
// src/pages/agent-office/components/AgentCard.jsx
import { AGENT_META, STATE_COLORS, DEFAULT_STATE_COLOR } from '../constants.js';

export default function AgentCard({ agentId, agentState, notificationCount = 0, active = false, onClick }) {
  const meta = AGENT_META[agentId];
  if (!meta) return null;

  const state = agentState?.state || 'idle';
  const stateInfo = STATE_COLORS[state] || DEFAULT_STATE_COLOR;
  const dotClass = `ao-card-dot ${state}${stateInfo.pulse ? ' pulse' : ''}`;
  const badgeText = notificationCount > 9 ? '9+' : String(notificationCount);

  return (
    <button
      type="button"
      className={`ao-card ${active ? 'active' : ''}`}
      onClick={onClick}
      style={{ '--card-accent': meta.color }}
    >
      <span className={dotClass} title={state} />
      {notificationCount > 0 && (
        <span className="ao-card-badge">{badgeText}</span>
      )}
      <div className="ao-card-image">
        <img src={meta.image} alt={meta.displayName} />
      </div>
      <div className="ao-card-name">{meta.displayName}</div>
    </button>
  );
}
  • Step 4: 테스트 통과 확인

Run: npx vitest run src/pages/agent-office/components/AgentCard.test.jsx Expected: PASS — 8개 테스트 모두 통과

  • Step 5: 커밋
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/agent-office/components/AgentCard.jsx src/pages/agent-office/components/AgentCard.test.jsx
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(agent-office): AgentCard component with state dot + badge

- state→color mapping via STATE_COLORS
- notification badge with 9+ overflow
- active prop for selected card border

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 3: PlaceholderCard 컴포넌트

Files:

  • Create: src/pages/agent-office/components/PlaceholderCard.jsx

  • Step 1: PlaceholderCard.jsx 작성

// src/pages/agent-office/components/PlaceholderCard.jsx
import { PLACEHOLDER_IMAGE, PLACEHOLDER_LABEL } from '../constants.js';

export default function PlaceholderCard({ active = false, onClick }) {
  return (
    <button
      type="button"
      className={`ao-card placeholder ${active ? 'active' : ''}`}
      onClick={onClick}
    >
      <div className="ao-card-image">
        <img src={PLACEHOLDER_IMAGE} alt={PLACEHOLDER_LABEL} />
      </div>
      <div className="ao-card-name">{PLACEHOLDER_LABEL}</div>
    </button>
  );
}
  • Step 2: 커밋
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/agent-office/components/PlaceholderCard.jsx
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(agent-office): PlaceholderCard for unstaffed slots

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 4: AgentGrid 컴포넌트

Files:

  • Create: src/pages/agent-office/components/AgentGrid.jsx

  • Step 1: AgentGrid.jsx 작성

// src/pages/agent-office/components/AgentGrid.jsx
import { GRID_SLOTS } from '../constants.js';
import AgentCard from './AgentCard.jsx';
import PlaceholderCard from './PlaceholderCard.jsx';

export default function AgentGrid({ agents, notifications, selectedAgent, onSelectAgent, onSelectPlaceholder }) {
  return (
    <div className="ao-grid">
      {GRID_SLOTS.map((slot, idx) => {
        if (slot.agentId === null) {
          const placeholderKey = `placeholder-${idx}`;
          return (
            <PlaceholderCard
              key={placeholderKey}
              active={selectedAgent === placeholderKey}
              onClick={() => onSelectPlaceholder(placeholderKey)}
            />
          );
        }
        return (
          <AgentCard
            key={slot.agentId}
            agentId={slot.agentId}
            agentState={agents[slot.agentId]}
            notificationCount={notifications[slot.agentId] || 0}
            active={selectedAgent === slot.agentId}
            onClick={() => onSelectAgent(slot.agentId)}
          />
        );
      })}
    </div>
  );
}
  • Step 2: 커밋
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/agent-office/components/AgentGrid.jsx
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(agent-office): AgentGrid renders 9 slots from GRID_SLOTS

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 5: EmptyDetailPanel 컴포넌트

Files:

  • Create: src/pages/agent-office/components/EmptyDetailPanel.jsx

  • Step 1: EmptyDetailPanel.jsx 작성

// src/pages/agent-office/components/EmptyDetailPanel.jsx
import { PLACEHOLDER_IMAGE } from '../constants.js';

export default function EmptyDetailPanel({ variant = 'initial', onClose }) {
  if (variant === 'placeholder') {
    return (
      <div className="ao-sidepanel">
        <div className="ao-sidepanel-header">
          <div className="ao-sidepanel-agent">
            <div className="ao-sidepanel-icon">
              <img src={PLACEHOLDER_IMAGE} alt="준비 중" />
            </div>
            <div className="ao-sidepanel-info">
              <div className="ao-sidepanel-name">준비 </div>
              <div className="ao-sidepanel-state"> 미고용 슬롯</div>
            </div>
          </div>
          <button className="ao-sidepanel-close" onClick={onClose}>×</button>
        </div>
        <div className="ao-sidepanel-content">
          <p className="ao-empty">
             자리는 아직 비어 있어요.<br />
            준비 중인 에이전트입니다.
          </p>
        </div>
      </div>
    );
  }

  // variant === 'initial'
  return (
    <div className="ao-sidepanel ao-sidepanel-initial">
      <div className="ao-sidepanel-content">
        <p className="ao-empty">
          왼쪽 그리드에서<br />
          에이전트를 선택하세요.
        </p>
      </div>
    </div>
  );
}
  • Step 2: 커밋
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/agent-office/components/EmptyDetailPanel.jsx
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(agent-office): EmptyDetailPanel for initial + placeholder views

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 6: SidePanel — AGENT_META 중앙화 + 이미지 헤더

Files:

  • Modify: src/pages/agent-office/components/SidePanel.jsx (전체 교체)

  • Step 1: 새 SidePanel.jsx 작성 (기존 내용 전체 교체)

// src/pages/agent-office/components/SidePanel.jsx
import { useState } from 'react';
import { AGENT_META } from '../constants.js';
import CommandTab from './CommandTab.jsx';
import TaskTab from './TaskTab.jsx';
import TokenTab from './TokenTab.jsx';
import LogTab from './LogTab.jsx';

const TABS = ['Commands', 'Tasks', 'Tokens', 'Logs'];

export default function SidePanel({ agentId, agentState, pendingTask, onClose, refreshTrigger }) {
  const [activeTab, setActiveTab] = useState('Commands');
  const meta = AGENT_META[agentId];
  if (!meta) return null;

  const stateText = agentState?.detail
    ? `${agentState.state} - ${agentState.detail}`
    : agentState?.state || 'unknown';

  return (
    <div className="ao-sidepanel">
      <div className="ao-sidepanel-header">
        <div className="ao-sidepanel-agent">
          <div className="ao-sidepanel-icon" style={{ borderColor: meta.color }}>
            <img src={meta.image} alt={meta.displayName} />
          </div>
          <div className="ao-sidepanel-info">
            <div className="ao-sidepanel-name">{meta.displayName}</div>
            <div className="ao-sidepanel-state"> {stateText}</div>
          </div>
        </div>
        <button className="ao-sidepanel-close" onClick={onClose}>×</button>
      </div>

      <div className="ao-sidepanel-tabs">
        {TABS.map(tab => (
          <button
            key={tab}
            className={`ao-sidepanel-tab ${activeTab === tab ? 'active' : ''}`}
            onClick={() => setActiveTab(tab)}
          >
            {tab}
          </button>
        ))}
      </div>

      <div className="ao-sidepanel-content">
        {activeTab === 'Commands' && (
          <CommandTab agentId={agentId} agentState={agentState?.state} pendingTask={pendingTask} />
        )}
        {activeTab === 'Tasks' && (
          <TaskTab agentId={agentId} refreshTrigger={refreshTrigger} />
        )}
        {activeTab === 'Tokens' && (
          <TokenTab agentId={agentId} />
        )}
        {activeTab === 'Logs' && (
          <LogTab agentId={agentId} refreshTrigger={refreshTrigger} />
        )}
      </div>
    </div>
  );
}
  • Step 2: 커밋
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add src/pages/agent-office/components/SidePanel.jsx
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "refactor(agent-office): SidePanel uses central AGENT_META + image header

- emoji icon replaced with agent_{id}.png image
- AGENT_META imported from constants (single source of truth)
- blog removed, insta added (matches backend agent registry)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 7: TopBar 단순화

Files:

  • Modify: src/pages/agent-office/components/TopBar.jsx (전체 교체)

  • Step 1: 새 TopBar.jsx 작성

// src/pages/agent-office/components/TopBar.jsx
export default function TopBar({ connected }) {
  return (
    <div className="ao-topbar">
      <div className="ao-topbar-left">
        <span className="ao-topbar-title">Agent Office</span>
        <span className={`ao-topbar-status ${connected ? 'connected' : 'disconnected'}`}>
           {connected ? 'Connected' : 'Disconnected'}
        </span>
      </div>
    </div>
  );
}
  • Step 2: 커밋 (보류)

이 시점에서는 아직 themes.js가 존재하므로 빌드는 가능. AgentOffice.jsx 리팩토링 후 캔버스 모듈 삭제 시 함께 커밋. Task 9에서 묶음 커밋.


Task 8: AgentOffice.jsx 재작성

Files:

  • Modify: src/pages/agent-office/AgentOffice.jsx (전체 교체)

  • Step 1: 새 AgentOffice.jsx 작성

// src/pages/agent-office/AgentOffice.jsx
import { useState, useCallback } from 'react';
import { useAgentManager } from './hooks/useAgentManager.js';
import { AGENT_META } from './constants.js';
import TopBar from './components/TopBar.jsx';
import AgentGrid from './components/AgentGrid.jsx';
import SidePanel from './components/SidePanel.jsx';
import EmptyDetailPanel from './components/EmptyDetailPanel.jsx';
import './AgentOffice.css';

export default function AgentOffice() {
  const {
    agents, pendingTasks, notifications, connected,
    refreshTrigger, clearNotifications
  } = useAgentManager();

  // selectedAgent: null | active agent id | "placeholder-N"
  const [selectedAgent, setSelectedAgent] = useState(null);

  const handleSelectAgent = useCallback((agentId) => {
    setSelectedAgent(agentId);
    clearNotifications(agentId);
  }, [clearNotifications]);

  const handleSelectPlaceholder = useCallback((placeholderKey) => {
    setSelectedAgent(placeholderKey);
  }, []);

  const handleClose = useCallback(() => {
    setSelectedAgent(null);
  }, []);

  const pendingTask = selectedAgent && AGENT_META[selectedAgent]
    ? pendingTasks.find(t => t.agent_id === selectedAgent)
    : null;

  let rightPanel;
  if (selectedAgent === null) {
    rightPanel = <EmptyDetailPanel variant="initial" />;
  } else if (selectedAgent.startsWith('placeholder-')) {
    rightPanel = <EmptyDetailPanel variant="placeholder" onClose={handleClose} />;
  } else {
    rightPanel = (
      <SidePanel
        agentId={selectedAgent}
        agentState={agents[selectedAgent]}
        pendingTask={pendingTask}
        onClose={handleClose}
        refreshTrigger={refreshTrigger}
      />
    );
  }

  return (
    <div className="ao-root">
      <TopBar connected={connected} />
      <div className="ao-main">
        <div className="ao-grid-wrap">
          <AgentGrid
            agents={agents}
            notifications={notifications}
            selectedAgent={selectedAgent}
            onSelectAgent={handleSelectAgent}
            onSelectPlaceholder={handleSelectPlaceholder}
          />
        </div>
        {rightPanel}
      </div>
    </div>
  );
}

export function Component() {
  return <AgentOffice />;
}
  • Step 2: 빌드 사전 점검 (canvas import 잔재 확인)

Run: npx vite build 2>&1 | head -40 Expected: useOfficeCanvas / canvas/themes 관련 import error가 나면 다음 단계로 진행. 다른 에러는 코드 수정 후 재시도.

(이 시점에서는 canvas 파일들이 아직 존재해서 빌드 통과할 가능성도 있음. 진행 가능.)


Task 9: AgentOffice.css 재작성 + canvas 파일 삭제 + 묶음 커밋

Files:

  • Modify: src/pages/agent-office/AgentOffice.css (전체 교체)

  • Delete: 12개 파일 (canvas/ + hook + json)

  • Step 1: 새 AgentOffice.css 작성 (기존 내용 전체 교체)

/* 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개 파일 삭제
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 묶음 커밋
git -C C:/Users/jaeoh/Desktop/workspace/web-ui add \
  src/pages/agent-office/AgentOffice.jsx \
  src/pages/agent-office/AgentOffice.css \
  src/pages/agent-office/components/TopBar.jsx \
  src/pages/agent-office/components/AgentGrid.jsx \
  src/pages/agent-office/components/PlaceholderCard.jsx \
  src/pages/agent-office/components/EmptyDetailPanel.jsx
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "feat(agent-office): replace canvas office with 3x3 agent grid

- AgentOffice renders TopBar + AgentGrid + dynamic right panel
- Right panel: SidePanel (active) / EmptyDetailPanel (initial or placeholder)
- TopBar simplified to connected status only (theme/zoom dropped)
- Wire AgentGrid through useAgentManager state
- Remove canvas/ (9 files), useOfficeCanvas, office-map.json
- New CSS for grid cards (state dot, notification badge, accent border)
- Mobile: 2-column grid + bottom-sheet panel

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

Task 10: 수동 검증 (dev server)

Files: 없음 (브라우저 검증)

  • Step 1: dev server 기동

Run: cd C:/Users/jaeoh/Desktop/workspace/web-ui && npm run dev (background로 띄우거나 별도 터미널)

확인 항목:

  • 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: 빈 디렉토리 정리
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/정리에서 수정사항이 있을 경우)

수정사항이 있으면:

git -C C:/Users/jaeoh/Desktop/workspace/web-ui add -A
git -C C:/Users/jaeoh/Desktop/workspace/web-ui commit -m "chore(agent-office): post-redesign lint + cleanup

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"

수정사항 없으면 건너뛰기.


Self-Review Notes

이 plan은 spec(docs/superpowers/specs/2026-05-17-agent-office-grid-redesign-design.md)의 다음 요소를 모두 다룬다:

Spec 섹션 구현 위치
§3 5명 active + 4 placeholder Task 1 (GRID_SLOTS, ACTIVE_AGENT_IDS), Task 4 (AgentGrid)
§3 blog 제거, insta 추가 Task 1 (AGENT_META), Task 6 (SidePanel import)
§3 insta 색상 d97706 Task 1 (AGENT_META.insta.color)
§4 디렉토리 — assets 위치 Task 1 import 경로 (./assets/agent_xxx.png, 실제 사용자 배치 위치 반영)
§4 6개 이미지 파일 Task 1 imports
§4 삭제 대상 12개 Task 9 git rm
§5 3x3 그리드, 슬롯 순서 Task 1 GRID_SLOTS, Task 9 CSS
§5 AgentCard 시각(이미지+dot+뱃지+이름) Task 2 AgentCard.jsx, Task 9 CSS
§5 dot 색상/펄스 Task 1 STATE_COLORS, Task 9 @keyframes ao-pulse
§5 알림 뱃지 (9+ 표시, 클릭 시 리셋) Task 2 AgentCard.test.jsx + 구현, Task 8 handleSelectAgentclearNotifications
§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).