Compare commits
14 Commits
2a89d52634
...
696c2ade15
| Author | SHA1 | Date | |
|---|---|---|---|
| 696c2ade15 | |||
| c024087c94 | |||
| d0bf5fdd50 | |||
| f6b8badd12 | |||
| 833b590afb | |||
| ce980b6eff | |||
| 4dc70a6fc6 | |||
| 57dfb3a3aa | |||
| 1dc5bc3391 | |||
| 76e6fa5e69 | |||
| ae6454ed37 | |||
| 2afcf487a1 | |||
| 0bc2ef3b98 | |||
| 726ed77b31 |
9
.mcp.json
Normal file
9
.mcp.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"co-gahusb": {
|
||||
"type": "http",
|
||||
"url": "https://gahusb.synology.me/api/co/mcp",
|
||||
"headers": { "Authorization": "Bearer ${CO_BUS_KEY}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
136
CLAUDE.md
136
CLAUDE.md
@@ -27,8 +27,18 @@
|
||||
| `/lab/music` | `MusicStudio` | AI 음악 생성 스튜디오 (Sonic Forge) |
|
||||
| `/todo` | `Todo` | 태스크 보드 |
|
||||
| `/insta` | `InstaCards` | 뉴스 키워드 발굴 → AI 카드 10장 자동 생성 → 인스타 업로드 |
|
||||
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅) |
|
||||
| `/agent-office` | `AgentOffice` | AI 에이전트 가상 오피스 (WebSocket + 채팅 + LogTab 5초 폴링 source 뱃지) |
|
||||
| `/portfolio` | `Portfolio` | 개인 포트폴리오 (프로필·경력·프로젝트·자기소개) |
|
||||
| `/saju` | `Saju` | 호령 사주 v2 — 메인/입력 (mobile night-bg + desktop mt-wash 산수화, useViewportMode 1024px 분기) |
|
||||
| `/saju/result?rid=N` | `SajuResult` | 사주 풀이 결과 (4탭: Basic/Chart/Flow/Traits) |
|
||||
| `/saju/today?rid=N` | `Today` | 오늘의 운세 (FortuneRing + 4 ScoreCard + LuckyBox + good_signs/warnings) |
|
||||
| `/saju/compatibility` | `Compatibility` | 궁합 입력 (두 사람 폼) |
|
||||
| `/saju/compatibility/result?cid=N` | `CompatibilityResult` | 궁합 점수 + 요약 + strengths/challenges |
|
||||
| `/saju/me` | `SajuMe` | 마이페이지 placeholder ("곧 만나요" + 4 비활성 카드) |
|
||||
| `/tarot` | `Tarot` | 타로 메인 (agent-office에서 분리, tarot-lab API) |
|
||||
| `/tarot/today` | `TarotTodayCard` | 오늘의 카드 (one_card spread) |
|
||||
| `/tarot/reading` | `TarotReading` | 멀티 카드 스프레드 리딩 (three_card 등) |
|
||||
| `/tarot/history` | `TarotHistory` | 리딩 이력 조회 |
|
||||
|
||||
라우트 정의: `src/routes.jsx` / 라우터 설정: `src/Router.jsx`
|
||||
|
||||
@@ -128,6 +138,23 @@ proxy: {
|
||||
| 포트폴리오 | GET | `/api/profile/public` — personal 서비스 |
|
||||
| 포트폴리오 | POST | `/api/profile/auth` — personal 서비스 |
|
||||
| 포트폴리오 | CRUD | `/api/profile/careers`, `/api/profile/projects`, `/api/profile/skills`, `/api/profile/introductions` — personal 서비스 |
|
||||
| 사주 | POST | `/api/saju/interpret` — body: `{ year, month, day, hour, gender, calendar_type, is_leap_month? }` → reading_id + saju_data + analysis + fortune_scores + lucky + monthly_flow |
|
||||
| 사주 | GET | `/api/saju/readings/:id` — 저장된 사주 조회 (`useSajuReading` hook 사용) |
|
||||
| 사주 | GET | `/api/saju/current-fortune?reading_id=N` — 현재 연도 세운 |
|
||||
| 사주 | PATCH/DELETE | `/api/saju/readings/:id` — 즐겨찾기·메모 / 삭제 |
|
||||
| 사주 | GET | `/api/saju/readings?page=1&size=20&favorite=bool` — 목록 |
|
||||
| 궁합 | POST | `/api/saju/compat/interpret` — body: `{ person_a, person_b }` → compat_id + score + interpretation |
|
||||
| 궁합 | GET | `/api/saju/compat/readings/:id` — 궁합 결과 |
|
||||
| 궁합 | PATCH/DELETE | `/api/saju/compat/readings/:id` |
|
||||
| 타로 | POST | `/api/tarot/interpret` — body: `{ spread_type, category, question, cards }` → interpretation_json (DB 저장 X) |
|
||||
| 타로 | POST | `/api/tarot/readings` — 확정 후 저장 |
|
||||
| 타로 | GET | `/api/tarot/readings?page=1&spread_type=X&category=Y` — 목록 |
|
||||
| 타로 | GET/PATCH/DELETE | `/api/tarot/readings/:id` |
|
||||
| 영상 생성 | POST | `/api/video/generate` — body: `{ provider, prompt, params }` → task_id (sora/veo/kling/seedance) |
|
||||
| 영상 생성 | GET | `/api/video/tasks/:id`, `/api/video/providers` |
|
||||
| 이미지 생성 | POST | `/api/image/generate` — body: `{ provider, prompt, params }` → task_id (gpt_image/nano_banana/flux) |
|
||||
| 이미지 생성 | GET | `/api/image/tasks/:id`, `/api/image/providers` |
|
||||
| 에이전트 로그 | GET | `/api/agent-office/agents/:id/logs?limit=50` — DB agent_logs + 컨테이너 `/logs/recent` 병합 |
|
||||
|
||||
---
|
||||
|
||||
@@ -332,6 +359,102 @@ handleGenerate()
|
||||
|
||||
---
|
||||
|
||||
## 호령 사주 v2 — `/saju` 라우트 트리
|
||||
|
||||
2026-05-27 풀 리디자인 (Phase 1-6, 30 commits). v1 `components/` + `Saju.css` 일괄 삭제 후 신규 디자인 시스템 도입.
|
||||
|
||||
### 디자인 컨셉
|
||||
|
||||
한국 전통 명리학 미학 + 호령 캐릭터. Inter/Roboto 같은 generic AI sans 회피.
|
||||
- **타이포**: Nanum Myeongjo (display, weight 800) + Nanum Gothic (body) + Gowun Batang (fallback serif). `index.html` head에서 preconnect + link 일괄 로드 (기존 Noto Serif KR도 v1 호환 유지)
|
||||
- **컬러**: navy `#1F2A44` dominant + gold `#D4AF37` accent + ivory `#F7F2E8` paper. 화면별 단일 accent (홈=navy, 오늘=gold, 궁합=green, 사주풀이=purple, 마이=gray)
|
||||
- **차별화 요소**: `OrnateFrame` (4 코너 꺽쇠 + double border), `MascotBubble` (paw-bob 2.4s 애니메이션), `OrnamentBloom` (꽃봉오리 SVG), `mt-wash` (산수화 SVG 데스크탑 배경)
|
||||
|
||||
### 디렉토리 구조
|
||||
|
||||
```
|
||||
src/pages/saju/
|
||||
├── _shell/ # 디자인 시스템 + 네비
|
||||
│ ├── tokens.css # CSS 변수 (.saju-v2 scope)
|
||||
│ ├── shell.css # paper-bg/night-bg/mt-wash/screenIn/paw-bob
|
||||
│ ├── useViewportMode.js # 1024px breakpoint hook
|
||||
│ ├── BottomNav.jsx # 모바일 5항목
|
||||
│ ├── DesktopHeader.jsx # 데스크탑 헤더 nav
|
||||
│ ├── Mascot.jsx # 7 variant 매핑 (full/head/upper/greeting/thinking/pointing/happy)
|
||||
│ ├── MascotBubble.jsx # 4 tone (ivory/navy/green/purple)
|
||||
│ ├── OrnateFrame.jsx
|
||||
│ ├── OrnamentBloom.jsx
|
||||
│ ├── TopRibbon.jsx
|
||||
│ ├── TitleBlock.jsx
|
||||
│ ├── PrimaryButton.jsx
|
||||
│ ├── GhostButton.jsx
|
||||
│ ├── InputRow.jsx
|
||||
│ ├── Icons.jsx # 5 nav + IconPaw/Chevron/Sparkle
|
||||
│ └── helpers/
|
||||
│ ├── hexA.js # hex + alpha → rgba
|
||||
│ ├── daeunLabel.js # 나이 → 8 인생 단계 label
|
||||
│ ├── deriveTraits.js # element_scores → 6 성향
|
||||
│ └── colorMap.js # 오행 한자 → CSS var + 한글/한자
|
||||
├── views/ # mobile/desktop 컴포넌트 분리
|
||||
│ ├── home.{mobile,desktop}.jsx
|
||||
│ ├── saju.{mobile,desktop}.jsx # 4탭 (Basic/Chart/Flow/Traits)
|
||||
│ ├── today.{mobile,desktop}.jsx
|
||||
│ └── match.{mobile,desktop}.jsx
|
||||
├── hooks/
|
||||
│ ├── useSajuForm.js # 폼 상태 (year/month/day/hour/gender/calendar_type, handleChange(field,value) 콜백)
|
||||
│ └── useSajuReading.js # rid 기반 { data, loading, error }
|
||||
├── Saju.jsx # /saju 진입 router
|
||||
├── SajuResult.jsx # /saju/result 진입 (Empty/Loading/Error state)
|
||||
├── Today.jsx
|
||||
├── Compatibility.jsx
|
||||
├── CompatibilityResult.jsx
|
||||
└── Me.jsx # placeholder
|
||||
```
|
||||
|
||||
### 응답 schema 매핑 (saju-lab → view)
|
||||
|
||||
`useSajuReading(rid).data` 구조:
|
||||
- `saju_data.{year,month,day,hour}` 각 `{stem, stem_kr, branch, branch_kr, ten_god, fortune}` (4기둥)
|
||||
- `analysis_data.element_scores` (한자 키 `木/火/土/金/水`) — view에서 `wood/fire/earth/metal/water`로 매핑 (`HANJA_TO_ID`)
|
||||
- `analysis_data.day_master_strength.{result, score, reasons}` (신강신약)
|
||||
- `daeun_data` (8개): `{age, start_year, end_year, stem, branch, stem_kr, branch_kr}` — 현재 판정 `start_year ≤ currentYear ≤ end_year`
|
||||
- `interpretation_json.{summary, items: [{key,title,content,evidence}], advice}`
|
||||
- `fortune_scores.{wealth, romance, social, career, overall}` (0-100)
|
||||
- `lucky.{color: string[], number, direction, good_signs: string[], warnings: string[]}`
|
||||
|
||||
### 반응형 전략
|
||||
|
||||
1024px breakpoint로 모바일/데스크탑 컴포넌트 트리 완전 분리:
|
||||
- 모바일 (< 1024): `night-bg` 또는 `paper-bg`, BottomNav 하단 fixed + safe-area
|
||||
- 데스크탑 (≥ 1024): `mt-wash` 산수화 배경, DesktopHeader sticky top, content max-width 1200px
|
||||
|
||||
### 호령 자산
|
||||
|
||||
`public/images/saju/horyung/` 7 PNG (horyung-main/bust/front/greeting/thinking/pointing/happy). Mascot variant API가 매핑:
|
||||
- `full` → horyung-main, `head` → horyung-bust, `upper` → horyung-front, 나머지는 1:1
|
||||
|
||||
---
|
||||
|
||||
## 타로 — `/tarot` 라우트 트리
|
||||
|
||||
agent-office에서 독립 라우트로 분리 (백엔드는 `tarot-lab` 컨테이너).
|
||||
|
||||
| 경로 | 컴포넌트 | 백엔드 |
|
||||
|------|----------|--------|
|
||||
| `/tarot` | `Tarot` | tarot-lab `/api/tarot/interpret` |
|
||||
| `/tarot/today` | `TarotTodayCard` | one_card spread |
|
||||
| `/tarot/reading` | `TarotReading` | three_card spread + 멀티 |
|
||||
| `/tarot/history` | `TarotHistory` | `/api/tarot/readings` 목록 |
|
||||
|
||||
해석 흐름 (interpret ↔ save 분리):
|
||||
1. 사용자가 카드 배치 → `POST /api/tarot/interpret` → Claude 응답 (DB 저장 X)
|
||||
2. 사용자 확정 또는 reroll 결정
|
||||
3. 확정 후 `POST /api/tarot/readings` → DB 저장 + reading_id 반환
|
||||
|
||||
`useTarotReading(id)` + `useTarotShuffle()` hook (`src/pages/tarot/hooks/`).
|
||||
|
||||
---
|
||||
|
||||
## Windows AI 서버 (`C:\Users\jaeoh\Desktop\workspace\music_ai`)
|
||||
|
||||
NAS의 music-lab 컨테이너 대신 Windows PC(RTX 5070 Ti)에서 MusicGen을 로컬 추론하는 별도 서버.
|
||||
@@ -372,3 +495,14 @@ web-ui → POST /api/music/generate (NAS music-lab)
|
||||
```
|
||||
|
||||
Windows 방화벽에서 포트 8765 인바운드 허용 필요.
|
||||
|
||||
---
|
||||
|
||||
## 협업 팀 버스 (co-gahusb) — 이 세션의 역할: **FE**
|
||||
|
||||
이 세션은 프론트엔드(FE) 역할이다. co-gahusb MCP 툴로 다른 세션(BE/AI/Producer)과 협업한다.
|
||||
- **소유권**: 이 세션은 `web-ui` repo만 쓴다(BE=web-backend, AI=web-ai).
|
||||
- **공유 리소스 변경 전 반드시 `acquire_lock(resource, "FE")`**: 대상 = `nas-deploy`, `stock-db-schema`, `lotto-db-schema`, `memory-mirror`, `nginx-conf`, `compose`. 점유 중이면 대기, 긴 작업은 `heartbeat_lock`, 끝나면 `release_lock`.
|
||||
- **모든 툴 호출에 `role="FE"`** (또는 `from_role`/`created_by`에 FE).
|
||||
- **수신**: `/loop`로 주기적으로 `read_inbox("FE", after_id=<last>)` + `list_tasks(assignee_role="FE")` 확인.
|
||||
- 키 `CO_BUS_KEY`는 환경변수로 주입(커밋 금지).
|
||||
|
||||
765
docs/superpowers/plans/2026-06-11-agent-oversight-timeline.md
Normal file
765
docs/superpowers/plans/2026-06-11-agent-oversight-timeline.md
Normal file
@@ -0,0 +1,765 @@
|
||||
# 에이전트 횡단 오버사이트 타임라인 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(<ActivityItem item={{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }} onSelectAgent={() => {}} />);
|
||||
expect(screen.getByText('holdings_brief')).toBeInTheDocument();
|
||||
expect(screen.getByText(/완료/)).toBeInTheDocument();
|
||||
expect(screen.getByText('2s')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('log 항목은 level 아이콘을 렌더한다', () => {
|
||||
render(<ActivityItem item={{ type: 'log', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', level: 'error' }} onSelectAgent={() => {}} />);
|
||||
expect(screen.getByText('signal_check')).toBeInTheDocument();
|
||||
expect(screen.getByText('❌')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('클릭 시 onSelectAgent(agent_id)를 호출한다', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<ActivityItem item={{ type: 'task', agent_id: 'insta', task_id: 'c', message: '발급', created_at: '2026-06-10T23:00:00Z', status: 'pending' }} onSelectAgent={onSelect} />);
|
||||
fireEvent.click(screen.getByText('발급').closest('.ao-activity-item'));
|
||||
expect(onSelect).toHaveBeenCalledWith('insta');
|
||||
});
|
||||
|
||||
it('미지정 agent_id는 id를 그대로 표시한다', () => {
|
||||
render(<ActivityItem item={{ type: 'log', agent_id: 'unknown', task_id: 'd', message: 'x', created_at: '2026-06-10T23:00:00Z', level: 'info' }} onSelectAgent={() => {}} />);
|
||||
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 (
|
||||
<div
|
||||
className={`ao-activity-item ${isTask ? 'is-task' : 'is-log'} ${highlight ? 'is-highlight' : ''}`}
|
||||
onClick={() => onSelectAgent(item.agent_id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="ao-activity-dot" style={{ background: color }} aria-hidden="true" />
|
||||
<div className="ao-activity-body">
|
||||
<div className="ao-activity-line">
|
||||
<span className="ao-activity-agent" style={{ color }}>{name}</span>
|
||||
{isTask
|
||||
? <span className="ao-activity-badge" style={{ background: status.bg, color: status.fg }}>{status.label}</span>
|
||||
: <span className={`ao-activity-level ${level.cls}`}>{level.icon}</span>}
|
||||
</div>
|
||||
<div className="ao-activity-msg">{item.message}</div>
|
||||
</div>
|
||||
<div className="ao-activity-meta">
|
||||
<span className="ao-activity-time">{formatTime(item.created_at)}</span>
|
||||
{isTask && item.duration_seconds != null && (
|
||||
<span className="ao-activity-dur">{item.duration_seconds}s</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **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(<ActivityFilters filters={{ ...base, type: 'log' }} onChange={() => {}} />);
|
||||
expect(screen.getByLabelText('상태 필터')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('기간 변경 시 onChange가 days와 함께 호출된다', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ActivityFilters filters={base} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText('기간 필터'), { target: { value: '30' } });
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ days: 30 }));
|
||||
});
|
||||
|
||||
it('type을 log로 바꾸면 status를 비운다', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ActivityFilters filters={{ ...base, status: 'succeeded' }} onChange={onChange} />);
|
||||
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 (
|
||||
<div className="ao-activity-filters">
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="에이전트 필터"
|
||||
value={filters.agent_id || ''}
|
||||
onChange={e => set({ agent_id: e.target.value })}
|
||||
>
|
||||
<option value="">모든 에이전트</option>
|
||||
{ACTIVE_AGENT_IDS.map(id => (
|
||||
<option key={id} value={id}>{AGENT_META[id]?.displayName || id}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="타입 필터"
|
||||
value={filters.type || ''}
|
||||
onChange={e => set(e.target.value === 'log' ? { type: 'log', status: '' } : { type: e.target.value })}
|
||||
>
|
||||
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="상태 필터"
|
||||
value={filters.status || ''}
|
||||
disabled={statusDisabled}
|
||||
onChange={e => set({ status: e.target.value })}
|
||||
>
|
||||
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="기간 필터"
|
||||
value={filters.days}
|
||||
onChange={e => set({ days: Number(e.target.value) })}
|
||||
>
|
||||
{DAYS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **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(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||
await waitFor(() => expect(screen.getByText('holdings_brief')).toBeInTheDocument());
|
||||
expect(screen.getByText(/팀 활동/)).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('빈 결과면 안내 문구를 표시한다', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({ items: [], total: 0 });
|
||||
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||
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(<ActivityTimeline refreshTrigger={0} onSelectAgent={onSelect} />);
|
||||
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 (
|
||||
<div className="ao-sidepanel ao-activity">
|
||||
<div className="ao-sidepanel-header ao-activity-header">
|
||||
<div className="ao-sidepanel-name">팀 활동 ({total})</div>
|
||||
</div>
|
||||
<ActivityFilters filters={filters} onChange={setFilters} />
|
||||
<div className="ao-sidepanel-content ao-activity-content">
|
||||
{error && (
|
||||
<div className="ao-activity-error">
|
||||
불러오기 실패: {error}
|
||||
<button type="button" onClick={retry}>재시도</button>
|
||||
</div>
|
||||
)}
|
||||
{!error && items.length === 0 && !loading && (
|
||||
<div className="ao-empty">최근 {filters.days}일 활동 없음</div>
|
||||
)}
|
||||
{items.map((item, i) => (
|
||||
<ActivityItem
|
||||
key={`${item.type}-${item.task_id}-${item.created_at}-${i}`}
|
||||
item={item}
|
||||
onSelectAgent={onSelectAgent}
|
||||
/>
|
||||
))}
|
||||
{loading && <div className="ao-activity-loading">불러오는 중…</div>}
|
||||
{hasMore && !loading && <div ref={sentinelRef} className="ao-activity-sentinel" />}
|
||||
{!hasMore && items.length > 0 && <div className="ao-activity-end">더 이상 활동 없음</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **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 = <EmptyDetailPanel variant="initial" />;
|
||||
} else if (selectedAgent.startsWith('placeholder-')) {
|
||||
```
|
||||
를 아래로 변경:
|
||||
```js
|
||||
if (selectedAgent === null) {
|
||||
rightPanel = (
|
||||
<ActivityTimeline
|
||||
refreshTrigger={refreshTrigger}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
/>
|
||||
);
|
||||
} 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 미수정).
|
||||
@@ -0,0 +1,107 @@
|
||||
# 에이전트 횡단 오버사이트 타임라인 — 설계
|
||||
|
||||
작성일: 2026-06-11
|
||||
대상 repo: `web-ui` (프론트엔드)
|
||||
연관 백엔드: ✅ 완료 (`GET /api/agent-office/activity` 필터 지원, main `2c2828c`)
|
||||
|
||||
## 배경 / 목적
|
||||
|
||||
3개 자율 에이전트(stock 보유종목·insta 발급·lotto 진화)가 모두 도는 상태에서
|
||||
"팀이 무엇을·언제·왜 했나"를 **한 화면에서** 보는 에이전트 횡단 오버사이트(CEO 가시화) 기능.
|
||||
|
||||
현재 web-ui에는 `/lotto/evolver` 탭의 lotto 전용 `LottoActivityTimeline`만 존재.
|
||||
통합 `/activity`(전 에이전트 대상)를 소비하는 횡단 뷰가 없다.
|
||||
|
||||
## 백엔드 응답 shape (라이브 검증 완료)
|
||||
|
||||
```
|
||||
GET /api/agent-office/activity?agent_id=&type=task|log&status=&days=&limit=&offset=
|
||||
→ { items: [...], total: N }
|
||||
```
|
||||
|
||||
- **task item**: `{ type:'task', agent_id, task_id, message, created_at, task_type, status, completed_at, duration_seconds }`
|
||||
- **log item**: `{ type:'log', agent_id, task_id, message, created_at, level }`
|
||||
- `status`는 task 전용(`type=log`에 주면 무시). injection 안전(? 바인딩 + 브랜치 선택).
|
||||
|
||||
검증 메모:
|
||||
- 무필터 `total`이 65,599건 → **기본 `days=7` 필터 필수**(task 기준 110건으로 감소).
|
||||
- `requires_approval` 필드는 **존재하지 않음** → `status:'pending'`을 진행/대기 강조로 처리.
|
||||
- `agent_id` 값이 `AGENT_META` 키(stock/music/insta/realestate/lotto)와 일치 → 색상/이미지 재사용.
|
||||
|
||||
## 아키텍처
|
||||
|
||||
AgentOffice는 단일 화면(TopBar + 3×3 AgentGrid + 우측 패널) 구조.
|
||||
우측 패널은 `selectedAgent` 상태로 분기:
|
||||
- `null` → (기존) `EmptyDetailPanel variant="initial"` → **`ActivityTimeline`으로 교체**
|
||||
- `placeholder-N` → `EmptyDetailPanel variant="placeholder"` (유지)
|
||||
- active agent id → `SidePanel` (유지)
|
||||
|
||||
즉 **에이전트 미선택 시 기본 우측 패널이 횡단 타임라인**이 되고, 그리드와 항상 동시 노출.
|
||||
항목/그리드 클릭으로 해당 에이전트 SidePanel로 전환.
|
||||
|
||||
## 신규/변경 파일
|
||||
|
||||
| 파일 | 역할 |
|
||||
|------|------|
|
||||
| `src/api.js` | `agentActivity({agent_id,type,status,days,limit,offset})` 추가 — 빈 값 제외 쿼리스트링 빌드 + GET `/api/agent-office/activity` |
|
||||
| `src/pages/agent-office/AgentOffice.jsx` | `selectedAgent===null` 분기를 `EmptyDetailPanel` → `ActivityTimeline`(props: `refreshTrigger`, `onSelectAgent`)로 교체 |
|
||||
| `src/pages/agent-office/hooks/useActivityFeed.js` | items/offset/total/hasMore/loading/error/filters 상태 관리 |
|
||||
| `src/pages/agent-office/components/ActivityTimeline.jsx` | 컨테이너: 헤더 + `ActivityFilters` + 리스트 + 무한스크롤 sentinel + 상태(loading/empty/error/end) |
|
||||
| `src/pages/agent-office/components/ActivityFilters.jsx` | 필터 4종(agent 색칩 / type / status / days). `type==='log'`일 때 status 비활성 |
|
||||
| `src/pages/agent-office/components/ActivityItem.jsx` | 한 행: agent 색·이미지 + message + 상태/level 뱃지 + 상대시간 + duration. 클릭 → `onSelectAgent(agent_id)` |
|
||||
| `src/pages/agent-office/AgentOffice.css` | 타임라인/필터/항목 스타일 (designer 스킬로 마감) |
|
||||
|
||||
## 데이터 흐름
|
||||
|
||||
```
|
||||
AgentOffice (selectedAgent===null)
|
||||
└─ <ActivityTimeline refreshTrigger={refreshTrigger} onSelectAgent={handleSelectAgent} />
|
||||
└─ useActivityFeed(filters)
|
||||
• mount / 필터 변경 → offset=0 fetch → items 교체
|
||||
• loadMore (sentinel 교차) → offset += limit → items append
|
||||
• refreshTrigger 변경 → offset=0 재조회 → items 교체 (WS 실시간 연동)
|
||||
└─ ActivityItem onClick → onSelectAgent(agent_id) → SidePanel로 전환
|
||||
```
|
||||
|
||||
`handleSelectAgent`는 기존 콜백 재사용(선택 + `clearNotifications`).
|
||||
|
||||
## 필터 기본값
|
||||
|
||||
`days=7`, `type=all`, `status=all`, `agent=all`, `limit=30`(페이지당).
|
||||
|
||||
## 상태 / 비주얼 매핑
|
||||
|
||||
- task `status`: `succeeded` → 초록 ✓ / `failed` → 빨강 ✗ / `pending`·`working` → 앰버 펄스 ⏳(강조)
|
||||
- log `level`: `error` → ❌ / `warning` → ⚠️ / `info` → ·
|
||||
- agent 색상: `AGENT_META[agent_id].color`, 미지정 agent → 회색 `#6b7280`
|
||||
- `offset >= total` → "더 이상 활동 없음" / 무한스크롤은 IntersectionObserver
|
||||
|
||||
## 상태 처리(엣지)
|
||||
|
||||
- 첫 페이지 로딩 → 스피너/스켈레톤
|
||||
- 빈 결과 → "최근 N일 활동 없음"
|
||||
- fetch 실패 → 인라인 에러 + 재시도 버튼
|
||||
- 리스트 끝 → end-of-list 표시, sentinel 관찰 중단
|
||||
|
||||
## 테스트 (TDD, vitest + RTL — 기존 패턴 따름)
|
||||
|
||||
- `useActivityFeed`: 필터 변경 시 offset 리셋 + items 교체 / loadMore append / refreshTrigger 재조회 / `hasMore = items.length < total` 계산 (api mock)
|
||||
- `ActivityItem`: task vs log 렌더 분기, status/level 뱃지 클래스, 클릭 시 `onSelectAgent(agent_id)` 호출
|
||||
- `ActivityFilters`: `type==='log'`일 때 status select 비활성, 필터 변경 시 onChange 호출
|
||||
|
||||
## 비범위 (YAGNI)
|
||||
|
||||
- 별도 라우트(`/agent-office/activity`) 미생성 — 기본 우측 패널 통합으로 충분
|
||||
- 기존 `getActivityFeed(limit, offset)` 헬퍼는 lotto evolver 등에서 사용 여부 확인 후 유지(신규 `agentActivity`와 공존, 무리한 통합 안 함)
|
||||
- `LottoActivityTimeline`(`kind/ts/payload` shape)은 다른 엔드포인트 소비 → 건드리지 않음
|
||||
- CSV/export, 검색어 필터 등 부가기능 제외
|
||||
|
||||
## 구현 순서
|
||||
|
||||
1. `agentActivity` api 헬퍼 추가
|
||||
2. `useActivityFeed` 훅 (TDD)
|
||||
3. `ActivityItem` / `ActivityFilters` (TDD)
|
||||
4. `ActivityTimeline` 컨테이너 조립
|
||||
5. `AgentOffice.jsx` 분기 교체
|
||||
6. designer 스킬로 CSS 마감
|
||||
7. lint + 테스트 + 빌드 검증
|
||||
11
src/api.js
11
src/api.js
@@ -594,6 +594,17 @@ export const sendAgentCommand = (agent, action, params={}) => apiPost('/api/age
|
||||
export const approveAgentTask = (agent, task_id, approved, feedback='') => apiPost('/api/agent-office/approve', { agent, task_id, approved, feedback });
|
||||
export const getAgentStates = () => apiGet('/api/agent-office/states');
|
||||
export const getActivityFeed = (limit=50, offset=0) => apiGet(`/api/agent-office/activity?limit=${limit}&offset=${offset}`);
|
||||
// 횡단 오버사이트 타임라인용 — 빈 값은 쿼리에서 제외(백엔드 브랜치 선택).
|
||||
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()}`);
|
||||
};
|
||||
export const getAgentTokenUsage = (id, days=1) => apiGet(`/api/agent-office/agents/${id}/token-usage?days=${days}`);
|
||||
|
||||
// --- Lotto Briefing ---
|
||||
|
||||
@@ -447,3 +447,102 @@
|
||||
padding-bottom: env(safe-area-inset-bottom, 16px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 횡단 오버사이트 타임라인 (mission-control activity log) ── */
|
||||
.ao-activity { display: flex; flex-direction: column; min-height: 0; height: 100%; }
|
||||
|
||||
/* 헤더 — 섹션 타이틀 톤 (퍼플 액센트 + 트래킹) */
|
||||
.ao-activity-header { align-items: center; }
|
||||
.ao-activity-header .ao-sidepanel-name {
|
||||
color: #8b5cf6; letter-spacing: 0.6px; text-transform: uppercase; font-size: 13px;
|
||||
}
|
||||
|
||||
/* 필터 바 — 다크 슬레이트 셀렉트 */
|
||||
.ao-activity-filters {
|
||||
display: flex; flex-wrap: wrap; gap: 6px;
|
||||
padding: 8px 12px; border-bottom: 1px solid #333;
|
||||
background: rgba(15, 23, 42, 0.6);
|
||||
}
|
||||
.ao-activity-select {
|
||||
background: #1e293b; color: #e2e8f0;
|
||||
border: 1px solid #334155; border-radius: 4px;
|
||||
padding: 4px 8px; font-family: inherit; font-size: 11px; cursor: pointer;
|
||||
transition: border-color .12s, box-shadow .12s;
|
||||
}
|
||||
.ao-activity-select:hover { border-color: #475569; }
|
||||
.ao-activity-select:focus { outline: none; border-color: #8b5cf6; box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.3); }
|
||||
.ao-activity-select:disabled { opacity: .35; cursor: not-allowed; }
|
||||
|
||||
.ao-activity-content { flex: 1; overflow-y: auto; min-height: 0; padding: 0; }
|
||||
|
||||
/* 활동 행 — 타임라인 스파인(수직 레일) + 신호등 도트 */
|
||||
.ao-activity-item {
|
||||
position: relative;
|
||||
display: flex; align-items: flex-start; gap: 10px;
|
||||
padding: 10px 12px; border-bottom: 1px solid #1a2233;
|
||||
cursor: pointer; transition: background .12s;
|
||||
animation: ao-activity-in .18s ease-out both;
|
||||
}
|
||||
.ao-activity-item::before {
|
||||
content: ''; position: absolute; left: 16px; top: 0; bottom: 0;
|
||||
width: 1px; background: #1e293b; z-index: 0;
|
||||
}
|
||||
.ao-activity-item:hover { background: #161b2e; }
|
||||
.ao-activity-item:focus-visible { outline: none; background: #161b2e; box-shadow: inset 2px 0 0 #8b5cf6; }
|
||||
|
||||
/* 진행/대기 강조 — 앰버 인셋 + 도트 펄스 */
|
||||
.ao-activity-item.is-highlight { background: rgba(245, 158, 11, 0.06); box-shadow: inset 2px 0 0 #f59e0b; }
|
||||
.ao-activity-item.is-highlight .ao-activity-dot { animation: ao-pulse 1.6s ease-in-out infinite; }
|
||||
|
||||
/* 에이전트 색 = 신호등. 링(#111)으로 뒤 레일을 끊어 점처럼 떠 보이게 */
|
||||
.ao-activity-dot {
|
||||
position: relative; z-index: 1; flex: 0 0 auto;
|
||||
width: 9px; height: 9px; border-radius: 50%; margin-top: 4px;
|
||||
box-shadow: 0 0 0 3px #111;
|
||||
}
|
||||
|
||||
.ao-activity-body { flex: 1; min-width: 0; }
|
||||
.ao-activity-line { display: flex; align-items: center; gap: 8px; }
|
||||
.ao-activity-agent { font-size: 11px; font-weight: bold; letter-spacing: 0.3px; }
|
||||
|
||||
/* 상태 뱃지 — 터미널 톤(각진 모서리, 모노) */
|
||||
.ao-activity-badge {
|
||||
font-size: 10px; font-weight: bold; letter-spacing: 0.3px;
|
||||
padding: 1px 7px; border-radius: 4px; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 로그 레벨 표식 */
|
||||
.ao-activity-level { font-size: 12px; line-height: 1; }
|
||||
.ao-activity-level.level-info { color: #475569; font-size: 15px; font-weight: bold; }
|
||||
.ao-activity-level.level-warning,
|
||||
.ao-activity-level.level-error { font-size: 12px; }
|
||||
|
||||
.ao-activity-msg {
|
||||
font-size: 12.5px; color: #cbd5e1; margin-top: 3px;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.ao-activity-item.is-log .ao-activity-msg { color: #94a3b8; }
|
||||
|
||||
.ao-activity-meta { flex: 0 0 auto; display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
|
||||
.ao-activity-time { font-size: 10px; color: #64748b; }
|
||||
.ao-activity-dur { font-size: 10px; color: #475569; }
|
||||
|
||||
.ao-activity-loading,
|
||||
.ao-activity-end {
|
||||
text-align: center; padding: 12px; font-size: 10px;
|
||||
color: #475569; letter-spacing: 0.6px; text-transform: uppercase;
|
||||
}
|
||||
.ao-activity-sentinel { height: 1px; }
|
||||
|
||||
.ao-activity-error { padding: 12px; font-size: 12px; color: #fca5a5; }
|
||||
.ao-activity-error button {
|
||||
margin-left: 8px; background: #2a2a4e; color: #8b5cf6;
|
||||
border: 1px solid #4c1d95; border-radius: 4px;
|
||||
padding: 3px 10px; font-family: inherit; font-size: 11px; cursor: pointer;
|
||||
}
|
||||
.ao-activity-error button:hover { background: #3a3a5e; }
|
||||
|
||||
@keyframes ao-activity-in {
|
||||
from { opacity: 0; transform: translateY(2px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ 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 ActivityTimeline from './components/ActivityTimeline.jsx';
|
||||
import './AgentOffice.css';
|
||||
|
||||
export default function AgentOffice() {
|
||||
@@ -36,7 +37,12 @@ export default function AgentOffice() {
|
||||
|
||||
let rightPanel;
|
||||
if (selectedAgent === null) {
|
||||
rightPanel = <EmptyDetailPanel variant="initial" />;
|
||||
rightPanel = (
|
||||
<ActivityTimeline
|
||||
refreshTrigger={refreshTrigger}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
/>
|
||||
);
|
||||
} else if (selectedAgent.startsWith('placeholder-')) {
|
||||
rightPanel = <EmptyDetailPanel variant="placeholder" onClose={handleClose} />;
|
||||
} else {
|
||||
|
||||
64
src/pages/agent-office/components/ActivityFilters.jsx
Normal file
64
src/pages/agent-office/components/ActivityFilters.jsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// 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 (
|
||||
<div className="ao-activity-filters">
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="에이전트 필터"
|
||||
value={filters.agent_id || ''}
|
||||
onChange={e => set({ agent_id: e.target.value })}
|
||||
>
|
||||
<option value="">모든 에이전트</option>
|
||||
{ACTIVE_AGENT_IDS.map(id => (
|
||||
<option key={id} value={id}>{AGENT_META[id]?.displayName || id}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="타입 필터"
|
||||
value={filters.type || ''}
|
||||
onChange={e => set(e.target.value === 'log' ? { type: 'log', status: '' } : { type: e.target.value })}
|
||||
>
|
||||
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="상태 필터"
|
||||
value={filters.status || ''}
|
||||
disabled={statusDisabled}
|
||||
onChange={e => set({ status: e.target.value })}
|
||||
>
|
||||
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
<select
|
||||
className="ao-activity-select"
|
||||
aria-label="기간 필터"
|
||||
value={filters.days}
|
||||
onChange={e => set({ days: Number(e.target.value) })}
|
||||
>
|
||||
{DAYS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/pages/agent-office/components/ActivityFilters.test.jsx
Normal file
26
src/pages/agent-office/components/ActivityFilters.test.jsx
Normal file
@@ -0,0 +1,26 @@
|
||||
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(<ActivityFilters filters={{ ...base, type: 'log' }} onChange={() => {}} />);
|
||||
expect(screen.getByLabelText('상태 필터')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('기간 변경 시 onChange가 days와 함께 호출된다', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ActivityFilters filters={base} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText('기간 필터'), { target: { value: '30' } });
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ days: 30 }));
|
||||
});
|
||||
|
||||
it('type을 log로 바꾸면 status를 비운다', () => {
|
||||
const onChange = vi.fn();
|
||||
render(<ActivityFilters filters={{ ...base, status: 'succeeded' }} onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText('타입 필터'), { target: { value: 'log' } });
|
||||
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ type: 'log', status: '' }));
|
||||
});
|
||||
});
|
||||
60
src/pages/agent-office/components/ActivityItem.jsx
Normal file
60
src/pages/agent-office/components/ActivityItem.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
// 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 (
|
||||
<div
|
||||
className={`ao-activity-item ${isTask ? 'is-task' : 'is-log'} ${highlight ? 'is-highlight' : ''}`}
|
||||
onClick={() => onSelectAgent(item.agent_id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
<span className="ao-activity-dot" style={{ background: color }} aria-hidden="true" />
|
||||
<div className="ao-activity-body">
|
||||
<div className="ao-activity-line">
|
||||
<span className="ao-activity-agent" style={{ color }}>{name}</span>
|
||||
{isTask
|
||||
? <span className="ao-activity-badge" style={{ background: status.bg, color: status.fg }}>{status.label}</span>
|
||||
: <span className={`ao-activity-level ${level.cls}`}>{level.icon}</span>}
|
||||
</div>
|
||||
<div className="ao-activity-msg">{item.message}</div>
|
||||
</div>
|
||||
<div className="ao-activity-meta">
|
||||
<span className="ao-activity-time">{formatTime(item.created_at)}</span>
|
||||
{isTask && item.duration_seconds != null && (
|
||||
<span className="ao-activity-dur">{item.duration_seconds}s</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/pages/agent-office/components/ActivityItem.test.jsx
Normal file
30
src/pages/agent-office/components/ActivityItem.test.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
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(<ActivityItem item={{ type: 'task', agent_id: 'stock', task_id: 'a', message: 'holdings_brief', created_at: '2026-06-10T23:30:00Z', status: 'succeeded', duration_seconds: 2 }} onSelectAgent={() => {}} />);
|
||||
expect(screen.getByText('holdings_brief')).toBeInTheDocument();
|
||||
expect(screen.getByText(/완료/)).toBeInTheDocument();
|
||||
expect(screen.getByText('2s')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('log 항목은 level 아이콘을 렌더한다', () => {
|
||||
render(<ActivityItem item={{ type: 'log', agent_id: 'lotto', task_id: 'b', message: 'signal_check', created_at: '2026-06-10T23:15:00Z', level: 'error' }} onSelectAgent={() => {}} />);
|
||||
expect(screen.getByText('signal_check')).toBeInTheDocument();
|
||||
expect(screen.getByText('❌')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('클릭 시 onSelectAgent(agent_id)를 호출한다', () => {
|
||||
const onSelect = vi.fn();
|
||||
render(<ActivityItem item={{ type: 'task', agent_id: 'insta', task_id: 'c', message: '발급', created_at: '2026-06-10T23:00:00Z', status: 'pending' }} onSelectAgent={onSelect} />);
|
||||
fireEvent.click(screen.getByText('발급').closest('.ao-activity-item'));
|
||||
expect(onSelect).toHaveBeenCalledWith('insta');
|
||||
});
|
||||
|
||||
it('미지정 agent_id는 id를 그대로 표시한다', () => {
|
||||
render(<ActivityItem item={{ type: 'log', agent_id: 'unknown', task_id: 'd', message: 'x', created_at: '2026-06-10T23:00:00Z', level: 'info' }} onSelectAgent={() => {}} />);
|
||||
expect(screen.getByText('unknown')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
53
src/pages/agent-office/components/ActivityTimeline.jsx
Normal file
53
src/pages/agent-office/components/ActivityTimeline.jsx
Normal file
@@ -0,0 +1,53 @@
|
||||
// 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 (
|
||||
<div className="ao-sidepanel ao-activity">
|
||||
<div className="ao-sidepanel-header ao-activity-header">
|
||||
<div className="ao-sidepanel-name">팀 활동 ({total})</div>
|
||||
</div>
|
||||
<ActivityFilters filters={filters} onChange={setFilters} />
|
||||
<div className="ao-sidepanel-content ao-activity-content">
|
||||
{error && (
|
||||
<div className="ao-activity-error">
|
||||
불러오기 실패: {error}
|
||||
<button type="button" onClick={retry}>재시도</button>
|
||||
</div>
|
||||
)}
|
||||
{!error && items.length === 0 && !loading && (
|
||||
<div className="ao-empty">최근 {filters.days}일 활동 없음</div>
|
||||
)}
|
||||
{items.map((item, i) => (
|
||||
<ActivityItem
|
||||
key={`${item.type}-${item.task_id}-${item.created_at}-${i}`}
|
||||
item={item}
|
||||
onSelectAgent={onSelectAgent}
|
||||
/>
|
||||
))}
|
||||
{loading && <div className="ao-activity-loading">불러오는 중…</div>}
|
||||
{hasMore && !loading && <div ref={sentinelRef} className="ao-activity-sentinel" />}
|
||||
{!hasMore && items.length > 0 && <div className="ao-activity-end">더 이상 활동 없음</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/pages/agent-office/components/ActivityTimeline.test.jsx
Normal file
45
src/pages/agent-office/components/ActivityTimeline.test.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
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(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||
await waitFor(() => expect(screen.getByText('holdings_brief')).toBeInTheDocument());
|
||||
expect(screen.getByText(/팀 활동/)).toHaveTextContent('1');
|
||||
});
|
||||
|
||||
it('빈 결과면 안내 문구를 표시한다', async () => {
|
||||
mockAgentActivity.mockResolvedValueOnce({ items: [], total: 0 });
|
||||
render(<ActivityTimeline refreshTrigger={0} onSelectAgent={() => {}} />);
|
||||
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(<ActivityTimeline refreshTrigger={0} onSelectAgent={onSelect} />);
|
||||
const row = await screen.findByText('signal_check');
|
||||
fireEvent.click(row.closest('.ao-activity-item'));
|
||||
expect(onSelect).toHaveBeenCalledWith('lotto');
|
||||
});
|
||||
});
|
||||
64
src/pages/agent-office/hooks/useActivityFeed.js
Normal file
64
src/pages/agent-office/hooks/useActivityFeed.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// 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 requestIdRef = useRef(0);
|
||||
const filtersRef = useRef(filters);
|
||||
filtersRef.current = filters;
|
||||
|
||||
const filterKey = JSON.stringify(filters);
|
||||
|
||||
const fetchPage = useCallback(async (offset, replace) => {
|
||||
// append(loadMore)만 중복 방지. replace(필터/refresh 재조회)는 항상 우선 진행.
|
||||
if (!replace && loadingRef.current) return;
|
||||
const reqId = ++requestIdRef.current;
|
||||
loadingRef.current = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await agentActivity({ ...filtersRef.current, limit: PAGE_SIZE, offset });
|
||||
if (reqId !== requestIdRef.current) return; // 더 새로운 요청이 시작됨 → stale 응답 무시
|
||||
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) {
|
||||
if (reqId !== requestIdRef.current) return;
|
||||
setError(e.message || '불러오기 실패');
|
||||
} finally {
|
||||
if (reqId === requestIdRef.current) {
|
||||
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 };
|
||||
}
|
||||
73
src/pages/agent-office/hooks/useActivityFeed.test.js
Normal file
73
src/pages/agent-office/hooks/useActivityFeed.test.js
Normal file
@@ -0,0 +1,73 @@
|
||||
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);
|
||||
});
|
||||
|
||||
it('필터 변경 중이던 이전(stale) 요청 응답은 무시된다', async () => {
|
||||
let resolveFirst;
|
||||
const firstPromise = new Promise(r => { resolveFirst = r; });
|
||||
mockAgentActivity
|
||||
.mockReturnValueOnce(firstPromise) // 초기 요청 — 느리게 resolve
|
||||
.mockResolvedValueOnce({ items: [item({ task_id: 'fresh', agent_id: 'insta' })], total: 1 });
|
||||
const { result, rerender } = renderHook(({ f }) => useActivityFeed(f, 0), { initialProps: { f: { days: 7 } } });
|
||||
rerender({ f: { days: 7, agent_id: 'insta' } }); // 첫 요청 resolve 전에 필터 변경
|
||||
await waitFor(() => expect(result.current.items[0]?.task_id).toBe('fresh'));
|
||||
await act(async () => { resolveFirst({ items: [item({ task_id: 'stale' })], total: 99 }); });
|
||||
expect(result.current.items[0].task_id).toBe('fresh'); // stale이 덮어쓰지 않음
|
||||
expect(result.current.total).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user