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 @@ -71,6 +71,7 @@ import { useProjectStore, loadProjects, addProject, initializeProject, removePro
import { useTaskStore, loadTasks } from './stores/task-store';
import { KanbanPilotView } from './components/KanbanPilotView';
import { ChangelogPilotView } from './components/ChangelogPilotView';
import { GitHubPRsPilotView } from './components/GitHubPRsPilotView';
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 @@ -1105,6 +1106,9 @@ export function App() {
{activeView === 'changelog-next' && (activeProjectId || selectedProjectId) && (
<ChangelogPilotView projectId={(activeProjectId || selectedProjectId)!} />
)}
{activeView === 'github-prs-next' && (activeProjectId || selectedProjectId) && (
<GitHubPRsPilotView projectId={(activeProjectId || selectedProjectId)!} />
)}
{activeView === 'worktrees' && (activeProjectId || selectedProjectId) && (
<Worktrees projectId={activeProjectId || selectedProjectId!} />
)}
Expand Down
122 changes: 122 additions & 0 deletions apps/frontend/src/renderer/__tests__/github-prs-ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Tests for the PRData → PullRequestList mapping helpers.
*/

import { describe, expect, it } from 'vitest';
import {
computePrStatValues,
filterPullRequests,
initialsOf,
mapPRToUi,
relativeAge,
} from '../lib/github-prs-ui';
import type { PRData } from '../../preload/api/modules/github-api';

const NOW = new Date('2026-07-19T12:00:00Z');

function makePR(overrides: Partial<PRData> = {}): PRData {
return {
number: 264,
title: 'refactor(shell): extract AppShell',
body: '',
state: 'OPEN',
author: { login: 'auto-code' },
headRefName: 'ac/spec-201',
baseRefName: 'develop',
additions: 482,
deletions: 137,
changedFiles: 14,
assignees: [{ login: 'om' }],
files: [],
createdAt: '2026-07-19T11:38:00Z',
updatedAt: '2026-07-19T11:38:00Z',
htmlUrl: 'https://github.com/o/r/pull/264',
...overrides,
};
}

describe('initialsOf', () => {
it('takes two segment initials or the first two letters', () => {
expect(initialsOf('auto-code')).toBe('AC');
expect(initialsOf('renovate_bot')).toBe('RB');
expect(initialsOf('om')).toBe('OM');
expect(initialsOf('x')).toBe('X');
});
});

describe('relativeAge', () => {
it('renders minute, hour, and day buckets', () => {
expect(relativeAge('2026-07-19T11:59:40Z', NOW)).toBe('<1m');
expect(relativeAge('2026-07-19T11:38:00Z', NOW)).toBe('22m');
expect(relativeAge('2026-07-19T08:00:00Z', NOW)).toBe('4h');
expect(relativeAge('2026-07-17T12:00:00Z', NOW)).toBe('2d');
});

it('returns an empty string for unparseable input', () => {
expect(relativeAge('not a date', NOW)).toBe('');
});

it('renders localized unit suffixes when provided', () => {
const fr = { minute: 'min', hour: 'h', day: 'j' };
expect(relativeAge('2026-07-19T11:38:00Z', NOW, fr)).toBe('22min');
expect(relativeAge('2026-07-17T12:00:00Z', NOW, fr)).toBe('2j');
});
});

describe('mapPRToUi', () => {
it('maps the row fields and assignee avatars', () => {
const ui = mapPRToUi(makePR(), NOW);
expect(ui).toEqual({
id: 'pr#264',
number: 264,
title: 'refactor(shell): extract AppShell',
state: 'open',
author: 'auto-code',
headBranch: 'ac/spec-201',
baseBranch: 'develop',
additions: 482,
deletions: 137,
reviewers: [{ initials: 'OM', name: 'om' }],
timeLabel: '22m',
});
});

it('omits reviewers when there are no assignees', () => {
const ui = mapPRToUi(makePR({ assignees: [] }), NOW);
expect(ui.reviewers).toBeUndefined();
});
});

describe('computePrStatValues', () => {
it('counts PRs, distinct authors, and summed line changes', () => {
const values = computePrStatValues([
makePR(),
makePR({ number: 262, author: { login: 'om' }, additions: 18, deletions: 2 }),
makePR({ number: 258, additions: 0, deletions: 0 }),
]);
expect(values).toEqual({
open: 3,
authors: 2,
additions: 500,
deletions: 139,
});
});
});

describe('filterPullRequests', () => {
const prs = [
mapPRToUi(makePR(), NOW),
mapPRToUi(makePR({ number: 262, title: 'fix(i18n): seal raw keys', author: { login: 'om' } }), NOW),
];

it('matches number, title, author, and branch, case-insensitively', () => {
expect(filterPullRequests(prs, '#264').map((pr) => pr.number)).toEqual([264]);
expect(filterPullRequests(prs, 'I18N').map((pr) => pr.number)).toEqual([262]);
expect(filterPullRequests(prs, 'spec-201')).toHaveLength(2);
expect(filterPullRequests(prs, 'nothing-here')).toEqual([]);
});

it('returns everything for a blank query', () => {
expect(filterPullRequests(prs, ' ')).toHaveLength(2);
});
});
161 changes: 161 additions & 0 deletions apps/frontend/src/renderer/components/GitHubPRsPilotView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/**
* GitHub PRs pilot on the shared design system (U5 B1).
*
* Renders `libs/ui`'s PullRequestList from the existing `github:pr:list`
* IPC (open PRs via GraphQL), mapped with the pure `github-prs-ui`
* helpers. Read-only list — review/merge flows stay on the legacy GitHub
* PRs view. Reachable via the "GitHub PRs (new UI)" sidebar item.
*/

import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PullRequestList } from '@auto-code/ui';
import type { UiPrStat, UiPullRequest } from '@auto-code/ui';
import type { PRData } from '../../preload/api/modules/github-api';
import {
computePrStatValues,
filterPullRequests,
mapPRToUi,
relativeAge,
} from '../lib/github-prs-ui';

interface GitHubPRsPilotViewProps {
projectId: string;
}

interface PilotState {
prs: PRData[] | null;
loading: boolean;
error: Error | null;
}

export function GitHubPRsPilotView({
projectId,
}: Readonly<GitHubPRsPilotViewProps>) {
const { t } = useTranslation(['github']);
const [state, setState] = useState<PilotState>({
prs: null,
loading: true,
error: null,
});
const [reloadKey, setReloadKey] = useState(0);
const [query, setQuery] = useState('');

useEffect(() => {
let active = true;
setState({ prs: null, loading: true, error: null });
// The list handler folds every failure into an empty list, so a real
// "not connected" state must come from the explicit connection check.
Promise.all([
window.electronAPI.github.checkGitHubConnection(projectId),
window.electronAPI.github.listPRs(projectId),
])
.then(([connection, result]) => {
if (!active) return;
if (!connection.success || connection.data?.connected !== true) {
console.error(
'[GitHubPRsPilotView] GitHub is not connected:',
connection.success ? connection.data : connection.error,
);
setState({
prs: null,
loading: false,
error: new Error(t('github:prsPilot.error')),
});
return;
}
setState({
prs: result.prs ?? [],
loading: false,
error: null,
});
})
.catch((err: unknown) => {
if (!active) return;
// Log the raw IPC error; the screen shows only localized text.
console.error('[GitHubPRsPilotView] Failed to list PRs:', err);
setState({
prs: null,
loading: false,
error: new Error(t('github:prsPilot.error')),
});
});
return () => {
active = false;
};
}, [projectId, reloadKey, t]);

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

const ageUnits = useMemo(
() => ({
minute: t('github:prsPilot.age.minute'),
hour: t('github:prsPilot.age.hour'),
day: t('github:prsPilot.age.day'),
}),
[t],
);

const pullRequests = useMemo<UiPullRequest[] | null>(() => {
if (state.prs == null) return null;
const now = new Date();
return state.prs.map((pr) => ({
...mapPRToUi(pr, now, ageUnits),
metaText: t('github:prsPilot.rowMeta', {
count: pr.changedFiles,
age: relativeAge(pr.createdAt, now, ageUnits),
}),
}));
}, [state.prs, t, ageUnits]);

const visible = useMemo(
() => (pullRequests == null ? null : filterPullRequests(pullRequests, query)),
[pullRequests, query],
);

const stats = useMemo<UiPrStat[] | undefined>(() => {
if (state.prs == null || state.prs.length === 0) return undefined;
const values = computePrStatValues(state.prs);
return [
{
value: String(values.open),
label: t('github:prsPilot.stats.open'),
tone: 'good',
},
{
value: String(values.authors),
label: t('github:prsPilot.stats.authors'),
},
{
value: `+${values.additions} −${values.deletions}`,
label: t('github:prsPilot.stats.lines'),
},
];
}, [state.prs, t]);

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

return (
<div className="h-full overflow-hidden">
<PullRequestList
pullRequests={visible}
stats={stats}
loading={state.loading}
error={state.error}
onRetry={reload}
searchValue={query}
onSearchChange={setQuery}
searchPlaceholder={t('github:prsPilot.searchPlaceholder')}
searchLabel={t('github:prsPilot.searchLabel')}
stateLabels={stateLabels}
/>
</div>
);
}
5 changes: 3 additions & 2 deletions 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' | 'gitlab-issues' | 'github-prs' | 'gitlab-merge-requests' | 'changelog' | 'changelog-next' | 'insights' | 'worktrees' | 'agent-tools' | 'plugins' | 'analytics' | 'productivity' | 'merge-analytics' | 'sessions' | 'scheduler' | 'feedback' | 'patterns' | 'model-usage' | 'agent-inspector';
export type SidebarView = 'kanban' | 'kanban-next' | 'terminals' | 'roadmap' | 'context' | 'ideation' | 'webhooks' | 'github-issues' | 'gitlab-issues' | 'github-prs' | 'github-prs-next' | 'gitlab-merge-requests' | 'changelog' | 'changelog-next' | 'insights' | 'worktrees' | 'agent-tools' | 'plugins' | 'analytics' | 'productivity' | 'merge-analytics' | 'sessions' | 'scheduler' | 'feedback' | 'patterns' | 'model-usage' | 'agent-inspector';

interface SidebarProps {
onSettingsClick: () => void;
Expand Down Expand Up @@ -116,7 +116,8 @@ const baseNavItems: NavItem[] = [
// GitHub nav items shown when GitHub is enabled
const githubNavItems: NavItem[] = [
{ id: 'github-issues', labelKey: 'navigation:items.githubIssues', icon: Github, shortcut: 'G' },
{ id: 'github-prs', labelKey: 'navigation:items.githubPRs', icon: GitPullRequest, shortcut: 'H' }
{ id: 'github-prs', labelKey: 'navigation:items.githubPRs', icon: GitPullRequest, shortcut: 'H' },
{ id: 'github-prs-next', labelKey: 'navigation:items.githubPRsNext', icon: GitPullRequest }
];

// GitLab nav items shown when GitLab is enabled
Expand Down
Loading
Loading