Skip to content

Commit d79343b

Browse files
committed
feat: add activity feed v1
1 parent 7536cd0 commit d79343b

7 files changed

Lines changed: 698 additions & 0 deletions

File tree

client/activity-feed.js

Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
1+
class ActivityFeedPanel {
2+
constructor(orchestrator) {
3+
this.orchestrator = orchestrator;
4+
this.events = [];
5+
this.eventIds = new Set();
6+
this.socket = null;
7+
this.serverUrl = window.location.origin;
8+
this.filterText = '';
9+
this.groupFilter = 'all';
10+
this.paused = false;
11+
this.unseenCount = 0;
12+
13+
this._dismissPointerHandler = null;
14+
this._dismissKeyHandler = null;
15+
this._socketHandler = null;
16+
}
17+
18+
isOpen() {
19+
const modal = document.getElementById('activity-feed-modal');
20+
return !!modal && !modal.classList.contains('hidden');
21+
}
22+
23+
toggle() {
24+
if (this.isOpen()) this.close();
25+
else this.show();
26+
}
27+
28+
async show() {
29+
this.renderModal();
30+
this.unseenCount = 0;
31+
this.updateButtonBadge();
32+
await this.refresh();
33+
}
34+
35+
close() {
36+
this.cleanupDismissHandlers();
37+
const modal = document.getElementById('activity-feed-modal');
38+
if (modal) modal.classList.add('hidden');
39+
}
40+
41+
attachDismissHandlers(modal) {
42+
this._dismissPointerHandler = (event) => {
43+
if (!modal) return;
44+
if (modal.querySelector('.modal-content')?.contains(event.target)) return;
45+
this.close();
46+
};
47+
48+
this._dismissKeyHandler = (event) => {
49+
if (event.key !== 'Escape') return;
50+
if (this.isOpen()) {
51+
event.preventDefault();
52+
this.close();
53+
}
54+
};
55+
56+
document.addEventListener('pointerdown', this._dismissPointerHandler, true);
57+
document.addEventListener('keydown', this._dismissKeyHandler, true);
58+
}
59+
60+
cleanupDismissHandlers() {
61+
if (this._dismissPointerHandler) {
62+
document.removeEventListener('pointerdown', this._dismissPointerHandler, true);
63+
this._dismissPointerHandler = null;
64+
}
65+
if (this._dismissKeyHandler) {
66+
document.removeEventListener('keydown', this._dismissKeyHandler, true);
67+
this._dismissKeyHandler = null;
68+
}
69+
}
70+
71+
renderModal() {
72+
this.cleanupDismissHandlers();
73+
74+
let modal = document.getElementById('activity-feed-modal');
75+
if (!modal) {
76+
modal = document.createElement('div');
77+
modal.id = 'activity-feed-modal';
78+
modal.className = 'modal activity-feed-modal hidden';
79+
modal.innerHTML = `
80+
<div class="modal-content activity-feed-content">
81+
<div class="browser-header">
82+
<h2>Activity</h2>
83+
<button class="close-btn" onclick="window.activityFeedPanel.close()">✕</button>
84+
</div>
85+
86+
<div class="browser-toolbar">
87+
<div class="browser-toolbar-row">
88+
<div class="search-container">
89+
<input type="text" id="activity-filter-text" placeholder="Filter (kind, sessionId, repo, PR...)">
90+
</div>
91+
92+
<select id="activity-group-filter" title="Category">
93+
<option value="all">All</option>
94+
<option value="agent">Agent</option>
95+
<option value="session">Session</option>
96+
<option value="server">Server</option>
97+
<option value="git">Git</option>
98+
<option value="pr">PR</option>
99+
<option value="tests">Tests</option>
100+
<option value="build">Build</option>
101+
</select>
102+
103+
<label class="option-toggle" title="Pause live updates while keeping history visible">
104+
<input type="checkbox" id="activity-pause-live">
105+
Pause live
106+
</label>
107+
108+
<button class="btn-secondary" id="activity-refresh-btn">Refresh</button>
109+
<button class="btn-secondary" id="activity-clear-btn" title="Clear only this UI list (does not delete server history)">Clear</button>
110+
</div>
111+
</div>
112+
113+
<div class="browser-stats" id="activity-stats">Loading...</div>
114+
<div class="activity-list" id="activity-list"></div>
115+
</div>
116+
`;
117+
document.body.appendChild(modal);
118+
}
119+
120+
window.activityFeedPanel = this;
121+
modal.classList.remove('hidden');
122+
this.attachDismissHandlers(modal);
123+
124+
const textInput = document.getElementById('activity-filter-text');
125+
if (textInput) {
126+
textInput.value = this.filterText;
127+
textInput.oninput = () => {
128+
this.filterText = String(textInput.value || '');
129+
this.renderList();
130+
};
131+
}
132+
133+
const groupSelect = document.getElementById('activity-group-filter');
134+
if (groupSelect) {
135+
groupSelect.value = this.groupFilter;
136+
groupSelect.onchange = () => {
137+
this.groupFilter = String(groupSelect.value || 'all');
138+
this.renderList();
139+
};
140+
}
141+
142+
const pauseCb = document.getElementById('activity-pause-live');
143+
if (pauseCb) {
144+
pauseCb.checked = !!this.paused;
145+
pauseCb.onchange = () => {
146+
this.paused = !!pauseCb.checked;
147+
this.renderStats();
148+
};
149+
}
150+
151+
document.getElementById('activity-refresh-btn')?.addEventListener('click', () => this.refresh());
152+
document.getElementById('activity-clear-btn')?.addEventListener('click', () => {
153+
this.events = [];
154+
this.eventIds.clear();
155+
this.renderList();
156+
});
157+
}
158+
159+
onSocketConnected(socket) {
160+
this.socket = socket || null;
161+
if (!this.socket) return;
162+
163+
if (this._socketHandler) {
164+
try {
165+
this.socket.off('activity-event', this._socketHandler);
166+
} catch {
167+
// ignore
168+
}
169+
}
170+
171+
this._socketHandler = (event) => {
172+
if (!event || !event.id) return;
173+
if (this.eventIds.has(event.id)) return;
174+
175+
this.eventIds.add(event.id);
176+
this.events.unshift(event);
177+
if (this.events.length > 500) {
178+
const removed = this.events.splice(500);
179+
for (const ev of removed) this.eventIds.delete(ev?.id);
180+
}
181+
182+
if (!this.isOpen()) {
183+
this.unseenCount += 1;
184+
this.updateButtonBadge();
185+
return;
186+
}
187+
188+
if (this.paused) {
189+
this.renderStats();
190+
return;
191+
}
192+
193+
this.renderList();
194+
};
195+
196+
this.socket.on('activity-event', this._socketHandler);
197+
}
198+
199+
async refresh() {
200+
try {
201+
const limit = 200;
202+
const resp = await fetch(`${this.serverUrl}/api/activity?limit=${limit}`);
203+
const data = await resp.json();
204+
if (!data?.ok) throw new Error(data?.error || 'Failed to fetch activity');
205+
206+
const next = Array.isArray(data.events) ? data.events : [];
207+
this.events = [];
208+
this.eventIds.clear();
209+
for (const ev of next) {
210+
if (!ev || !ev.id) continue;
211+
if (this.eventIds.has(ev.id)) continue;
212+
this.eventIds.add(ev.id);
213+
this.events.push(ev);
214+
}
215+
216+
this.renderList();
217+
} catch (error) {
218+
this.renderError(error?.message || String(error));
219+
}
220+
}
221+
222+
renderError(message) {
223+
const list = document.getElementById('activity-list');
224+
if (list) {
225+
list.innerHTML = `<div class="activity-empty">Failed to load activity: ${this.escapeHtml(message)}</div>`;
226+
}
227+
const stats = document.getElementById('activity-stats');
228+
if (stats) {
229+
stats.textContent = 'Failed to load';
230+
}
231+
}
232+
233+
renderStats() {
234+
const stats = document.getElementById('activity-stats');
235+
if (!stats) return;
236+
const total = this.events.length;
237+
const paused = this.paused ? ' (paused)' : '';
238+
stats.textContent = `${total} events${paused}`;
239+
}
240+
241+
renderList() {
242+
this.renderStats();
243+
const list = document.getElementById('activity-list');
244+
if (!list) return;
245+
246+
const items = this.getFilteredEvents();
247+
if (items.length === 0) {
248+
list.innerHTML = `<div class="activity-empty">No activity events.</div>`;
249+
return;
250+
}
251+
252+
const html = items.slice(0, 500).map(ev => this.renderEvent(ev)).join('\n');
253+
list.innerHTML = html;
254+
}
255+
256+
getFilteredEvents() {
257+
const text = String(this.filterText || '').trim().toLowerCase();
258+
const group = String(this.groupFilter || 'all');
259+
return this.events.filter((ev) => {
260+
const kind = String(ev?.kind || '');
261+
const groupOk = group === 'all' ? true : this.getGroup(kind) === group;
262+
if (!groupOk) return false;
263+
if (!text) return true;
264+
const hay = `${kind} ${JSON.stringify(ev?.data || {})}`.toLowerCase();
265+
return hay.includes(text);
266+
});
267+
}
268+
269+
getGroup(kind) {
270+
const k = String(kind || '');
271+
const head = k.split('.')[0] || '';
272+
return head || 'other';
273+
}
274+
275+
renderEvent(ev) {
276+
const ts = Number(ev?.ts) || 0;
277+
const when = ts ? new Date(ts) : null;
278+
const time = when ? when.toLocaleString() : 'unknown time';
279+
const kind = String(ev?.kind || 'unknown');
280+
const summary = this.escapeHtml(this.summarizeEvent(ev));
281+
const dataJson = this.escapeHtml(this.compactJson(ev?.data));
282+
const group = this.getGroup(kind);
283+
284+
return `
285+
<div class="activity-event">
286+
<div class="activity-meta">
287+
<span class="activity-time">${this.escapeHtml(time)}</span>
288+
<span class="activity-kind activity-kind-${this.escapeHtml(group)}">${this.escapeHtml(kind)}</span>
289+
</div>
290+
<div class="activity-summary">${summary}</div>
291+
<div class="activity-data">${dataJson}</div>
292+
</div>
293+
`;
294+
}
295+
296+
summarizeEvent(ev) {
297+
const kind = String(ev?.kind || '');
298+
const data = ev?.data && typeof ev.data === 'object' ? ev.data : {};
299+
300+
if (kind === 'server.started') return `Server started (port ${data.port || 'unknown'})`;
301+
if (kind === 'git.pull') return `Git pull (${data.ok ? 'ok' : 'failed'})`;
302+
if (kind === 'pr.merge') return `PR merge ${data.ok ? 'ok' : 'failed'} (${data.repo || 'repo'} #${data.prNumber || '?'})`;
303+
if (kind === 'pr.review') return `PR review ${data.ok ? 'ok' : 'failed'} (${data.repo || 'repo'} #${data.prNumber || '?'})`;
304+
if (kind.startsWith('tests.')) return `${kind} (${data.ok ? 'ok' : 'running'})`;
305+
if (kind.startsWith('agent.start')) return `Start agent (${data.agent || 'agent'}) for ${data.sessionId || 'session'}`;
306+
if (kind.startsWith('session.')) return `${kind} (${data.sessionId || ''})`.trim();
307+
if (kind.startsWith('server.control')) return `${kind} (${data.action || ''})`.trim();
308+
if (kind.startsWith('build.production')) return `${kind} (${data.ok ? 'ok' : data.error ? 'failed' : 'running'})`;
309+
310+
if (data.sessionId) return `${kind} (${data.sessionId})`;
311+
return kind;
312+
}
313+
314+
compactJson(data) {
315+
try {
316+
if (!data || typeof data !== 'object') return String(data || '');
317+
const s = JSON.stringify(data);
318+
if (s.length <= 280) return s;
319+
return `${s.slice(0, 260)}…`;
320+
} catch {
321+
return '';
322+
}
323+
}
324+
325+
updateButtonBadge() {
326+
const btn = document.getElementById('activity-btn');
327+
if (!btn) return;
328+
const base = '📰 Activity';
329+
btn.textContent = this.unseenCount > 0 ? `${base} (${this.unseenCount})` : base;
330+
}
331+
332+
escapeHtml(str) {
333+
return String(str || '')
334+
.replaceAll('&', '&amp;')
335+
.replaceAll('<', '&lt;')
336+
.replaceAll('>', '&gt;')
337+
.replaceAll('"', '&quot;')
338+
.replaceAll("'", '&#039;');
339+
}
340+
}
341+
342+
window.ActivityFeedPanel = ActivityFeedPanel;

client/app.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,15 @@ class ClaudeOrchestrator {
256256
this.showQueuePanel();
257257
});
258258

259+
// Activity feed (recent system events)
260+
if (typeof ActivityFeedPanel !== 'undefined') {
261+
this.activityFeedPanel = new ActivityFeedPanel(this);
262+
document.getElementById('activity-btn')?.addEventListener('click', () => {
263+
this.activityFeedPanel.toggle();
264+
});
265+
console.log('Activity feed initialized');
266+
}
267+
259268
document.getElementById('diff-viewer-open')?.addEventListener('click', () => {
260269
this.openDiffViewerFromCurrentContext();
261270
});
@@ -322,6 +331,9 @@ class ClaudeOrchestrator {
322331

323332
// Connect to server
324333
await this.connectToServer();
334+
335+
// Hook panels that depend on socket events
336+
this.activityFeedPanel?.onSocketConnected?.(this.socket);
325337

326338
// Load user settings from server
327339
await this.loadUserSettings();

client/index.html

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ <h1>Agent Orchestrator</h1>
6060
<button id="queue-btn" class="header-btn" title="Review inbox (tiers/queue)">
6161
📥 Queue
6262
</button>
63+
<button id="activity-btn" class="header-btn" title="Activity feed (agents, PRs, tests)">
64+
📰 Activity
65+
</button>
6366
<button id="diff-viewer-open" class="header-btn" title="Open Advanced Diff Viewer for the active PR/branch (falls back to home)">
6467
🔍 Diff
6568
</button>
@@ -462,6 +465,7 @@ <h3>Notifications</h3>
462465
<script src="workspace-tab-manager.js"></script>
463466
<script src="voice-control.js"></script>
464467
<script src="conversation-browser.js"></script>
468+
<script src="activity-feed.js"></script>
465469
<script src="app.js"></script>
466470
<!-- Focus Overlay -->
467471
<div id="focus-overlay" class="focus-overlay">

0 commit comments

Comments
 (0)