# 에이전트 횡단 오버사이트 타임라인 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:** AgentOffice 우측 패널(에이전트 미선택 시)에 전 에이전트 활동을 시간순으로 보여주는 횡단 오버사이트 타임라인을 추가한다. **Architecture:** 백엔드 `GET /api/agent-office/activity`(필터 지원)를 소비. `useActivityFeed` 훅이 페이지네이션·필터·WS refreshTrigger 재조회를 담당하고, `ActivityTimeline`이 `ActivityFilters` + `ActivityItem` 리스트 + IntersectionObserver 무한스크롤을 조립한다. AgentOffice는 `selectedAgent===null`일 때 기존 `EmptyDetailPanel`을 `ActivityTimeline`으로 교체한다. **Tech Stack:** React 18, vitest + @testing-library/react(v16, `renderHook` 사용), 기존 `ao-*` CSS 컨벤션, `AGENT_META` 색상/표시명 재사용. --- ## 파일 구조 | 파일 | 책임 | |------|------| | `src/api.js` (수정) | `agentActivity({agent_id,type,status,days,limit,offset})` 헬퍼 추가 | | `src/pages/agent-office/hooks/useActivityFeed.js` (생성) | items/total/loading/error/hasMore 상태, 필터·refreshTrigger 재조회, loadMore append | | `src/pages/agent-office/components/ActivityItem.jsx` (생성) | 한 행: agent 색·표시명 + 메시지 + 상태/level 뱃지 + 시간/duration, 클릭 → onSelectAgent | | `src/pages/agent-office/components/ActivityFilters.jsx` (생성) | agent/type/status/days select 4종, type=log 시 status 비활성 | | `src/pages/agent-office/components/ActivityTimeline.jsx` (생성) | 컨테이너: 헤더 + 필터 + 리스트 + sentinel + 상태 | | `src/pages/agent-office/AgentOffice.jsx` (수정) | null 분기를 ActivityTimeline으로 교체 | | `src/pages/agent-office/AgentOffice.css` (수정) | 타임라인 baseline 스타일 (Task 7) → designer 마감 (Task 8) | | 각 `*.test.{js,jsx}` | hook/Item/Filters 단위 테스트 | --- ## Task 1: `agentActivity` API 헬퍼 **Files:** - Modify: `src/api.js` (기존 `getActivityFeed` 줄 근처, 596라인 부근) - [ ] **Step 1: 헬퍼 추가** `src/api.js`에서 기존 줄 ```js export const getActivityFeed = (limit=50, offset=0) => apiGet(`/api/agent-office/activity?limit=${limit}&offset=${offset}`); ``` 바로 아래에 추가: ```js // 횡단 오버사이트 타임라인용 — 빈 값은 쿼리에서 제외(백엔드 브랜치 선택). export const agentActivity = ({ agent_id, type, status, days, limit = 30, offset = 0 } = {}) => { const p = new URLSearchParams(); if (agent_id) p.set('agent_id', agent_id); if (type) p.set('type', type); if (status) p.set('status', status); if (days) p.set('days', String(days)); p.set('limit', String(limit)); p.set('offset', String(offset)); return apiGet(`/api/agent-office/activity?${p.toString()}`); }; ``` - [ ] **Step 2: lint 통과 확인** Run: `npm run lint` Expected: 에러 없음 (no-unused-vars 등) - [ ] **Step 3: Commit** ```bash git add src/api.js git commit -m "feat(agent-office): agentActivity API 헬퍼 추가" ``` --- ## Task 2: `useActivityFeed` 훅 (TDD) **Files:** - Create: `src/pages/agent-office/hooks/useActivityFeed.js` - Test: `src/pages/agent-office/hooks/useActivityFeed.test.js` - [ ] **Step 1: 실패하는 테스트 작성** `src/pages/agent-office/hooks/useActivityFeed.test.js`: ```js import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, act, waitFor } from '@testing-library/react'; import { useActivityFeed } from './useActivityFeed.js'; const mockAgentActivity = vi.fn(); vi.mock('../../../api', () => ({ agentActivity: (...args) => mockAgentActivity(...args), })); beforeEach(() => mockAgentActivity.mockReset()); const item = (over = {}) => ({ type: 'task', task_id: 'a', agent_id: 'stock', created_at: 't1', ...over }); describe('useActivityFeed', () => { it('마운트 시 offset=0으로 첫 페이지를 불러온다', async () => { mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 1 }); const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0)); await waitFor(() => expect(result.current.items).toHaveLength(1)); expect(mockAgentActivity).toHaveBeenCalledWith(expect.objectContaining({ days: 7, offset: 0 })); expect(result.current.total).toBe(1); }); it('loadMore는 다음 offset으로 append한다', async () => { mockAgentActivity .mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 2 }) .mockResolvedValueOnce({ items: [item({ task_id: 'b', agent_id: 'lotto', created_at: 't2' })], total: 2 }); const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0)); await waitFor(() => expect(result.current.items).toHaveLength(1)); await act(async () => { result.current.loadMore(); }); await waitFor(() => expect(result.current.items).toHaveLength(2)); expect(mockAgentActivity).toHaveBeenLastCalledWith(expect.objectContaining({ offset: 1 })); }); it('필터 변경 시 offset 리셋 + items 교체', async () => { mockAgentActivity .mockResolvedValueOnce({ items: [item({ task_id: 'a' })], total: 1 }) .mockResolvedValueOnce({ items: [item({ type: 'log', task_id: 'c', agent_id: 'insta', created_at: 't3', level: 'info' })], total: 1 }); const { result, rerender } = renderHook(({ f }) => useActivityFeed(f, 0), { initialProps: { f: { days: 7 } } }); await waitFor(() => expect(result.current.items[0].task_id).toBe('a')); rerender({ f: { days: 7, agent_id: 'insta' } }); await waitFor(() => expect(result.current.items[0].task_id).toBe('c')); expect(result.current.items).toHaveLength(1); }); it('refreshTrigger 변경 시 첫 페이지 재조회', async () => { mockAgentActivity.mockResolvedValue({ items: [item()], total: 1 }); const { rerender } = renderHook(({ rt }) => useActivityFeed({ days: 7 }, rt), { initialProps: { rt: 0 } }); await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(1)); rerender({ rt: 1 }); await waitFor(() => expect(mockAgentActivity).toHaveBeenCalledTimes(2)); }); it('hasMore는 items.length < total', async () => { mockAgentActivity.mockResolvedValueOnce({ items: [item()], total: 5 }); const { result } = renderHook(() => useActivityFeed({ days: 7 }, 0)); await waitFor(() => expect(result.current.items).toHaveLength(1)); expect(result.current.hasMore).toBe(true); }); }); ``` - [ ] **Step 2: 테스트 실패 확인** Run: `npm run test:run -- src/pages/agent-office/hooks/useActivityFeed.test.js` Expected: FAIL — "Failed to resolve import './useActivityFeed.js'" 또는 useActivityFeed undefined - [ ] **Step 3: 훅 구현** `src/pages/agent-office/hooks/useActivityFeed.js`: ```js // src/pages/agent-office/hooks/useActivityFeed.js import { useState, useEffect, useCallback, useRef } from 'react'; import { agentActivity } from '../../../api'; const PAGE_SIZE = 30; export function useActivityFeed(filters, refreshTrigger = 0) { const [items, setItems] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const offsetRef = useRef(0); const loadingRef = useRef(false); const filtersRef = useRef(filters); filtersRef.current = filters; const filterKey = JSON.stringify(filters); const fetchPage = useCallback(async (offset, replace) => { if (loadingRef.current) return; loadingRef.current = true; setLoading(true); setError(null); try { const data = await agentActivity({ ...filtersRef.current, limit: PAGE_SIZE, offset }); const newItems = Array.isArray(data?.items) ? data.items : []; setTotal(data?.total || 0); setItems(prev => (replace ? newItems : [...prev, ...newItems])); offsetRef.current = offset + newItems.length; } catch (e) { setError(e.message || '불러오기 실패'); } finally { loadingRef.current = false; setLoading(false); } }, []); useEffect(() => { offsetRef.current = 0; fetchPage(0, true); }, [filterKey, refreshTrigger, fetchPage]); const loadMore = useCallback(() => { if (loadingRef.current) return; if (offsetRef.current >= total) return; fetchPage(offsetRef.current, false); }, [fetchPage, total]); const retry = useCallback(() => { offsetRef.current = 0; fetchPage(0, true); }, [fetchPage]); const hasMore = items.length < total; return { items, total, loading, error, hasMore, loadMore, retry }; } ``` - [ ] **Step 4: 테스트 통과 확인** Run: `npm run test:run -- src/pages/agent-office/hooks/useActivityFeed.test.js` Expected: PASS (5 tests) - [ ] **Step 5: Commit** ```bash git add src/pages/agent-office/hooks/useActivityFeed.js src/pages/agent-office/hooks/useActivityFeed.test.js git commit -m "feat(agent-office): useActivityFeed 훅 (페이지네이션·필터·refresh)" ``` --- ## Task 3: `ActivityItem` 컴포넌트 (TDD) **Files:** - Create: `src/pages/agent-office/components/ActivityItem.jsx` - Test: `src/pages/agent-office/components/ActivityItem.test.jsx` - [ ] **Step 1: 실패하는 테스트 작성** `src/pages/agent-office/components/ActivityItem.test.jsx`: ```jsx import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import ActivityItem from './ActivityItem.jsx'; describe('ActivityItem', () => { it('task 항목은 상태 뱃지와 duration을 렌더한다', () => { render( {}} />); expect(screen.getByText('holdings_brief')).toBeInTheDocument(); expect(screen.getByText(/완료/)).toBeInTheDocument(); expect(screen.getByText('2s')).toBeInTheDocument(); }); it('log 항목은 level 아이콘을 렌더한다', () => { render( {}} />); expect(screen.getByText('signal_check')).toBeInTheDocument(); expect(screen.getByText('❌')).toBeInTheDocument(); }); it('클릭 시 onSelectAgent(agent_id)를 호출한다', () => { const onSelect = vi.fn(); render(); fireEvent.click(screen.getByText('발급').closest('.ao-activity-item')); expect(onSelect).toHaveBeenCalledWith('insta'); }); it('미지정 agent_id는 id를 그대로 표시한다', () => { render( {}} />); expect(screen.getByText('unknown')).toBeInTheDocument(); }); }); ``` - [ ] **Step 2: 테스트 실패 확인** Run: `npm run test:run -- src/pages/agent-office/components/ActivityItem.test.jsx` Expected: FAIL — import 해결 실패 - [ ] **Step 3: 컴포넌트 구현** `src/pages/agent-office/components/ActivityItem.jsx`: ```jsx // src/pages/agent-office/components/ActivityItem.jsx import { AGENT_META } from '../constants.js'; const STATUS_STYLE = { succeeded: { bg: '#065f46', fg: '#34d399', label: '✓ 완료' }, failed: { bg: '#7f1d1d', fg: '#fca5a5', label: '✗ 실패' }, working: { bg: '#1e3a5f', fg: '#60a5fa', label: '⏳ 진행' }, pending: { bg: '#92400e', fg: '#fbbf24', label: '⏳ 대기' }, }; const LEVEL_STYLE = { error: { icon: '❌', cls: 'level-error' }, warning: { icon: '⚠️', cls: 'level-warning' }, info: { icon: '·', cls: 'level-info' }, }; 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 ActivityItem({ item, onSelectAgent }) { const meta = AGENT_META[item.agent_id]; const color = meta?.color || '#6b7280'; const name = meta?.displayName || item.agent_id; const isTask = item.type === 'task'; const status = STATUS_STYLE[item.status] || STATUS_STYLE.pending; const level = LEVEL_STYLE[item.level] || LEVEL_STYLE.info; const highlight = isTask && (item.status === 'pending' || item.status === 'working'); return (
onSelectAgent(item.agent_id)} role="button" tabIndex={0} >
); } ``` - [ ] **Step 4: 테스트 통과 확인** Run: `npm run test:run -- src/pages/agent-office/components/ActivityItem.test.jsx` Expected: PASS (4 tests) - [ ] **Step 5: Commit** ```bash git add src/pages/agent-office/components/ActivityItem.jsx src/pages/agent-office/components/ActivityItem.test.jsx git commit -m "feat(agent-office): ActivityItem (task/log 행 + 상태 뱃지)" ``` --- ## Task 4: `ActivityFilters` 컴포넌트 (TDD) **Files:** - Create: `src/pages/agent-office/components/ActivityFilters.jsx` - Test: `src/pages/agent-office/components/ActivityFilters.test.jsx` - [ ] **Step 1: 실패하는 테스트 작성** `src/pages/agent-office/components/ActivityFilters.test.jsx`: ```jsx import { describe, it, expect, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; import ActivityFilters from './ActivityFilters.jsx'; const base = { agent_id: '', type: '', status: '', days: 7 }; describe('ActivityFilters', () => { it('type=log이면 상태 필터가 비활성화된다', () => { render( {}} />); expect(screen.getByLabelText('상태 필터')).toBeDisabled(); }); it('기간 변경 시 onChange가 days와 함께 호출된다', () => { const onChange = vi.fn(); render(); fireEvent.change(screen.getByLabelText('기간 필터'), { target: { value: '30' } }); expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ days: 30 })); }); it('type을 log로 바꾸면 status를 비운다', () => { const onChange = vi.fn(); render(); fireEvent.change(screen.getByLabelText('타입 필터'), { target: { value: 'log' } }); expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ type: 'log', status: '' })); }); }); ``` - [ ] **Step 2: 테스트 실패 확인** Run: `npm run test:run -- src/pages/agent-office/components/ActivityFilters.test.jsx` Expected: FAIL — import 해결 실패 - [ ] **Step 3: 컴포넌트 구현** `src/pages/agent-office/components/ActivityFilters.jsx`: ```jsx // src/pages/agent-office/components/ActivityFilters.jsx import { ACTIVE_AGENT_IDS, AGENT_META } from '../constants.js'; const TYPE_OPTIONS = [ { value: '', label: '전체' }, { value: 'task', label: 'Task' }, { value: 'log', label: 'Log' }, ]; const STATUS_OPTIONS = [ { value: '', label: '전체' }, { value: 'succeeded', label: '완료' }, { value: 'failed', label: '실패' }, { value: 'pending', label: '대기' }, ]; const DAYS_OPTIONS = [ { value: 1, label: '1일' }, { value: 7, label: '7일' }, { value: 30, label: '30일' }, ]; export default function ActivityFilters({ filters, onChange }) { const set = (patch) => onChange({ ...filters, ...patch }); const statusDisabled = filters.type === 'log'; return (
); } ``` - [ ] **Step 4: 테스트 통과 확인** Run: `npm run test:run -- src/pages/agent-office/components/ActivityFilters.test.jsx` Expected: PASS (3 tests) - [ ] **Step 5: Commit** ```bash git add src/pages/agent-office/components/ActivityFilters.jsx src/pages/agent-office/components/ActivityFilters.test.jsx git commit -m "feat(agent-office): ActivityFilters (agent/type/status/days)" ``` --- ## Task 5: `ActivityTimeline` 컨테이너 (TDD) **Files:** - Create: `src/pages/agent-office/components/ActivityTimeline.jsx` - Test: `src/pages/agent-office/components/ActivityTimeline.test.jsx` > 참고: jsdom에는 IntersectionObserver가 없으므로 테스트 setup에서 stub이 필요하다. Step 1에서 테스트 파일 상단에 직접 stub을 둔다(전역 test-setup 수정 없이 국소 처리). - [ ] **Step 1: 실패하는 테스트 작성** `src/pages/agent-office/components/ActivityTimeline.test.jsx`: ```jsx import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import ActivityTimeline from './ActivityTimeline.jsx'; // jsdom IntersectionObserver stub beforeEach(() => { global.IntersectionObserver = class { observe() {} unobserve() {} disconnect() {} }; }); const mockAgentActivity = vi.fn(); vi.mock('../../../api', () => ({ agentActivity: (...args) => mockAgentActivity(...args), })); describe('ActivityTimeline', () => { it('항목을 렌더하고 헤더에 total을 표시한다', async () => { mockAgentActivity.mockResolvedValueOnce({ items: [{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }], total: 1, }); render( {}} />); await waitFor(() => expect(screen.getByText('holdings_brief')).toBeInTheDocument()); expect(screen.getByText(/팀 활동/)).toHaveTextContent('1'); }); it('빈 결과면 안내 문구를 표시한다', async () => { mockAgentActivity.mockResolvedValueOnce({ items: [], total: 0 }); render( {}} />); await waitFor(() => expect(screen.getByText(/활동 없음/)).toBeInTheDocument()); }); it('항목 클릭 시 onSelectAgent가 호출된다', async () => { const onSelect = vi.fn(); mockAgentActivity.mockResolvedValueOnce({ items: [{ type: 'task', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', status: 'succeeded' }], total: 1, }); render(); const row = await screen.findByText('signal_check'); fireEvent.click(row.closest('.ao-activity-item')); expect(onSelect).toHaveBeenCalledWith('lotto'); }); }); ``` - [ ] **Step 2: 테스트 실패 확인** Run: `npm run test:run -- src/pages/agent-office/components/ActivityTimeline.test.jsx` Expected: FAIL — import 해결 실패 - [ ] **Step 3: 컴포넌트 구현** `src/pages/agent-office/components/ActivityTimeline.jsx`: ```jsx // src/pages/agent-office/components/ActivityTimeline.jsx import { useState, useRef, useEffect } from 'react'; import { useActivityFeed } from '../hooks/useActivityFeed.js'; import ActivityFilters from './ActivityFilters.jsx'; import ActivityItem from './ActivityItem.jsx'; const DEFAULT_FILTERS = { agent_id: '', type: '', status: '', days: 7 }; export default function ActivityTimeline({ refreshTrigger, onSelectAgent }) { const [filters, setFilters] = useState(DEFAULT_FILTERS); const { items, total, loading, error, hasMore, loadMore, retry } = useActivityFeed(filters, refreshTrigger); const sentinelRef = useRef(null); useEffect(() => { const el = sentinelRef.current; if (!el) return; const io = new IntersectionObserver(entries => { if (entries[0].isIntersecting) loadMore(); }, { rootMargin: '120px' }); io.observe(el); return () => io.disconnect(); }, [loadMore, items.length]); return (
팀 활동 ({total})
{error && (
불러오기 실패: {error}
)} {!error && items.length === 0 && !loading && (
최근 {filters.days}일 활동 없음
)} {items.map((item, i) => ( ))} {loading &&
불러오는 중…
} {hasMore && !loading &&
} {!hasMore && items.length > 0 &&
더 이상 활동 없음
}
); } ``` - [ ] **Step 4: 테스트 통과 확인** Run: `npm run test:run -- src/pages/agent-office/components/ActivityTimeline.test.jsx` Expected: PASS (3 tests) - [ ] **Step 5: Commit** ```bash git add src/pages/agent-office/components/ActivityTimeline.jsx src/pages/agent-office/components/ActivityTimeline.test.jsx git commit -m "feat(agent-office): ActivityTimeline 컨테이너 (필터+무한스크롤)" ``` --- ## Task 6: AgentOffice 우측 패널 배선 **Files:** - Modify: `src/pages/agent-office/AgentOffice.jsx` - [ ] **Step 1: import 추가** `src/pages/agent-office/AgentOffice.jsx`에서 ```js import EmptyDetailPanel from './components/EmptyDetailPanel.jsx'; ``` 바로 아래에 추가: ```js import ActivityTimeline from './components/ActivityTimeline.jsx'; ``` - [ ] **Step 2: null 분기 교체** 같은 파일에서 ```js if (selectedAgent === null) { rightPanel = ; } else if (selectedAgent.startsWith('placeholder-')) { ``` 를 아래로 변경: ```js if (selectedAgent === null) { rightPanel = ( ); } else if (selectedAgent.startsWith('placeholder-')) { ``` - [ ] **Step 3: 전체 테스트 통과 확인 (회귀 없음)** Run: `npm run test:run` Expected: PASS — 신규 테스트 포함 전부 통과, 기존 테스트 회귀 없음 - [ ] **Step 4: Commit** ```bash git add src/pages/agent-office/AgentOffice.jsx git commit -m "feat(agent-office): 우측 기본 패널을 횡단 타임라인으로 교체" ``` --- ## Task 7: baseline CSS **Files:** - Modify: `src/pages/agent-office/AgentOffice.css` (파일 끝에 append) - [ ] **Step 1: 스타일 추가** `src/pages/agent-office/AgentOffice.css` 맨 끝에 추가: ```css /* ── 횡단 오버사이트 타임라인 ── */ .ao-activity { display: flex; flex-direction: column; min-height: 0; } .ao-activity-header { display: flex; align-items: center; } .ao-activity-filters { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 12px; border-bottom: 1px solid #1f2937; } .ao-activity-select { background: #111827; color: #e5e7eb; border: 1px solid #374151; border-radius: 6px; padding: 4px 8px; font-size: 12px; } .ao-activity-select:disabled { opacity: .4; cursor: not-allowed; } .ao-activity-content { flex: 1; overflow-y: auto; min-height: 0; } .ao-activity-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; border-bottom: 1px solid #161b25; cursor: pointer; transition: background .12s; } .ao-activity-item:hover { background: #161b25; } .ao-activity-item.is-highlight { background: rgba(245, 158, 11, .08); } .ao-activity-dot { flex: 0 0 auto; width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; } .ao-activity-body { flex: 1; min-width: 0; } .ao-activity-line { display: flex; align-items: center; gap: 8px; } .ao-activity-agent { font-size: 12px; font-weight: 600; } .ao-activity-badge { font-size: 11px; padding: 1px 7px; border-radius: 10px; white-space: nowrap; } .ao-activity-level { font-size: 12px; } .ao-activity-msg { font-size: 13px; color: #cbd5e1; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ao-activity-meta { flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end; gap: 2px; } .ao-activity-time { font-size: 11px; color: #6b7280; } .ao-activity-dur { font-size: 10px; color: #475569; } .ao-activity-loading, .ao-activity-end { text-align: center; padding: 12px; font-size: 12px; color: #6b7280; } .ao-activity-sentinel { height: 1px; } .ao-activity-error { padding: 12px; font-size: 13px; color: #fca5a5; } .ao-activity-error button { margin-left: 8px; background: #374151; color: #e5e7eb; border: none; border-radius: 6px; padding: 2px 10px; cursor: pointer; } ``` - [ ] **Step 2: 개발 서버에서 시각 확인** Run: `npm run dev` 후 브라우저에서 `http://localhost:3007/agent-office` 접속 → 우측 패널에 타임라인/필터/항목이 보이는지 확인 (에이전트 미선택 상태). Expected: 필터 4종 + 활동 항목 리스트 표시, 항목 클릭 시 SidePanel 전환 - [ ] **Step 3: Commit** ```bash git add src/pages/agent-office/AgentOffice.css git commit -m "style(agent-office): 횡단 타임라인 baseline 스타일" ``` --- ## Task 8: designer 스킬 비주얼 마감 + 최종 검증 **Files:** - Modify: `src/pages/agent-office/AgentOffice.css` (+ 필요 시 컴포넌트 className 미세 조정) - [ ] **Step 1: designer 스킬 적용** `designer` 스킬을 invoke하여 AgentOffice 다크 미감과 일관된 타임라인 비주얼로 마감 (에이전트 색 강조, 상태 뱃지 가독성, 펄스 애니메이션, 밀도/여백). 기능/마크업 구조는 유지하고 스타일만 개선. - [ ] **Step 2: lint + 전체 테스트 + 빌드 검증** ```bash npm run lint npm run test:run npm run build ``` Expected: lint 0 error, 전체 테스트 PASS, build 성공 - [ ] **Step 3: Commit** ```bash git add -A git commit -m "style(agent-office): designer 마감 — 횡단 오버사이트 타임라인" ``` --- ## Self-Review 체크리스트 (작성자 검증 완료) - **Spec coverage:** agentActivity 헬퍼(T1) ✓ / useActivityFeed 필터·페이지네이션·refreshTrigger(T2) ✓ / 상태·level 뱃지 + agent 색 + 클릭(T3) ✓ / 필터 4종 + log시 status 비활성(T4) ✓ / 무한스크롤·empty·error·end(T5) ✓ / AgentOffice 배선(T6) ✓ / 비주얼(T7·T8) ✓ — spec 전 항목 커버. - **Placeholder scan:** 모든 step에 실제 코드/명령/기대출력 포함, TBD 없음. - **Type consistency:** `useActivityFeed(filters, refreshTrigger)` 반환 `{items,total,loading,error,hasMore,loadMore,retry}` — T5에서 동일 사용. `onSelectAgent(agent_id)` 시그니처 T3/T5/T6 일치. `AGENT_META`/`ACTIVE_AGENT_IDS` import 경로 `../constants.js` 일치. `agentActivity({...})` 객체 인자 T1 정의 ↔ T2 호출 일치. - **Known caveat:** jsdom IntersectionObserver 없음 → T5 테스트 상단 stub으로 처리(전역 setup 미수정).