Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/frontend/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import { GitHubIssuesPilotView } from './components/GitHubIssuesPilotView';
import { PatternsPilotView } from './components/PatternsPilotView';
import { AnalyticsPilotView } from './components/AnalyticsPilotView';
import { ProductivityPilotView } from './components/ProductivityPilotView';
import { MergeAnalyticsPilotView } from './components/MergeAnalyticsPilotView';
import { useSettingsStore, loadSettings, loadProfiles, saveSettings } from './stores/settings-store';
import { useClaudeProfileStore } from './stores/claude-profile-store';
import { useTerminalStore, restoreTerminalSessions } from './stores/terminal-store';
Expand Down Expand Up @@ -1153,6 +1154,9 @@ export function App() {
{activeView === 'merge-analytics' && (activeProjectId || selectedProjectId) && (
<MergeAnalyticsDashboard projectId={activeProjectId || selectedProjectId!} />
)}
{activeView === 'merge-analytics-next' && (activeProjectId || selectedProjectId) && (
<MergeAnalyticsPilotView projectId={(activeProjectId || selectedProjectId)!} />
)}
{activeView === 'feedback' && (activeProjectId || selectedProjectId) && (
<FeedbackDashboard projectId={activeProjectId || selectedProjectId!} />
)}
Expand Down
125 changes: 125 additions & 0 deletions apps/frontend/src/renderer/__tests__/merge-analytics-ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Tests for the MergeAnalytics / ConflictPattern → AnalyticsDashboard mappers.
*/

import { describe, expect, it } from 'vitest';
import {
buildMergeBarLists,
buildMergeKpis,
buildMergeSections,
formatDuration,
shortenPath,
} from '../lib/merge-analytics-ui';
import type { ConflictPattern, MergeAnalytics } from '../../shared/types';

function makeAnalytics(overrides: Partial<MergeAnalytics> = {}): MergeAnalytics {
return {
total_operations: 84,
total_files_merged: 312,
total_conflicts: 12,
successful_operations: 80,
failed_operations: 4,
total_ai_calls: 47,
total_tokens_used: 1_240_000,
average_duration_seconds: 125,
success_rate: 0.95,
auto_merge_rate: 0.72,
conflict_patterns: [],
...overrides,
};
}

function makePattern(overrides: Partial<ConflictPattern> = {}): ConflictPattern {
return {
file_path: 'apps/frontend/src/renderer/App.tsx',
location: 'L120',
occurrence_count: 6,
severity: 'high',
tasks_involved: ['001', '002'],
last_seen: '2026-05-22T00:00:00Z',
...overrides,
};
}

describe('shortenPath', () => {
it('keeps short paths and truncates deep ones to the trailing segments', () => {
expect(shortenPath('App.tsx')).toBe('App.tsx');
expect(shortenPath('a/b')).toBe('a/b');
expect(shortenPath('apps/frontend/src/App.tsx')).toBe('…/src/App.tsx');
});
});

describe('formatDuration', () => {
it('renders seconds and minute+second forms', () => {
expect(formatDuration(45)).toBe('45s');
expect(formatDuration(125)).toBe('2m 05s');
expect(formatDuration(-1)).toBe('0s');
});
});

describe('buildMergeKpis', () => {
const labels = {
operations: 'ops',
operationsSub: 'all',
successRate: 'success',
autoMerge: 'auto',
conflicts: 'conflicts',
conflictsSub: 'total',
};

it('maps the analytics onto four tiles, converting 0-1 rates to percents', () => {
const kpis = buildMergeKpis(makeAnalytics(), labels);
expect(kpis.map((k) => k.value)).toEqual(['84', '95%', '72%', '12']);
expect(kpis[1].tone).toBe('good');
expect(kpis[3].tone).toBe('warn');
});

it('warns a low success rate and drops the conflict tone at zero', () => {
const kpis = buildMergeKpis(
makeAnalytics({ success_rate: 0.6, total_conflicts: 0 }),
labels,
);
expect(kpis[1].tone).toBe('warn');
expect(kpis[3].tone).toBeUndefined();
});
});

describe('buildMergeBarLists', () => {
it('maps conflict patterns to a distribution with severity-toned bars', () => {
const lists = buildMergeBarLists(
[
makePattern({ occurrence_count: 6, severity: 'high' }),
makePattern({ file_path: 'x/y/z/util.ts', occurrence_count: 3, severity: 'medium' }),
],
'Top conflicts',
'aria',
);
expect(lists).toHaveLength(1);
expect(lists[0].items).toEqual([
{ label: '…/renderer/App.tsx', value: 6, tone: 'bad' },
{ label: '…/z/util.ts', value: 3, tone: 'warn' },
]);
});

it('returns no card when there are no patterns', () => {
expect(buildMergeBarLists([], 't', 'a')).toEqual([]);
});
});

describe('buildMergeSections', () => {
it('builds outcome + efficiency cards', () => {
const sections = buildMergeSections(makeAnalytics(), {
outcomesTitle: 'Outcomes',
successful: 'Successful',
failed: 'Failed',
filesMerged: 'Files merged',
efficiencyTitle: 'Efficiency',
avgDuration: 'Avg duration',
aiCalls: 'AI calls',
tokens: 'Tokens',
});
expect(sections[0].rows[0]).toEqual({ label: 'Successful', value: '80' });
expect(sections[1].rows[0]).toEqual({ label: 'Avg duration', value: '2m 05s' });
expect(sections[1].rows[2]).toEqual({ label: 'Tokens', value: '1.2M' });
});
});
148 changes: 148 additions & 0 deletions apps/frontend/src/renderer/components/MergeAnalyticsPilotView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* Merge Analytics pilot on the shared design system (U5 B2).
*
* Renders `libs/ui`'s AnalyticsDashboard from the existing `getMergeSummary`
* + `getConflictPatterns` IPC, mapped with the pure `merge-analytics-ui`
* helpers. The conflict patterns render as a BarList distribution. Read-only
* — the legacy Merge Analytics view keeps its export flow. Reachable via the
* "Merge Analytics (new UI)" sidebar item.
*/

import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AnalyticsDashboard } from '@auto-code/ui';
import type { UiBarListCard, UiKpi, UiMetaSection } from '@auto-code/ui';
import type { ConflictPattern, MergeAnalytics } from '../../shared/types';
import {
buildMergeBarLists,
buildMergeKpis,
buildMergeSections,
} from '../lib/merge-analytics-ui';

interface MergeAnalyticsPilotViewProps {
projectId: string;
}

interface PilotState {
analytics: MergeAnalytics | null;
patterns: ConflictPattern[];
loading: boolean;
error: Error | null;
}

export function MergeAnalyticsPilotView({
projectId,
}: Readonly<MergeAnalyticsPilotViewProps>) {
const { t } = useTranslation(['analytics']);
const [state, setState] = useState<PilotState>({
analytics: null,
patterns: [],
loading: true,
error: null,
});
const [reloadKey, setReloadKey] = useState(0);

useEffect(() => {
let active = true;
setState({ analytics: null, patterns: [], loading: true, error: null });
Promise.all([
window.electronAPI.getMergeSummary(projectId),
window.electronAPI.getConflictPatterns(projectId, 10),
])
.then(([summaryResult, patternsResult]) => {
if (!active) return;
if (!summaryResult.success || summaryResult.data == null) {
console.error(
'[MergeAnalyticsPilotView] Failed to load summary:',
summaryResult.success ? 'no data' : summaryResult.error,
);
setState({
analytics: null,
patterns: [],
loading: false,
error: new Error(t('analytics:mergePilot.error')),
});
return;
}
setState({
analytics: summaryResult.data,
patterns: patternsResult.success ? (patternsResult.data ?? []) : [],
loading: false,
error: null,
});
})
.catch((err: unknown) => {
if (!active) return;
console.error('[MergeAnalyticsPilotView] Failed to load merge analytics:', err);
setState({
analytics: null,
patterns: [],
loading: false,
error: new Error(t('analytics:mergePilot.error')),
});
});
return () => {
active = false;
};
}, [projectId, reloadKey, t]);

const reload = useCallback(() => setReloadKey((key) => key + 1), []);

const kpis = useMemo<UiKpi[] | null>(() => {
if (state.analytics == null) return null;
return buildMergeKpis(state.analytics, {
operations: t('analytics:mergePilot.kpis.operations'),
operationsSub: t('analytics:mergePilot.kpis.operationsSub'),
successRate: t('analytics:mergePilot.kpis.successRate'),
autoMerge: t('analytics:mergePilot.kpis.autoMerge'),
conflicts: t('analytics:mergePilot.kpis.conflicts'),
conflictsSub: t('analytics:mergePilot.kpis.conflictsSub'),
});
}, [state.analytics, t]);

const barLists = useMemo<UiBarListCard[] | undefined>(() => {
if (state.analytics == null) return undefined;
return buildMergeBarLists(
state.patterns,
t('analytics:mergePilot.conflicts.title'),
t('analytics:mergePilot.conflicts.aria'),
);
}, [state.analytics, state.patterns, t]);

const sections = useMemo<UiMetaSection[] | undefined>(() => {
if (state.analytics == null) return undefined;
return buildMergeSections(state.analytics, {
outcomesTitle: t('analytics:mergePilot.sections.outcomesTitle'),
successful: t('analytics:mergePilot.sections.successful'),
failed: t('analytics:mergePilot.sections.failed'),
filesMerged: t('analytics:mergePilot.sections.filesMerged'),
efficiencyTitle: t('analytics:mergePilot.sections.efficiencyTitle'),
avgDuration: t('analytics:mergePilot.sections.avgDuration'),
aiCalls: t('analytics:mergePilot.sections.aiCalls'),
tokens: t('analytics:mergePilot.sections.tokens'),
});
}, [state.analytics, t]);

const stateLabels = useMemo(
() => ({
loading: t('analytics:mergePilot.states.loading'),
retry: t('analytics:mergePilot.states.retry'),
empty: t('analytics:mergePilot.states.empty'),
}),
[t],
);

return (
<div className="h-full overflow-hidden">
<AnalyticsDashboard
kpis={kpis}
barLists={barLists}
sections={sections}
loading={state.loading}
error={state.error}
onRetry={reload}
stateLabels={stateLabels}
/>
</div>
);
}
3 changes: 2 additions & 1 deletion apps/frontend/src/renderer/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ import { SessionContextIndicator } from './SessionContextIndicator';
import { NavIndicator } from './NavIndicator';
import type { Project, AutoBuildVersionInfo, GitStatus, ProjectEnvConfig } from '../../shared/types';

export type SidebarView = 'kanban' | 'kanban-next' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'webhooks' | 'github-issues' | 'github-issues-next' | 'gitlab-issues' | 'github-prs' | 'github-prs-next' | 'gitlab-merge-requests' | 'changelog' | 'changelog-next' | 'insights' | 'worktrees' | 'agent-tools' | 'plugins' | 'analytics' | 'analytics-next' | 'productivity' | 'productivity-next' | 'merge-analytics' | 'sessions' | 'scheduler' | 'feedback' | 'patterns' | 'patterns-next' | 'model-usage' | 'agent-inspector';
export type SidebarView = 'kanban' | 'kanban-next' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'webhooks' | 'github-issues' | 'github-issues-next' | 'gitlab-issues' | 'github-prs' | 'github-prs-next' | 'gitlab-merge-requests' | 'changelog' | 'changelog-next' | 'insights' | 'worktrees' | 'agent-tools' | 'plugins' | 'analytics' | 'analytics-next' | 'productivity' | 'productivity-next' | 'merge-analytics' | 'merge-analytics-next' | 'sessions' | 'scheduler' | 'feedback' | 'patterns' | 'patterns-next' | 'model-usage' | 'agent-inspector';

interface SidebarProps {
onSettingsClick: () => void;
Expand Down Expand Up @@ -110,6 +110,7 @@ const baseNavItems: NavItem[] = [
{ id: 'plugins', labelKey: 'navigation:items.plugins', icon: Puzzle, shortcut: 'U' },
{ id: 'worktrees', labelKey: 'navigation:items.worktrees', icon: GitBranch, shortcut: 'W' },
{ id: 'merge-analytics', labelKey: 'navigation:items.mergeAnalytics', icon: Activity, shortcut: 'T' },
{ id: 'merge-analytics-next', labelKey: 'navigation:items.mergeAnalyticsNext', icon: GitMerge },
{ id: 'sessions', labelKey: 'navigation:items.sessions', icon: Play },
{ id: 'agent-inspector', labelKey: 'navigation:items.agentInspector', icon: Brain },
{ id: 'feedback', labelKey: 'navigation:items.feedback', icon: MessageSquare, shortcut: 'F' },
Expand Down
Loading
Loading