diff --git a/src/api.js b/src/api.js index d6970e9..75b5c7c 100644 --- a/src/api.js +++ b/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 --- diff --git a/src/pages/agent-office/AgentOffice.css b/src/pages/agent-office/AgentOffice.css index 8cfa59a..3e14c9b 100644 --- a/src/pages/agent-office/AgentOffice.css +++ b/src/pages/agent-office/AgentOffice.css @@ -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; } +} diff --git a/src/pages/agent-office/AgentOffice.jsx b/src/pages/agent-office/AgentOffice.jsx index e9e24d4..551ead8 100644 --- a/src/pages/agent-office/AgentOffice.jsx +++ b/src/pages/agent-office/AgentOffice.jsx @@ -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 = ; + rightPanel = ( + + ); } else if (selectedAgent.startsWith('placeholder-')) { rightPanel = ; } else { diff --git a/src/pages/agent-office/components/ActivityFilters.jsx b/src/pages/agent-office/components/ActivityFilters.jsx new file mode 100644 index 0000000..0038237 --- /dev/null +++ b/src/pages/agent-office/components/ActivityFilters.jsx @@ -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 ( +
+ + + + +
+ ); +} diff --git a/src/pages/agent-office/components/ActivityFilters.test.jsx b/src/pages/agent-office/components/ActivityFilters.test.jsx new file mode 100644 index 0000000..797eb06 --- /dev/null +++ b/src/pages/agent-office/components/ActivityFilters.test.jsx @@ -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( {}} />); + 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: '' })); + }); +}); diff --git a/src/pages/agent-office/components/ActivityItem.jsx b/src/pages/agent-office/components/ActivityItem.jsx new file mode 100644 index 0000000..9d74252 --- /dev/null +++ b/src/pages/agent-office/components/ActivityItem.jsx @@ -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 ( +
onSelectAgent(item.agent_id)} + role="button" + tabIndex={0} + > +
+ ); +} diff --git a/src/pages/agent-office/components/ActivityItem.test.jsx b/src/pages/agent-office/components/ActivityItem.test.jsx new file mode 100644 index 0000000..48f495b --- /dev/null +++ b/src/pages/agent-office/components/ActivityItem.test.jsx @@ -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( {}} />); + 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(); + }); +}); diff --git a/src/pages/agent-office/components/ActivityTimeline.jsx b/src/pages/agent-office/components/ActivityTimeline.jsx new file mode 100644 index 0000000..1ebd1db --- /dev/null +++ b/src/pages/agent-office/components/ActivityTimeline.jsx @@ -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 ( +
+
+
팀 활동 ({total})
+
+ +
+ {error && ( +
+ 불러오기 실패: {error} + +
+ )} + {!error && items.length === 0 && !loading && ( +
최근 {filters.days}일 활동 없음
+ )} + {items.map((item, i) => ( + + ))} + {loading &&
불러오는 중…
} + {hasMore && !loading &&
} + {!hasMore && items.length > 0 &&
더 이상 활동 없음
} +
+
+ ); +} diff --git a/src/pages/agent-office/components/ActivityTimeline.test.jsx b/src/pages/agent-office/components/ActivityTimeline.test.jsx new file mode 100644 index 0000000..ba72d5a --- /dev/null +++ b/src/pages/agent-office/components/ActivityTimeline.test.jsx @@ -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( {}} />); + 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'); + }); +}); diff --git a/src/pages/agent-office/hooks/useActivityFeed.js b/src/pages/agent-office/hooks/useActivityFeed.js new file mode 100644 index 0000000..ed2e44f --- /dev/null +++ b/src/pages/agent-office/hooks/useActivityFeed.js @@ -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 }; +} diff --git a/src/pages/agent-office/hooks/useActivityFeed.test.js b/src/pages/agent-office/hooks/useActivityFeed.test.js new file mode 100644 index 0000000..e2fd6bd --- /dev/null +++ b/src/pages/agent-office/hooks/useActivityFeed.test.js @@ -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); + }); +});