Skip to content
Open
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 @@ -72,6 +72,7 @@ import { useTaskStore, loadTasks } from './stores/task-store';
import { KanbanPilotView } from './components/KanbanPilotView';
import { ChangelogPilotView } from './components/ChangelogPilotView';
import { GitHubPRsPilotView } from './components/GitHubPRsPilotView';
import { GitHubIssuesPilotView } from './components/GitHubIssuesPilotView';
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 @@ -1109,6 +1110,9 @@ export function App() {
{activeView === 'github-prs-next' && (activeProjectId || selectedProjectId) && (
<GitHubPRsPilotView projectId={(activeProjectId || selectedProjectId)!} />
)}
{activeView === 'github-issues-next' && (activeProjectId || selectedProjectId) && (
<GitHubIssuesPilotView projectId={(activeProjectId || selectedProjectId)!} />
)}
{activeView === 'worktrees' && (activeProjectId || selectedProjectId) && (
<Worktrees projectId={activeProjectId || selectedProjectId!} />
)}
Expand Down
116 changes: 116 additions & 0 deletions apps/frontend/src/renderer/__tests__/github-issues-ui.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* Tests for the GitHubIssue → IssueList mapping helpers.
*/

import { describe, expect, it } from 'vitest';
import {
filterIssues,
labelToneOf,
mapIssueToUi,
} from '../lib/github-issues-ui';
import type { GitHubIssue } from '../../shared/types';

function makeIssue(overrides: Partial<GitHubIssue> = {}): GitHubIssue {
return {
id: 1,
number: 412,
title: 'QA fixer should accept screenshot evidence',
body: '',
state: 'open',
labels: [
{ id: 1, name: 'bug', color: 'ff0000' },
{ id: 2, name: 'area: qa-fixer', color: '888888' },
],
assignees: [{ login: 'om' }, { login: 'nikitos' }],
author: { login: 'nikitos' },
createdAt: '2026-07-20T10:00:00Z',
updatedAt: '2026-07-20T11:00:00Z',
commentsCount: 8,
url: 'https://api.github.com/x',
htmlUrl: 'https://github.com/o/r/issues/412',
repoFullName: 'obenner/auto-coding',
...overrides,
} as GitHubIssue;
}

describe('labelToneOf', () => {
it('maps common label families onto semantic tones', () => {
expect(labelToneOf('bug')).toBe('bug');
expect(labelToneOf('regression')).toBe('bug');
expect(labelToneOf('documentation')).toBe('docs');
expect(labelToneOf('good first issue')).toBe('good-first');
expect(labelToneOf('needs repro')).toBe('help');
expect(labelToneOf('discussion')).toBe('help');
expect(labelToneOf('enhancement')).toBe('feat');
expect(labelToneOf('priority: high')).toBe('feat');
expect(labelToneOf('area: frontend')).toBe('area');
expect(labelToneOf('anything-else')).toBe('area');
});
});

describe('mapIssueToUi', () => {
it('maps the row fields, labels, and assignee avatars', () => {
const ui = mapIssueToUi(makeIssue());
expect(ui).toEqual({
id: 'issue#412',
number: 412,
title: 'QA fixer should accept screenshot evidence',
state: 'open',
repo: 'obenner/auto-coding',
author: 'nikitos',
labels: [
{ text: 'bug', tone: 'bug' },
{ text: 'area: qa-fixer', tone: 'area' },
],
assignees: [
{ initials: 'OM', name: 'om' },
{ initials: 'NI', name: 'nikitos' },
],
commentsCount: 8,
});
});

it('maps closed state and omits empty labels/assignees', () => {
const ui = mapIssueToUi(
makeIssue({ state: 'closed', labels: [], assignees: [] }),
);
expect(ui.state).toBe('closed');
expect(ui.labels).toBeUndefined();
expect(ui.assignees).toBeUndefined();
});
});

describe('filterIssues', () => {
const issues = [
mapIssueToUi(makeIssue()),
mapIssueToUi(
makeIssue({
number: 411,
title: 'Add dark mode toggle',
labels: [{ id: 3, name: 'enhancement', color: '00f' }],
assignees: [{ login: 'om' }],
}),
),
];

it('matches number, title, label, and assignee, case-insensitively', () => {
expect(filterIssues(issues, '#412').map((issue) => issue.number)).toEqual([412]);
expect(filterIssues(issues, 'DARK MODE').map((issue) => issue.number)).toEqual([411]);
expect(filterIssues(issues, 'qa-fixer').map((issue) => issue.number)).toEqual([412]);
expect(filterIssues(issues, 'zzz')).toEqual([]);
});

it('matches the issue author, as the search label promises', () => {
const authored = [
mapIssueToUi(makeIssue({ number: 500, author: { login: 'scout-agent' } })),
mapIssueToUi(makeIssue({ number: 501, author: { login: 'om' } })),
];
expect(filterIssues(authored, 'scout').map((issue) => issue.number)).toEqual([
500,
]);
});

it('returns everything for a blank query', () => {
expect(filterIssues(issues, ' ')).toHaveLength(2);
});
});
210 changes: 210 additions & 0 deletions apps/frontend/src/renderer/components/GitHubIssuesPilotView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
/**
* GitHub Issues pilot on the shared design system (U5 B1).
*
* Renders `libs/ui`'s IssueList from the existing `getGitHubIssues` IPC,
* paired with the explicit connection check so a disconnected project
* surfaces the error state (the list handler folds failures into empty
* pages). Read-only list — investigation/import flows stay on the legacy
* view. Reachable via the "GitHub Issues (new UI)" sidebar item.
*/

import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IssueList } from '@auto-code/ui';
import type { UiIssue, UiMetaSection } from '@auto-code/ui';
import type { GitHubIssue, GitHubSyncStatus } from '../../shared/types';
import { filterIssues, mapIssueToUi } from '../lib/github-issues-ui';
import { relativeAge } from '../lib/github-prs-ui';

type IssueStateFilter = 'open' | 'closed' | 'all';

interface GitHubIssuesPilotViewProps {
projectId: string;
}

interface PilotState {
issues: GitHubIssue[] | null;
sync: GitHubSyncStatus | null;
loading: boolean;
error: Error | null;
}

export function GitHubIssuesPilotView({
projectId,
}: Readonly<GitHubIssuesPilotViewProps>) {
const { t } = useTranslation(['github']);
const [state, setState] = useState<PilotState>({
issues: null,
sync: null,
loading: true,
error: null,
});
const [reloadKey, setReloadKey] = useState(0);
const [query, setQuery] = useState('');
const [stateFilter, setStateFilter] = useState<IssueStateFilter>('open');

useEffect(() => {
let active = true;
setState({ issues: null, sync: null, loading: true, error: null });
Promise.all([
window.electronAPI.github.checkGitHubConnection(projectId),
window.electronAPI.github.getGitHubIssues(projectId, stateFilter, 1),
])
.then(([connection, result]) => {
if (!active) return;
if (!connection.success || connection.data?.connected !== true) {
console.error(
'[GitHubIssuesPilotView] GitHub is not connected:',
connection.success ? connection.data : connection.error,
);
setState({
issues: null,
sync: null,
loading: false,
error: new Error(t('github:issuesPilot.error')),
});
return;
}
if (!result.success) {
console.error(
'[GitHubIssuesPilotView] Failed to list issues:',
result.error,
);
setState({
issues: null,
sync: null,
loading: false,
error: new Error(t('github:issuesPilot.error')),
});
return;
}
setState({
issues: result.data?.issues ?? [],
sync: connection.data ?? null,
loading: false,
error: null,
});
})
.catch((err: unknown) => {
if (!active) return;
// Log the raw IPC error; the screen shows only localized text.
console.error('[GitHubIssuesPilotView] Failed to load issues:', err);
setState({
issues: null,
sync: null,
loading: false,
error: new Error(t('github:issuesPilot.error')),
});
});
return () => {
active = false;
};
}, [projectId, stateFilter, reloadKey, t]);

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

const issues = useMemo<UiIssue[] | null>(() => {
if (state.issues == null) return null;
const now = new Date();
return state.issues.map((issue) => ({
...mapIssueToUi(issue),
metaText: t('github:issuesPilot.rowMeta', {
author: issue.author.login,
age: relativeAge(issue.createdAt, now, {
minute: t('github:prsPilot.age.minute'),
hour: t('github:prsPilot.age.hour'),
day: t('github:prsPilot.age.day'),
}),
}),
}));
}, [state.issues, t]);

const visible = useMemo(
() => (issues == null ? null : filterIssues(issues, query)),
[issues, query],
);

const filters = useMemo(
() => [
{ id: 'open', label: t('github:issuesPilot.filters.open') },
{ id: 'closed', label: t('github:issuesPilot.filters.closed') },
{ id: 'all', label: t('github:issuesPilot.filters.all') },
],
[t],
);

const metaSections = useMemo<UiMetaSection[] | undefined>(() => {
if (state.sync == null) return undefined;
const rows = [
...(state.sync.repoFullName != null
? [
{
label: t('github:issuesPilot.meta.repository'),
value: state.sync.repoFullName,
},
]
: []),
// GitHubSyncStatus.issueCount comes from a per_page=1 probe (always
// 0 or 1), so report what this page actually loaded instead.
...(state.issues != null
? [
{
label: t('github:issuesPilot.meta.loaded'),
value: String(state.issues.length),
},
]
: []),
...(state.sync.lastSyncedAt != null
? [
{
label: t('github:issuesPilot.meta.lastSynced'),
value: new Date(state.sync.lastSyncedAt).toLocaleString(),
},
]
: []),
];
if (rows.length === 0) return undefined;
return [{ title: t('github:issuesPilot.meta.title'), rows }];
}, [state.sync, state.issues, t]);

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

const issueStateLabels = useMemo(
() => ({
open: t('github:issuesPilot.issueStates.open'),
closed: t('github:issuesPilot.issueStates.closed'),
draft: t('github:issuesPilot.issueStates.draft'),
}),
[t],
);

return (
<div className="h-full overflow-hidden">
<IssueList
issues={visible}
loading={state.loading}
error={state.error}
onRetry={reload}
searchValue={query}
onSearchChange={setQuery}
searchPlaceholder={t('github:issuesPilot.searchPlaceholder')}
searchLabel={t('github:issuesPilot.searchLabel')}
filters={filters}
activeFilterId={stateFilter}
onSelectFilter={(id) => setStateFilter(id as IssueStateFilter)}
filtersLabel={t('github:issuesPilot.filtersLabel')}
stateLabels={stateLabels}
issueStateLabels={issueStateLabels}
commentsLabel={t('github:issuesPilot.commentsLabel')}
metaSections={metaSections}
/>
</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' | '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';
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' | 'productivity' | 'merge-analytics' | 'sessions' | 'scheduler' | 'feedback' | 'patterns' | 'model-usage' | 'agent-inspector';

interface SidebarProps {
onSettingsClick: () => void;
Expand Down Expand Up @@ -116,6 +116,7 @@ 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-issues-next', labelKey: 'navigation:items.githubIssuesNext', icon: Github },
{ id: 'github-prs', labelKey: 'navigation:items.githubPRs', icon: GitPullRequest, shortcut: 'H' },
{ id: 'github-prs-next', labelKey: 'navigation:items.githubPRsNext', icon: GitPullRequest }
];
Expand Down
Loading
Loading