Skip to content

Commit c4cab08

Browse files
feat(dashboard): agent contributions panel — per-agent git attribution (#3276)
Implements #3269 dashboard component — agent commits per-agent git identity. New component: AgentContributionsPanel with KPIs, horizontal bar chart, agent list. 6 new Vitest tests. Loading/empty states. Mock data pending backend (#3269 backend).
1 parent 0fd866c commit c4cab08

3 files changed

Lines changed: 298 additions & 0 deletions

File tree

Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
/**
2+
* AgentContributionsPanel.tsx — Per-agent git contribution stats.
3+
*
4+
* Shows commit count, lines changed, PRs opened, and contribution bars
5+
* for each agent. Part of issue #3269: agent git identity tracking.
6+
*
7+
* Mock data until backend provides per-agent git identity (#3269 backend).
8+
*/
9+
10+
import { BarChart, Bar, XAxis, YAxis, Tooltip, CartesianGrid, Cell } from 'recharts';
11+
import { ChartFrame } from '../shared/ChartFrame';
12+
import { formatCompact } from '../../utils/formatNumber';
13+
import { GitBranch, GitCommit, GitPullRequest, Users } from 'lucide-react';
14+
15+
export interface AgentContribution {
16+
agent: string;
17+
commits: number;
18+
additions: number;
19+
deletions: number;
20+
prs: number;
21+
role: string;
22+
}
23+
24+
export interface AgentContributionsPanelProps {
25+
data?: AgentContribution[];
26+
loading?: boolean;
27+
className?: string;
28+
}
29+
30+
const AGENT_COLORS: Record<string, string> = {
31+
'Daedalus': 'var(--color-accent-cyan)',
32+
'Hephaestus': 'var(--color-accent-purple)',
33+
'Argus': 'var(--color-success)',
34+
'Athena': 'var(--color-warning)',
35+
'Scribe': 'var(--color-info)',
36+
'Hermes': 'var(--color-warning)',
37+
'Orpheus': 'var(--color-accent-cyan)',
38+
'Themis': 'var(--color-danger)',
39+
other: 'var(--color-text-muted)',
40+
};
41+
42+
const MOCK_DATA: AgentContribution[] = [
43+
{ agent: 'Hephaestus', commits: 87, additions: 12840, deletions: 3210, prs: 12, role: 'Backend' },
44+
{ agent: 'Daedalus', commits: 64, additions: 9650, deletions: 1820, prs: 9, role: 'Frontend' },
45+
{ agent: 'Argus', commits: 31, additions: 2100, deletions: 890, prs: 5, role: 'Review' },
46+
{ agent: 'Scribe', commits: 22, additions: 4320, deletions: 420, prs: 4, role: 'Docs' },
47+
{ agent: 'Hermes', commits: 15, additions: 1800, deletions: 600, prs: 3, role: 'DevOps' },
48+
{ agent: 'Athena', commits: 8, additions: 920, deletions: 180, prs: 2, role: 'PM' },
49+
];
50+
51+
function CustomTooltip({ active, payload }: {
52+
active?: boolean;
53+
payload?: Array<{ payload: AgentContribution }>;
54+
}) {
55+
if (!active || !payload?.length) return null;
56+
const point = payload[0].payload;
57+
return (
58+
<div className="rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface)] p-3 shadow-xl">
59+
<p className="mb-1 text-sm font-medium text-[var(--color-text-primary)]">
60+
{point.agent}
61+
<span className="ml-2 text-xs text-[var(--color-text-muted)]">{point.role}</span>
62+
</p>
63+
<div className="mt-2 space-y-1 text-xs">
64+
<div className="flex justify-between gap-6">
65+
<span className="text-[var(--color-text-muted)]">Commits</span>
66+
<span className="font-mono font-medium text-[var(--color-text-primary)]">{point.commits}</span>
67+
</div>
68+
<div className="flex justify-between gap-6">
69+
<span className="text-[var(--color-success)]">+{formatCompact(point.additions)}</span>
70+
<span className="text-[var(--color-danger)]">-{formatCompact(point.deletions)}</span>
71+
</div>
72+
<div className="flex justify-between gap-6">
73+
<span className="text-[var(--color-text-muted)]">PRs</span>
74+
<span className="font-mono font-medium text-[var(--color-text-primary)]">{point.prs}</span>
75+
</div>
76+
</div>
77+
</div>
78+
);
79+
}
80+
81+
export function AgentContributionsPanel({ data, loading = false, className = '' }: AgentContributionsPanelProps) {
82+
const contributions = data ?? MOCK_DATA;
83+
84+
const totalCommits = contributions.reduce((s, a) => s + a.commits, 0);
85+
const totalAdditions = contributions.reduce((s, a) => s + a.additions, 0);
86+
const totalDeletions = contributions.reduce((s, a) => s + a.deletions, 0);
87+
const totalPRs = contributions.reduce((s, a) => s + a.prs, 0);
88+
89+
if (loading) {
90+
return (
91+
<section
92+
className={`rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-strong)] p-5 ${className}`}
93+
aria-label="Agent contributions loading"
94+
>
95+
<h3 className="mb-4 text-lg font-medium text-[var(--color-text-primary)]">
96+
Agent Contributions
97+
</h3>
98+
<div className="flex h-64 items-center justify-center">
99+
<div className="h-4 w-4 animate-spin rounded-full border-2 border-[var(--color-accent-cyan)] border-t-transparent" />
100+
</div>
101+
</section>
102+
);
103+
}
104+
105+
if (contributions.length === 0) {
106+
return (
107+
<section
108+
className={`rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-strong)] p-5 ${className}`}
109+
aria-label="Agent contributions"
110+
>
111+
<h3 className="mb-4 text-lg font-medium text-[var(--color-text-primary)]">
112+
Agent Contributions
113+
</h3>
114+
<p className="py-12 text-center text-sm text-[var(--color-text-muted)]">
115+
No agent contribution data available yet.
116+
</p>
117+
</section>
118+
);
119+
}
120+
121+
return (
122+
<section
123+
className={`rounded-lg border border-[var(--color-border-strong)] bg-[var(--color-surface-strong)] p-5 ${className}`}
124+
aria-label="Agent contributions panel"
125+
>
126+
<div className="mb-4 flex items-center justify-between">
127+
<h3 className="text-lg font-medium text-[var(--color-text-primary)]">
128+
<Users className="mr-2 inline h-5 w-5 text-[var(--color-accent-cyan)]" />
129+
Agent Contributions
130+
</h3>
131+
<span className="text-xs text-[var(--color-text-muted)]">
132+
Per-agent git attribution
133+
</span>
134+
</div>
135+
136+
{/* Summary KPIs */}
137+
<div className="mb-5 grid grid-cols-2 gap-3 sm:grid-cols-4">
138+
<div className="rounded-md border border-[var(--color-border-strong)] bg-[var(--color-surface)] p-3">
139+
<div className="flex items-center gap-1.5 text-xs text-[var(--color-text-muted)]">
140+
<GitCommit className="h-3 w-3" />
141+
Commits
142+
</div>
143+
<div className="mt-1 text-lg font-bold font-mono text-[var(--color-text-primary)]">
144+
{formatCompact(totalCommits)}
145+
</div>
146+
</div>
147+
<div className="rounded-md border border-[var(--color-border-strong)] bg-[var(--color-surface)] p-3">
148+
<div className="flex items-center gap-1.5 text-xs text-[var(--color-text-muted)]">
149+
<GitBranch className="h-3 w-3" />
150+
Lines Changed
151+
</div>
152+
<div className="mt-1 text-lg font-bold font-mono text-[var(--color-text-primary)]">
153+
<span className="text-[var(--color-success)]">+{formatCompact(totalAdditions)}</span>
154+
<span className="mx-1 text-[var(--color-text-muted)]">/</span>
155+
<span className="text-[var(--color-danger)]">-{formatCompact(totalDeletions)}</span>
156+
</div>
157+
</div>
158+
<div className="rounded-md border border-[var(--color-border-strong)] bg-[var(--color-surface)] p-3">
159+
<div className="flex items-center gap-1.5 text-xs text-[var(--color-text-muted)]">
160+
<GitPullRequest className="h-3 w-3" />
161+
PRs
162+
</div>
163+
<div className="mt-1 text-lg font-bold font-mono text-[var(--color-text-primary)]">
164+
{totalPRs}
165+
</div>
166+
</div>
167+
<div className="rounded-md border border-[var(--color-border-strong)] bg-[var(--color-surface)] p-3">
168+
<div className="text-xs text-[var(--color-text-muted)]">
169+
<Users className="inline h-3 w-3 mr-1" />
170+
Active Agents
171+
</div>
172+
<div className="mt-1 text-lg font-bold font-mono text-[var(--color-text-primary)]">
173+
{contributions.length}
174+
</div>
175+
</div>
176+
</div>
177+
178+
{/* Commit chart — horizontal bar */}
179+
<ChartFrame className="h-56 min-w-0" label="Agent commits chart loading">
180+
{({ width, height }) => (
181+
<BarChart width={width} height={height} data={contributions} layout="vertical" margin={{ left: 10 }}>
182+
<CartesianGrid strokeDasharray="3 3" stroke="var(--color-void-lighter)" horizontal={false} />
183+
<XAxis
184+
type="number"
185+
tick={{ fill: 'var(--color-text-muted)', fontSize: 11 }}
186+
stroke="var(--color-void-lighter)"
187+
/>
188+
<YAxis
189+
type="category"
190+
dataKey="agent"
191+
tick={{ fill: 'var(--color-text-muted)', fontSize: 11 }}
192+
stroke="var(--color-void-lighter)"
193+
width={90}
194+
/>
195+
<Tooltip content={<CustomTooltip />} />
196+
<Bar dataKey="commits" radius={[0, 4, 4, 0]} animationDuration={500}>
197+
{contributions.map((entry) => (
198+
<Cell
199+
key={entry.agent}
200+
fill={AGENT_COLORS[entry.agent] ?? AGENT_COLORS.other}
201+
/>
202+
))}
203+
</Bar>
204+
</BarChart>
205+
)}
206+
</ChartFrame>
207+
208+
{/* Agent list */}
209+
<div className="mt-4 space-y-2">
210+
{contributions.map((agent) => {
211+
const commitPct = totalCommits > 0 ? (agent.commits / totalCommits) * 100 : 0;
212+
return (
213+
<div key={agent.agent} className="flex items-center gap-3">
214+
<div
215+
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
216+
style={{ backgroundColor: AGENT_COLORS[agent.agent] ?? AGENT_COLORS.other }}
217+
/>
218+
<div className="min-w-0 flex-1">
219+
<div className="flex items-center justify-between">
220+
<span className="text-sm font-medium text-[var(--color-text-primary)]">
221+
{agent.agent}
222+
<span className="ml-1.5 text-xs text-[var(--color-text-muted)]">({agent.role})</span>
223+
</span>
224+
<span className="text-xs font-mono text-[var(--color-text-muted)]">
225+
{agent.commits} commits · {agent.prs} PRs
226+
</span>
227+
</div>
228+
<div className="mt-1 h-1.5 w-full rounded-full bg-[var(--color-void-light)]">
229+
<div
230+
className="h-1.5 rounded-full transition-all duration-500"
231+
style={{
232+
width: `${commitPct}%`,
233+
backgroundColor: AGENT_COLORS[agent.agent] ?? AGENT_COLORS.other,
234+
}}
235+
/>
236+
</div>
237+
</div>
238+
</div>
239+
);
240+
})}
241+
</div>
242+
</section>
243+
);
244+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* AgentContributionsPanel.test.tsx — Tests for agent contributions panel.
3+
*/
4+
5+
import { describe, it, expect } from 'vitest';
6+
import { render, screen } from '@testing-library/react';
7+
import { AgentContributionsPanel } from '../AgentContributionsPanel';
8+
9+
describe('AgentContributionsPanel', () => {
10+
it('renders with mock data by default', () => {
11+
const { container } = render(<AgentContributionsPanel />);
12+
expect(container.querySelector('section')).toBeTruthy();
13+
expect(screen.getByText('Agent Contributions')).toBeTruthy();
14+
});
15+
16+
it('renders empty state when data is empty array', () => {
17+
render(<AgentContributionsPanel data={[]} />);
18+
expect(screen.getByText(/No agent contribution data available yet/)).toBeTruthy();
19+
});
20+
21+
it('renders loading state', () => {
22+
render(<AgentContributionsPanel loading />);
23+
expect(screen.getByText('Agent Contributions')).toBeTruthy();
24+
});
25+
26+
it('renders with custom data', () => {
27+
const data = [
28+
{ agent: 'Daedalus', commits: 10, additions: 500, deletions: 100, prs: 2, role: 'Frontend' },
29+
{ agent: 'Hephaestus', commits: 20, additions: 1000, deletions: 300, prs: 3, role: 'Backend' },
30+
];
31+
render(<AgentContributionsPanel data={data} />);
32+
expect(screen.getByText('Agent Contributions')).toBeTruthy();
33+
// Check summary KPIs
34+
expect(screen.getByText('30')).toBeTruthy();
35+
expect(screen.getByText('5')).toBeTruthy();
36+
});
37+
38+
it('has correct aria-label', () => {
39+
render(<AgentContributionsPanel />);
40+
expect(screen.getByLabelText('Agent contributions panel')).toBeTruthy();
41+
});
42+
43+
it('shows agent names in the list', () => {
44+
const data = [
45+
{ agent: 'TestAgent', commits: 5, additions: 200, deletions: 50, prs: 1, role: 'Test' },
46+
];
47+
render(<AgentContributionsPanel data={data} />);
48+
expect(screen.getByText(/TestAgent/)).toBeTruthy();
49+
});
50+
});

dashboard/src/pages/AnalyticsPage.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { formatDateShort } from '../utils/formatDate';
3131
import type { AnalyticsSummary, RateLimitAnalyticsResponse } from '../types';
3232
import { RateLimitChart } from '../components/analytics/RateLimitChart';
3333
import { RateLimitForecastCard } from '../components/analytics/RateLimitForecastCard';
34+
import { AgentContributionsPanel } from '../components/analytics/AgentContributionsPanel';
3435

3536
const MODEL_COLORS: Record<string, string> = {
3637
'claude-sonnet-4.6': 'var(--color-accent-cyan)',
@@ -480,6 +481,9 @@ export default function AnalyticsPage() {
480481
<RateLimitForecastCard forecast={rateLimitData.forecast} />
481482
</div>
482483
)}
484+
485+
{/* Agent Contributions (#3269) */}
486+
<AgentContributionsPanel />
483487
</div>
484488
);
485489
}

0 commit comments

Comments
 (0)