diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index cf4fa53d1..d006c70d1 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -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'; @@ -1105,6 +1106,9 @@ export function App() { {activeView === 'changelog-next' && (activeProjectId || selectedProjectId) && ( )} + {activeView === 'github-prs-next' && (activeProjectId || selectedProjectId) && ( + + )} {activeView === 'worktrees' && (activeProjectId || selectedProjectId) && ( )} diff --git a/apps/frontend/src/renderer/__tests__/github-prs-ui.test.ts b/apps/frontend/src/renderer/__tests__/github-prs-ui.test.ts new file mode 100644 index 000000000..a6dce75d1 --- /dev/null +++ b/apps/frontend/src/renderer/__tests__/github-prs-ui.test.ts @@ -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 { + 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); + }); +}); diff --git a/apps/frontend/src/renderer/components/GitHubPRsPilotView.tsx b/apps/frontend/src/renderer/components/GitHubPRsPilotView.tsx new file mode 100644 index 000000000..5a53d2a3d --- /dev/null +++ b/apps/frontend/src/renderer/components/GitHubPRsPilotView.tsx @@ -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) { + const { t } = useTranslation(['github']); + const [state, setState] = useState({ + 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(() => { + 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(() => { + 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 ( +
+ +
+ ); +} diff --git a/apps/frontend/src/renderer/components/Sidebar.tsx b/apps/frontend/src/renderer/components/Sidebar.tsx index dd9d32637..ef5e5b69b 100644 --- a/apps/frontend/src/renderer/components/Sidebar.tsx +++ b/apps/frontend/src/renderer/components/Sidebar.tsx @@ -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; @@ -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 diff --git a/apps/frontend/src/renderer/lib/github-prs-ui.ts b/apps/frontend/src/renderer/lib/github-prs-ui.ts new file mode 100644 index 000000000..74e28b473 --- /dev/null +++ b/apps/frontend/src/renderer/lib/github-prs-ui.ts @@ -0,0 +1,102 @@ +/** + * Pure mapping helpers between the preload GitHub PRData shape and the + * shared PullRequestList screen. Localized strings (stat labels, row meta) + * stay in the pilot; these helpers only produce locale-neutral values. + */ + +import type { UiPullRequest } from '@auto-code/ui'; +import type { PRData } from '../../preload/api/modules/github-api'; + +/** "auto-code" → "AC", "OM" → "OM", "renovate" → "RE". */ +export function initialsOf(login: string): string { + const parts = login.split(/[-_\s]+/).filter(Boolean); + if (parts.length >= 2) { + return (parts[0][0] + parts[1][0]).toUpperCase(); + } + return login.slice(0, 2).toUpperCase(); +} + +export interface AgeUnits { + minute: string; + hour: string; + day: string; +} + +const DEFAULT_AGE_UNITS: AgeUnits = { minute: 'm', hour: 'h', day: 'd' }; + +/** Compact relative age: "22m", "4h", "2d"; "<1m" under a minute. */ +export function relativeAge( + iso: string, + now: Date = new Date(), + units: AgeUnits = DEFAULT_AGE_UNITS, +): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ''; + const minutes = Math.floor((now.getTime() - then) / 60_000); + if (minutes < 1) return `<1${units.minute}`; + if (minutes < 60) return `${minutes}${units.minute}`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}${units.hour}`; + return `${Math.floor(hours / 24)}${units.day}`; +} + +/** + * Map one GitHub PR onto the shared row shape. `listPRs` only returns open + * PRs today, so the state tile is always "open"; draft/merged/conflict land + * with the detail flow in part 2. `metaText` is left for the caller (i18n). + */ +export function mapPRToUi( + pr: PRData, + now: Date = new Date(), + ageUnits?: AgeUnits, +): UiPullRequest { + return { + id: `pr#${pr.number}`, + number: pr.number, + title: pr.title, + state: 'open', + author: pr.author.login, + headBranch: pr.headRefName, + baseBranch: pr.baseRefName, + additions: pr.additions, + deletions: pr.deletions, + reviewers: + pr.assignees.length > 0 + ? pr.assignees.map((assignee) => ({ + initials: initialsOf(assignee.login), + name: assignee.login, + })) + : undefined, + timeLabel: relativeAge(pr.updatedAt, now, ageUnits), + }; +} + +export interface PrStatValues { + open: number; + authors: number; + additions: number; + deletions: number; +} + +export function computePrStatValues(prs: readonly PRData[]): PrStatValues { + return { + open: prs.length, + authors: new Set(prs.map((pr) => pr.author.login)).size, + additions: prs.reduce((total, pr) => total + (pr.additions || 0), 0), + deletions: prs.reduce((total, pr) => total + (pr.deletions || 0), 0), + }; +} + +/** Case-insensitive match over number, title, author, and branches. */ +export function filterPullRequests( + prs: readonly UiPullRequest[], + query: string, +): UiPullRequest[] { + const needle = query.trim().toLowerCase(); + if (needle === '') return [...prs]; + return prs.filter((pr) => + [`#${pr.number}`, pr.title, pr.author, pr.headBranch, pr.baseBranch] + .filter((field): field is string => field != null) + .some((field) => field.toLowerCase().includes(needle)), + ); +} diff --git a/apps/frontend/src/shared/i18n/locales/en/github.json b/apps/frontend/src/shared/i18n/locales/en/github.json index 5fe6e1719..70a7791ff 100644 --- a/apps/frontend/src/shared/i18n/locales/en/github.json +++ b/apps/frontend/src/shared/i18n/locales/en/github.json @@ -6,5 +6,27 @@ "creatingTask": "Creating task from investigation...", "complete": "Investigation complete!" } + }, + "prsPilot": { + "error": "Failed to load pull requests. Check the GitHub connection in Settings.", + "searchPlaceholder": "Search title, branch, author…", + "searchLabel": "Search pull requests by number, title, author, or branch", + "states": { + "loading": "Loading pull requests…", + "retry": "Retry", + "empty": "No open pull requests." + }, + "stats": { + "open": "open PRs", + "authors": "authors", + "lines": "lines changed" + }, + "rowMeta_one": "{{count}} file · opened {{age}} ago", + "rowMeta_other": "{{count}} files · opened {{age}} ago", + "age": { + "minute": "m", + "hour": "h", + "day": "d" + } } } diff --git a/apps/frontend/src/shared/i18n/locales/en/navigation.json b/apps/frontend/src/shared/i18n/locales/en/navigation.json index c9539a3f3..507022017 100644 --- a/apps/frontend/src/shared/i18n/locales/en/navigation.json +++ b/apps/frontend/src/shared/i18n/locales/en/navigation.json @@ -30,7 +30,8 @@ "feedback": "Feedback", "modelUsage": "Model Usage", "kanbanNext": "Kanban (New UI)", - "changelogNext": "Changelog (New UI)" + "changelogNext": "Changelog (New UI)", + "githubPRsNext": "GitHub PRs (New UI)" }, "actions": { "settings": "Settings", diff --git a/apps/frontend/src/shared/i18n/locales/fr/github.json b/apps/frontend/src/shared/i18n/locales/fr/github.json index 101910eb7..3fb709d06 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/github.json +++ b/apps/frontend/src/shared/i18n/locales/fr/github.json @@ -6,5 +6,27 @@ "creatingTask": "Création de la tâche à partir de l'investigation...", "complete": "Investigation terminée !" } + }, + "prsPilot": { + "error": "Échec du chargement des pull requests. Vérifiez la connexion GitHub dans les Réglages.", + "searchPlaceholder": "Rechercher titre, branche, auteur…", + "searchLabel": "Rechercher des pull requests par numéro, titre, auteur ou branche", + "states": { + "loading": "Chargement des pull requests…", + "retry": "Réessayer", + "empty": "Aucune pull request ouverte." + }, + "stats": { + "open": "PRs ouvertes", + "authors": "auteurs", + "lines": "lignes modifiées" + }, + "rowMeta_one": "{{count}} fichier · ouverte il y a {{age}}", + "rowMeta_other": "{{count}} fichiers · ouverte il y a {{age}}", + "age": { + "minute": "min", + "hour": "h", + "day": "j" + } } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/navigation.json b/apps/frontend/src/shared/i18n/locales/fr/navigation.json index 7f6a95fab..34f28d5c6 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/navigation.json +++ b/apps/frontend/src/shared/i18n/locales/fr/navigation.json @@ -30,7 +30,8 @@ "feedback": "Retour d'information", "modelUsage": "Utilisation des Modèles", "kanbanNext": "Kanban (nouvelle interface)", - "changelogNext": "Journal des modifications (nouvelle interface)" + "changelogNext": "Journal des modifications (nouvelle interface)", + "githubPRsNext": "PRs GitHub (nouvelle interface)" }, "actions": { "settings": "Paramètres", diff --git a/libs/ui/src/client/types.ts b/libs/ui/src/client/types.ts index db2a67896..b2b210e83 100644 --- a/libs/ui/src/client/types.ts +++ b/libs/ui/src/client/types.ts @@ -129,3 +129,50 @@ export interface UiRelease { meta?: readonly string[]; sections: UiReleaseSection[]; } + +/** Lifecycle tile of a PR row; drives the state glyph + colors. */ +export type UiPullRequestState = 'open' | 'draft' | 'merged' | 'conflict'; + +export type UiPrCheckStatus = 'good' | 'bad' | 'warn' | 'run' | 'skip'; + +/** One CI check square; label doubles as tooltip and accessible name. */ +export interface UiPrCheck { + label: string; + status: UiPrCheckStatus; +} + +export interface UiPrReviewer { + /** Short initials shown in the avatar circle (e.g. "OM"). */ + initials: string; + name?: string; + status?: 'good' | 'warn' | 'bad'; +} + +/** One pull request row. Adapters own all label formatting. */ +export interface UiPullRequest { + /** Unique key for React identity (e.g. "repo#264"). */ + id: string; + number: number; + title: string; + state: UiPullRequestState; + author?: string; + headBranch?: string; + baseBranch?: string; + additions?: number; + deletions?: number; + /** Pre-localized trailing meta (e.g. "14 files · opened 22 min ago"). */ + metaText?: string; + checks?: UiPrCheck[]; + reviewers?: UiPrReviewer[]; + badge?: UiTaskBadge; + /** Right-column relative time (e.g. "22m"). */ + timeLabel?: string; +} + +/** Summary tile above the PR toolbar. */ +export interface UiPrStat { + value: string; + label: string; + sub?: string; + tone?: 'good' | 'warn' | 'bad'; +} diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index f368fa28a..fc0db6a2b 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -42,6 +42,12 @@ export type { UiReleaseSection, UiReleaseSectionKind, UiReleaseType, + UiPullRequest, + UiPullRequestState, + UiPrCheck, + UiPrCheckStatus, + UiPrReviewer, + UiPrStat, CreateTaskInput, TaskStatus, BadgeTone, @@ -74,3 +80,10 @@ export type { UseBoardFilterResult, FilterId } from './client/useBoardFilter'; // Changelog browser (U5 B1 pilot) export { ChangelogView } from './screens/ChangelogView'; export type { ChangelogViewProps, ChangelogViewStateLabels } from './screens/ChangelogView'; +// GitHub PRs list (U5 B1) +export { PullRequestList } from './screens/PullRequestList'; +export type { + PullRequestListProps, + PullRequestFilter, + PullRequestListStateLabels, +} from './screens/PullRequestList'; diff --git a/libs/ui/src/screens/PullRequestList.css b/libs/ui/src/screens/PullRequestList.css new file mode 100644 index 000000000..45c52119a --- /dev/null +++ b/libs/ui/src/screens/PullRequestList.css @@ -0,0 +1,333 @@ +/* GitHub PRs list ported from .lazyweb/mockups/desktop-github-prs.html: + summary stat tiles | search + chip toolbar | full-width PR rows. */ + +.ac-prs { + font-family: var(--font-sans); + color: var(--ink); + height: 100%; + display: flex; + flex-direction: column; +} + +/* --- Summary stats --- */ + +.ac-prs__summary { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 12px; + padding: 14px 28px; + background: var(--soft); + border-bottom: 1px solid var(--line); +} +.ac-prs__stat { + display: flex; + flex-direction: column; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + padding: 12px 14px; +} +.ac-prs__stat strong { + font-size: 20px; + font-weight: 800; +} +.ac-prs__stat > span { + font-size: 11.5px; + color: var(--muted); +} +.ac-prs__stat-sub { + font-size: 10.5px; + color: var(--quiet) !important; +} +.ac-prs__stat-num--good { + color: var(--green); +} +.ac-prs__stat-num--warn { + color: var(--amber); +} +.ac-prs__stat-num--bad { + color: var(--red); +} + +/* --- Toolbar --- */ + +.ac-prs__toolbar { + display: grid; + grid-template-columns: minmax(260px, 360px) minmax(0, 1fr); + align-items: center; + gap: 12px; + padding: 12px 28px; + background: var(--panel); + border-bottom: 1px solid var(--line); +} +.ac-prs__search { + min-height: 34px; +} +.ac-prs__filters { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0; + padding: 0; + border: none; +} +.ac-prs__chip { + display: inline-flex; + align-items: center; + gap: 6px; + min-height: 28px; + padding: 0 12px; + border-radius: 999px; + border: 1px solid var(--line); + background: var(--soft); + color: var(--muted); + font: inherit; + font-size: 12px; + cursor: pointer; +} +.ac-prs__chip:focus-visible { + outline: 2px solid var(--blue); + outline-offset: 1px; +} +.ac-prs__chip--active { + color: var(--ink); + background: var(--panel); + border-color: var(--line-strong); + font-weight: 720; +} +.ac-prs__chip-count { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10.5px; + color: var(--quiet); +} + +/* --- States --- */ + +.ac-prs__state { + color: var(--muted); + font-size: 14px; + padding: 20px 28px; +} +.ac-prs__state--error { + color: var(--red); +} +.ac-prs__retry { + font-family: inherit; + margin-left: 8px; + font-size: 13px; + color: var(--blue); + background: transparent; + border: 1px solid var(--line); + border-radius: 6px; + padding: 2px 10px; + cursor: pointer; +} + +/* --- PR rows --- */ + +.ac-prs__list { + flex: 1; + min-height: 0; + overflow-y: auto; +} +.ac-prs__row { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto auto auto; + gap: 14px; + align-items: center; + padding: 14px 28px; + border-bottom: 1px solid var(--line); +} +.ac-prs__row--link { + cursor: pointer; +} +.ac-prs__row--link:hover { + background: var(--soft); +} +.ac-prs__row--link:focus-visible { + outline: 2px solid var(--blue); + outline-offset: -2px; +} + +.ac-prs__tile { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 8px; + font-size: 14px; + font-weight: 800; +} +.ac-prs__tile--open { + color: var(--green); + background: var(--green-soft); +} +.ac-prs__tile--draft { + color: var(--muted); + background: var(--rail); +} +/* No purple token exists in the sheet — the mockup hardcodes this pair; + the fg is darkened from the mockup's #ad57c2 to meet WCAG AA (5.5:1), + and the dark values are a matched hand-pick. */ +.ac-prs__tile--merged { + color: #8a3aa3; + background: #f6e7fb; +} +[data-theme='dark'] .ac-prs__tile--merged { + color: #c98fd8; + background: #2c1f33; +} +.ac-prs__tile--conflict { + color: var(--red); + background: var(--red-soft); +} + +.ac-prs__title { + margin: 0; + font-size: 14px; + font-weight: 720; + overflow-wrap: anywhere; +} +.ac-prs__num { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + color: var(--quiet); + margin-right: 8px; +} +.ac-prs__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-top: 3px; + font-size: 11.5px; + color: var(--muted); +} +.ac-prs__branch { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + background: var(--soft); + border: 1px solid var(--line); + border-radius: 4px; + padding: 1px 5px; +} +.ac-prs__diff { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; +} +.ac-prs__plus { + color: var(--green); + font-weight: 720; +} +.ac-prs__minus { + color: var(--red); + font-weight: 720; +} + +.ac-prs__checks { + display: flex; + gap: 4px; +} +.ac-prs__check { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 5px; + font-size: 11px; + font-weight: 800; +} +.ac-prs__check--good { + color: var(--green); + background: var(--green-soft); +} +.ac-prs__check--bad { + color: var(--red); + background: var(--red-soft); +} +.ac-prs__check--warn { + color: var(--amber); + background: var(--amber-soft); +} +.ac-prs__check--run { + color: var(--blue); + background: var(--blue-soft); +} +.ac-prs__check--skip { + color: var(--quiet); + background: var(--rail); +} + +.ac-prs__reviews { + display: flex; +} +.ac-prs__avatar { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + background: var(--rail); + border: 2px solid var(--panel); + font-size: 9.5px; + font-weight: 800; +} +.ac-prs__avatar + .ac-prs__avatar { + margin-left: -6px; +} +.ac-prs__ind { + position: absolute; + right: -1px; + bottom: -1px; + width: 10px; + height: 10px; + border-radius: 50%; + border: 2px solid var(--panel); +} +.ac-prs__ind--good { + background: var(--green); +} +.ac-prs__ind--warn { + background: var(--amber); +} +.ac-prs__ind--bad { + background: var(--red); +} + +.ac-prs__right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 4px; +} +.ac-prs__time { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10.5px; + color: var(--quiet); +} + +@media (max-width: 1180px) { + .ac-prs__row { + grid-template-columns: 32px minmax(0, 1fr) auto; + gap: 10px; + } + .ac-prs__checks, + .ac-prs__reviews { + display: none; + } +} +@media (max-width: 760px) { + .ac-prs__toolbar { + grid-template-columns: minmax(0, 1fr); + } + .ac-prs__row { + padding: 12px 14px; + } + .ac-prs__summary { + padding: 14px; + } +} diff --git a/libs/ui/src/screens/PullRequestList.stories.tsx b/libs/ui/src/screens/PullRequestList.stories.tsx new file mode 100644 index 000000000..f350c0e85 --- /dev/null +++ b/libs/ui/src/screens/PullRequestList.stories.tsx @@ -0,0 +1,183 @@ +import { useState } from 'react'; +import type React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { PullRequestList } from './PullRequestList'; +import type { UiPullRequest } from '../client/types'; + +const CHECK_NAMES = ['lint', 'tsc', 'tests', 'i18n parity', 'bundle size']; + +const checks = (...statuses: ('good' | 'bad' | 'warn' | 'run' | 'skip')[]) => + statuses.map((status, index) => ({ label: CHECK_NAMES[index], status })); + +const PRS: UiPullRequest[] = [ + { + id: 'pr#264', + number: 264, + title: 'refactor(shell): extract AppShell + sidebar primitives from monolithic layout', + state: 'open', + author: 'OM', + headBranch: 'ac/spec-201', + baseBranch: 'develop', + additions: 482, + deletions: 137, + metaText: '14 files · opened 22 min ago', + checks: checks('good', 'good', 'run', 'good', 'warn'), + reviewers: [ + { initials: 'NK', status: 'good' }, + { initials: 'LP', status: 'warn' }, + ], + badge: { label: 'Auto Code', tone: 'info' }, + timeLabel: '22m', + }, + { + id: 'pr#262', + number: 262, + title: 'fix(i18n): seal raw translation keys behind a screenshot guard', + state: 'open', + author: 'Auto Code', + headBranch: 'ac/spec-199', + baseBranch: 'develop', + additions: 318, + deletions: 94, + metaText: '42 files · opened 1 h ago', + checks: checks('good', 'good', 'good', 'good', 'good'), + reviewers: [ + { initials: 'OM', status: 'good' }, + { initials: 'NK', status: 'good' }, + ], + badge: { label: 'Ready to merge', tone: 'good' }, + timeLabel: '1h', + }, + { + id: 'pr#258', + number: 258, + title: 'feat(provider): diagnostics drawer with retest and safe fallback', + state: 'conflict', + author: 'Auto Code', + headBranch: 'ac/spec-203', + baseBranch: 'develop', + additions: 112, + deletions: 18, + metaText: '6 files · opened 4 h ago', + checks: checks('good', 'good', 'bad', 'good', 'skip'), + reviewers: [{ initials: 'OM', status: 'warn' }], + badge: { label: 'Conflict', tone: 'bad' }, + timeLabel: '4h', + }, + { + id: 'pr#253', + number: 253, + title: 'wip: provider auto-recover orchestration with exponential backoff', + state: 'draft', + author: 'NK', + headBranch: 'nk/provider-auto-recover', + baseBranch: 'develop', + additions: 78, + deletions: 12, + metaText: '3 files · last activity 2 d ago', + checks: checks('good', 'warn', 'skip', 'skip', 'skip'), + badge: { label: 'Draft', tone: 'neutral' }, + timeLabel: '2d', + }, + { + id: 'pr#243', + number: 243, + title: 'refactor(settings): consolidate provider cards into a single component', + state: 'merged', + author: 'Auto Code', + headBranch: 'ac/spec-196', + baseBranch: 'develop', + additions: 162, + deletions: 218, + metaText: '9 files · merged yesterday', + checks: checks('good', 'good', 'good', 'good', 'good'), + reviewers: [ + { initials: 'OM', status: 'good' }, + { initials: 'NK', status: 'good' }, + ], + badge: { label: 'Merged', tone: 'neutral' }, + timeLabel: '1d', + }, +]; + +const meta = { + title: 'Screens/PullRequestList', + component: PullRequestList, + parameters: { layout: 'fullscreen' }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + loading: false, + error: null, + pullRequests: PRS, + stats: [ + { value: '12', label: 'open PRs', sub: 'across 4 repos', tone: 'good' }, + { value: '3', label: 'need your review', sub: 'waiting > 24h', tone: 'warn' }, + { value: '2', label: 'merge conflicts', sub: 'vs base develop', tone: 'bad' }, + { value: '5', label: 'ready to merge', sub: 'all checks pass' }, + { value: '87%', label: 'median CI pass', sub: 'last 30 days' }, + ], + searchPlaceholder: 'Search title, branch, author…', + filters: [ + { id: 'all', label: 'All', count: 12 }, + { id: 'open', label: 'Open', count: 8 }, + { id: 'draft', label: 'Draft', count: 3 }, + { id: 'conflict', label: 'Conflicts', count: 2 }, + ], + activeFilterId: 'all', + onSelectPullRequest: () => {}, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function InteractivePullRequestList( + args: Readonly>, +) { + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState('all'); + return ( + + ); +} + +export const OpenPRs: Story = { + render: (args) => , +}; + +export const NoToolbar: Story = { + args: { + stats: undefined, + filters: undefined, + onSelectPullRequest: undefined, + }, +}; + +export const Loading: Story = { + args: { pullRequests: null, loading: true, stats: undefined, filters: undefined }, +}; + +export const ErrorState: Story = { + args: { + pullRequests: null, + error: new Error('GitHub is not connected'), + stats: undefined, + filters: undefined, + }, +}; + +export const Empty: Story = { + args: { pullRequests: [], stats: undefined, filters: undefined }, +}; diff --git a/libs/ui/src/screens/PullRequestList.tsx b/libs/ui/src/screens/PullRequestList.tsx new file mode 100644 index 000000000..06b5170a8 --- /dev/null +++ b/libs/ui/src/screens/PullRequestList.tsx @@ -0,0 +1,325 @@ +import type { KeyboardEvent } from 'react'; +import { Badge } from '../primitives/Badge'; +import { Input } from '../primitives/Input'; +import type { + UiPrCheckStatus, + UiPrStat, + UiPullRequest, + UiPullRequestState, +} from '../client/types'; +import './PullRequestList.css'; + +const STATE_GLYPHS: Record = { + open: '↑', + draft: '○', + merged: '●', + conflict: '⤫', +}; + +const CHECK_GLYPHS: Record = { + good: '✓', + bad: '✕', + warn: '!', + run: '↻', + skip: '·', +}; + +const STATE_LABELS: Record = { + open: 'Open', + draft: 'Draft', + merged: 'Merged', + conflict: 'Conflict', +}; + +const CHECK_STATUS_LABELS: Record = { + good: 'passed', + bad: 'failed', + warn: 'warning', + run: 'running', + skip: 'skipped', +}; + +export interface PullRequestFilter { + id: string; + label: string; + count?: number; +} + +export interface PullRequestListStateLabels { + loading?: string; + retry?: string; + empty?: string; +} + +export interface PullRequestListProps { + pullRequests: UiPullRequest[] | null; + /** Summary tiles above the toolbar (open PRs, conflicts, …). */ + stats?: UiPrStat[]; + loading?: boolean; + error?: Error | null; + onRetry?: () => void; + searchValue?: string; + onSearchChange?: (value: string) => void; + searchPlaceholder?: string; + /** Accessible name for the search input. */ + searchLabel?: string; + filters?: PullRequestFilter[]; + activeFilterId?: string; + onSelectFilter?: (id: string) => void; + /** Accessible name for the filter chip group. */ + filtersLabel?: string; + onSelectPullRequest?: (pullRequest: UiPullRequest) => void; + /** Localized loading/retry/empty state labels; falls back to English. */ + stateLabels?: PullRequestListStateLabels; + /** Localized PR-state tile labels (accessible names); falls back to English. */ + prStateLabels?: Partial>; + /** Localized check-status words for accessible names; falls back to English. */ + checkStatusLabels?: Partial>; +} + +/** + * Presentational GitHub PR list mirroring the `.lazyweb` mockup: summary + * stat tiles, a search + filter-chip toolbar, and full-width PR rows with + * state tile, branch/diff meta, CI check squares, reviewer avatars, and a + * status badge column. Data-agnostic — pair with an adapter that maps the + * app's PR source onto UiPullRequest[]. + */ +export function PullRequestList({ + pullRequests, + stats, + loading = false, + error = null, + onRetry, + searchValue, + onSearchChange, + searchPlaceholder, + searchLabel, + filters, + activeFilterId, + onSelectFilter, + filtersLabel, + onSelectPullRequest, + stateLabels, + prStateLabels, + checkStatusLabels, +}: Readonly) { + const hasToolbar = + onSearchChange != null || (filters != null && filters.length > 0); + + return ( +
+ {stats != null && stats.length > 0 && ( +
+ {stats.map((stat) => ( +
+ + {stat.value} + + {stat.label} + {stat.sub != null && ( + {stat.sub} + )} +
+ ))} +
+ )} + + {hasToolbar && ( +
+ {onSearchChange != null && ( + onSearchChange(event.target.value)} + /> + )} + {filters != null && filters.length > 0 && ( +
+ {filters.map((filter) => ( + + ))} +
+ )} +
+ )} + + {loading && ( +

{stateLabels?.loading ?? 'Loading…'}

+ )} + + {!loading && error != null && ( +

+ {error.message} + {onRetry != null && ( + + )} +

+ )} + + {!loading && + error == null && + (pullRequests == null || pullRequests.length === 0) && ( +

+ {stateLabels?.empty ?? 'No pull requests.'} +

+ )} + + {!loading && error == null && pullRequests != null && pullRequests.length > 0 && ( +
+ {pullRequests.map((pullRequest) => ( + + ))} +
+ )} +
+ ); +} + +function PullRequestRow({ + pullRequest, + onSelect, + prStateLabels, + checkStatusLabels, +}: Readonly<{ + pullRequest: UiPullRequest; + onSelect?: (pullRequest: UiPullRequest) => void; + prStateLabels?: Partial>; + checkStatusLabels?: Partial>; +}>) { + const interactive = onSelect != null; + return ( +
onSelect(pullRequest), + onKeyDown: (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelect(pullRequest); + } + }, + } + : {})} + > + + {STATE_GLYPHS[pullRequest.state]} + + +
+

+ #{pullRequest.number} + {pullRequest.title} +

+
+ {pullRequest.author != null && {pullRequest.author}} + {pullRequest.headBranch != null && ( + <> + {pullRequest.headBranch} + {pullRequest.baseBranch != null && ( + <> + {' → '} + {pullRequest.baseBranch} + + )} + + )} + {(pullRequest.additions != null || pullRequest.deletions != null) && ( + + {pullRequest.additions != null && ( + +{pullRequest.additions} + )} + {pullRequest.additions != null && pullRequest.deletions != null + ? ' ' + : null} + {pullRequest.deletions != null && ( + −{pullRequest.deletions} + )} + + )} + {pullRequest.metaText != null && {pullRequest.metaText}} +
+
+ + {pullRequest.checks != null && pullRequest.checks.length > 0 && ( +
+ {pullRequest.checks.map((check) => ( + + {CHECK_GLYPHS[check.status]} + + ))} +
+ )} + + {pullRequest.reviewers != null && pullRequest.reviewers.length > 0 && ( +
+ {pullRequest.reviewers.map((reviewer, index) => ( + + {reviewer.initials} + {reviewer.status != null && ( + + ))} +
+ )} + +
+ {pullRequest.badge != null && ( + + {pullRequest.badge.label} + + )} + {pullRequest.timeLabel != null && ( + {pullRequest.timeLabel} + )} +
+
+ ); +}