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 (
+
+
+
+
+ {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');
+ });
+});