From 911221fc11b3ea19b35094c2de6f481cf65a3305 Mon Sep 17 00:00:00 2001 From: Oleg Miagkov Date: Mon, 20 Jul 2026 20:10:10 +0400 Subject: [PATCH 1/3] feat(ui): GitHub Issues list pilot on the shared design system (U5 B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third B1 screen, ported from desktop-github-issues.html: - libs/ui: new IssueList screen — search + filter-chip toolbar (active chip count turns blue per the mockup), two-column body with issue rows (22px circular state pill open/closed/draft, mono repo slug, label pills in six semantic tones, assignee stack, comment count) beside an optional right meta rail on the shared UiMetaSection card family; loading/error/empty states; rail drops below at 1180px. New shared types: UiIssue/UiIssueState/UiIssueLabel/UiIssueLabelTone/ UiIssueAssignee. - Electron: pure github-issues-ui mappers (label-name → tone inference, GitHubIssue → row shape reusing the PR pilot's initials/ relative-age helpers, client-side filtering; 5 tests) + GitHubIssuesPilotView over the existing getGitHubIssues IPC paired with checkGitHubConnection for a reachable error state; the state chips (Open/Closed/All) drive the server-side state param. Mounted as "GitHub Issues (new UI)" beside the legacy investigation view. - i18n: github:issuesPilot.* keys (en+fr), navigation.githubIssuesNext. Co-Authored-By: Claude Fable 5 --- apps/frontend/src/renderer/App.tsx | 4 + .../__tests__/github-issues-ui.test.ts | 106 ++++++ .../components/GitHubIssuesPilotView.tsx | 200 +++++++++++ .../src/renderer/components/Sidebar.tsx | 3 +- .../src/renderer/lib/github-issues-ui.ts | 76 +++++ .../src/shared/i18n/locales/en/github.json | 28 ++ .../shared/i18n/locales/en/navigation.json | 3 +- .../src/shared/i18n/locales/fr/github.json | 28 ++ .../shared/i18n/locales/fr/navigation.json | 3 +- libs/ui/src/client/types.ts | 39 +++ libs/ui/src/index.ts | 12 + libs/ui/src/screens/IssueList.css | 310 ++++++++++++++++++ libs/ui/src/screens/IssueList.stories.tsx | 161 +++++++++ libs/ui/src/screens/IssueList.tsx | 276 ++++++++++++++++ 14 files changed, 1246 insertions(+), 3 deletions(-) create mode 100644 apps/frontend/src/renderer/__tests__/github-issues-ui.test.ts create mode 100644 apps/frontend/src/renderer/components/GitHubIssuesPilotView.tsx create mode 100644 apps/frontend/src/renderer/lib/github-issues-ui.ts create mode 100644 libs/ui/src/screens/IssueList.css create mode 100644 libs/ui/src/screens/IssueList.stories.tsx create mode 100644 libs/ui/src/screens/IssueList.tsx diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index d006c70d1..8af974981 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -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'; @@ -1109,6 +1110,9 @@ export function App() { {activeView === 'github-prs-next' && (activeProjectId || selectedProjectId) && ( )} + {activeView === 'github-issues-next' && (activeProjectId || selectedProjectId) && ( + + )} {activeView === 'worktrees' && (activeProjectId || selectedProjectId) && ( )} diff --git a/apps/frontend/src/renderer/__tests__/github-issues-ui.test.ts b/apps/frontend/src/renderer/__tests__/github-issues-ui.test.ts new file mode 100644 index 000000000..86f624b47 --- /dev/null +++ b/apps/frontend/src/renderer/__tests__/github-issues-ui.test.ts @@ -0,0 +1,106 @@ +/** + * 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 { + 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', + 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, 'nikitos').map((issue) => issue.number)).toEqual([412]); + expect(filterIssues(issues, 'zzz')).toEqual([]); + }); + + it('returns everything for a blank query', () => { + expect(filterIssues(issues, ' ')).toHaveLength(2); + }); +}); diff --git a/apps/frontend/src/renderer/components/GitHubIssuesPilotView.tsx b/apps/frontend/src/renderer/components/GitHubIssuesPilotView.tsx new file mode 100644 index 000000000..5d6421f05 --- /dev/null +++ b/apps/frontend/src/renderer/components/GitHubIssuesPilotView.tsx @@ -0,0 +1,200 @@ +/** + * 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) { + const { t } = useTranslation(['github']); + const [state, setState] = useState({ + issues: null, + sync: null, + loading: true, + error: null, + }); + const [reloadKey, setReloadKey] = useState(0); + const [query, setQuery] = useState(''); + const [stateFilter, setStateFilter] = useState('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(() => { + 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(() => { + if (state.sync == null) return undefined; + const rows = [ + ...(state.sync.repoFullName != null + ? [ + { + label: t('github:issuesPilot.meta.repository'), + value: state.sync.repoFullName, + }, + ] + : []), + ...(state.sync.issueCount != null + ? [ + { + label: t('github:issuesPilot.meta.openIssues'), + value: String(state.sync.issueCount), + }, + ] + : []), + ]; + if (rows.length === 0) return undefined; + return [{ title: t('github:issuesPilot.meta.title'), rows }]; + }, [state.sync, 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 ( +
+ setStateFilter(id as IssueStateFilter)} + filtersLabel={t('github:issuesPilot.filtersLabel')} + stateLabels={stateLabels} + issueStateLabels={issueStateLabels} + commentsLabel={t('github:issuesPilot.commentsLabel')} + metaSections={metaSections} + /> +
+ ); +} diff --git a/apps/frontend/src/renderer/components/Sidebar.tsx b/apps/frontend/src/renderer/components/Sidebar.tsx index ef5e5b69b..b15bbef9a 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' | '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; @@ -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 } ]; diff --git a/apps/frontend/src/renderer/lib/github-issues-ui.ts b/apps/frontend/src/renderer/lib/github-issues-ui.ts new file mode 100644 index 000000000..230d5d3dd --- /dev/null +++ b/apps/frontend/src/renderer/lib/github-issues-ui.ts @@ -0,0 +1,76 @@ +/** + * Pure mapping helpers between the shared GitHubIssue shape and the + * IssueList screen. Localized strings (row meta) stay in the pilot; these + * helpers only produce locale-neutral values. Reuses the PR mappers' + * initials/relative-age helpers so the two pilots stay consistent. + */ + +import type { UiIssue, UiIssueLabel, UiIssueLabelTone } from '@auto-code/ui'; +import type { GitHubIssue } from '../../shared/types'; +import { initialsOf } from './github-prs-ui'; + +const LABEL_TONES: ReadonlyArray<[RegExp, UiIssueLabelTone]> = [ + [/bug|regression|crash/i, 'bug'], + [/doc/i, 'docs'], + [/good first/i, 'good-first'], + [/help|question|discussion|repro/i, 'help'], + [/enhancement|feature|priority/i, 'feat'], +]; + +/** Map a GitHub label name onto a semantic pill tone; unknown → area. */ +export function labelToneOf(name: string): UiIssueLabelTone { + for (const [pattern, tone] of LABEL_TONES) { + if (pattern.test(name)) return tone; + } + return 'area'; +} + +export function mapIssueLabels( + labels: GitHubIssue['labels'], +): UiIssueLabel[] | undefined { + if (labels.length === 0) return undefined; + return labels.map((label) => ({ + text: label.name, + tone: labelToneOf(label.name), + })); +} + +/** Map one GitHub issue onto the shared row shape. `metaText` is the caller's. */ +export function mapIssueToUi(issue: GitHubIssue): UiIssue { + return { + id: `issue#${issue.number}`, + number: issue.number, + title: issue.title, + state: issue.state === 'closed' ? 'closed' : 'open', + repo: issue.repoFullName || undefined, + labels: mapIssueLabels(issue.labels), + assignees: + issue.assignees.length > 0 + ? issue.assignees.map((assignee) => ({ + initials: initialsOf(assignee.login), + name: assignee.login, + })) + : undefined, + commentsCount: issue.commentsCount, + }; +} + +/** Case-insensitive match over number, title, author, and label names. */ +export function filterIssues( + issues: readonly UiIssue[], + query: string, +): UiIssue[] { + const needle = query.trim().toLowerCase(); + if (needle === '') return [...issues]; + return issues.filter((issue) => + [ + `#${issue.number}`, + issue.title, + issue.repo, + ...(issue.labels ?? []).map((label) => label.text), + ...(issue.assignees ?? []).map((assignee) => assignee.name), + ] + .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 70a7791ff..16604e76b 100644 --- a/apps/frontend/src/shared/i18n/locales/en/github.json +++ b/apps/frontend/src/shared/i18n/locales/en/github.json @@ -28,5 +28,33 @@ "hour": "h", "day": "d" } + }, + "issuesPilot": { + "error": "Failed to load issues. Check the GitHub connection in Settings.", + "searchPlaceholder": "Search title, author, label…", + "searchLabel": "Search issues by number, title, author, or label", + "filtersLabel": "Filter issues by state", + "filters": { + "open": "Open", + "closed": "Closed", + "all": "All" + }, + "rowMeta": "opened {{age}} ago by {{author}}", + "commentsLabel": "comments", + "issueStates": { + "open": "Open", + "closed": "Closed", + "draft": "Draft" + }, + "states": { + "loading": "Loading issues…", + "retry": "Retry", + "empty": "No issues." + }, + "meta": { + "title": "Connected repository", + "repository": "Repository", + "openIssues": "Open issues" + } } } diff --git a/apps/frontend/src/shared/i18n/locales/en/navigation.json b/apps/frontend/src/shared/i18n/locales/en/navigation.json index 507022017..9973fc6ea 100644 --- a/apps/frontend/src/shared/i18n/locales/en/navigation.json +++ b/apps/frontend/src/shared/i18n/locales/en/navigation.json @@ -31,7 +31,8 @@ "modelUsage": "Model Usage", "kanbanNext": "Kanban (New UI)", "changelogNext": "Changelog (New UI)", - "githubPRsNext": "GitHub PRs (New UI)" + "githubPRsNext": "GitHub PRs (New UI)", + "githubIssuesNext": "GitHub Issues (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 3fb709d06..8b56ae9c3 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/github.json +++ b/apps/frontend/src/shared/i18n/locales/fr/github.json @@ -28,5 +28,33 @@ "hour": "h", "day": "j" } + }, + "issuesPilot": { + "error": "Échec du chargement des issues. Vérifiez la connexion GitHub dans les Réglages.", + "searchPlaceholder": "Rechercher titre, auteur, étiquette…", + "searchLabel": "Rechercher des issues par numéro, titre, auteur ou étiquette", + "filtersLabel": "Filtrer les issues par état", + "filters": { + "open": "Ouvertes", + "closed": "Fermées", + "all": "Toutes" + }, + "rowMeta": "ouverte il y a {{age}} par {{author}}", + "commentsLabel": "commentaires", + "issueStates": { + "open": "Ouverte", + "closed": "Fermée", + "draft": "Brouillon" + }, + "states": { + "loading": "Chargement des issues…", + "retry": "Réessayer", + "empty": "Aucune issue." + }, + "meta": { + "title": "Dépôt connecté", + "repository": "Dépôt", + "openIssues": "Issues ouvertes" + } } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/navigation.json b/apps/frontend/src/shared/i18n/locales/fr/navigation.json index 34f28d5c6..64621ce4e 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/navigation.json +++ b/apps/frontend/src/shared/i18n/locales/fr/navigation.json @@ -31,7 +31,8 @@ "modelUsage": "Utilisation des Modèles", "kanbanNext": "Kanban (nouvelle interface)", "changelogNext": "Journal des modifications (nouvelle interface)", - "githubPRsNext": "PRs GitHub (nouvelle interface)" + "githubPRsNext": "PRs GitHub (nouvelle interface)", + "githubIssuesNext": "Issues GitHub (nouvelle interface)" }, "actions": { "settings": "Paramètres", diff --git a/libs/ui/src/client/types.ts b/libs/ui/src/client/types.ts index b2b210e83..f736af604 100644 --- a/libs/ui/src/client/types.ts +++ b/libs/ui/src/client/types.ts @@ -176,3 +176,42 @@ export interface UiPrStat { sub?: string; tone?: 'good' | 'warn' | 'bad'; } + +/** Lifecycle of an issue row; drives the circular state pill. */ +export type UiIssueState = 'open' | 'closed' | 'draft'; + +/** Semantic tone of a label pill; drives the pill colors. */ +export type UiIssueLabelTone = + | 'bug' + | 'feat' + | 'docs' + | 'good-first' + | 'help' + | 'area'; + +export interface UiIssueLabel { + text: string; + tone: UiIssueLabelTone; +} + +export interface UiIssueAssignee { + /** Short initials shown in the avatar circle (e.g. "OM"). */ + initials: string; + name?: string; +} + +/** One issue row. Adapters own all label formatting. */ +export interface UiIssue { + /** Unique key for React identity (e.g. "issue#412"). */ + id: string; + number: number; + title: string; + state: UiIssueState; + /** Mono repo slug shown first in the meta line (e.g. "o/auto-coding"). */ + repo?: string; + /** Pre-localized meta text (e.g. "opened 14m ago by nikitos"). */ + metaText?: string; + labels?: UiIssueLabel[]; + assignees?: UiIssueAssignee[]; + commentsCount?: number; +} diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index fc0db6a2b..d0f23aa80 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -44,6 +44,11 @@ export type { UiReleaseType, UiPullRequest, UiPullRequestState, + UiIssue, + UiIssueState, + UiIssueLabel, + UiIssueLabelTone, + UiIssueAssignee, UiPrCheck, UiPrCheckStatus, UiPrReviewer, @@ -87,3 +92,10 @@ export type { PullRequestFilter, PullRequestListStateLabels, } from './screens/PullRequestList'; +// GitHub Issues list (U5 B1) +export { IssueList } from './screens/IssueList'; +export type { + IssueListProps, + IssueListFilter, + IssueListStateLabels, +} from './screens/IssueList'; diff --git a/libs/ui/src/screens/IssueList.css b/libs/ui/src/screens/IssueList.css new file mode 100644 index 000000000..9aabe25ae --- /dev/null +++ b/libs/ui/src/screens/IssueList.css @@ -0,0 +1,310 @@ +/* GitHub Issues list ported from .lazyweb/mockups/desktop-github-issues.html: + search + chip toolbar | issue rows beside an optional 320px meta rail. */ + +.ac-issues { + font-family: var(--font-sans); + color: var(--ink); + height: 100%; + display: flex; + flex-direction: column; +} + +/* --- Toolbar (same family as the PRs toolbar) --- */ + +.ac-issues__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-issues__search { + min-height: 34px; +} +.ac-issues__filters { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 0; + padding: 0; + border: none; +} +.ac-issues__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-issues__chip:focus-visible { + outline: 2px solid var(--blue); + outline-offset: 1px; +} +.ac-issues__chip--active { + color: var(--ink); + background: var(--panel); + border-color: var(--line-strong); + font-weight: 720; +} +.ac-issues__chip-count { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10.5px; + color: var(--quiet); +} +.ac-issues__chip--active .ac-issues__chip-count { + color: var(--blue); +} + +/* --- States --- */ + +.ac-issues__state { + color: var(--muted); + font-size: 14px; + padding: 20px 28px; +} +.ac-issues__state--error { + color: var(--red); +} +.ac-issues__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; +} + +/* --- Two-column body --- */ + +.ac-issues__body { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + overflow: hidden; +} +.ac-issues__body--no-rail { + grid-template-columns: minmax(0, 1fr); +} +.ac-issues__list { + overflow-y: auto; +} + +/* --- Issue rows --- */ + +.ac-issues__row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 14px; + align-items: start; + padding: 14px 28px; + border-bottom: 1px solid var(--line); +} +.ac-issues__row--link { + cursor: pointer; +} +.ac-issues__row--link:hover { + background: var(--soft); +} +.ac-issues__row--link:focus-visible { + outline: 2px solid var(--blue); + outline-offset: -2px; +} + +.ac-issues__pill { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 999px; + font-size: 11px; + font-weight: 800; +} +.ac-issues__pill--open { + color: var(--green); + background: var(--green-soft); +} +/* Same non-tokenized purple family as the merged PR tile (WCAG AA fg). */ +.ac-issues__pill--closed { + color: #8a3aa3; + background: #f6e7fb; +} +[data-theme='dark'] .ac-issues__pill--closed { + color: #c98fd8; + background: #2c1f33; +} +.ac-issues__pill--draft { + color: var(--muted); + background: var(--rail); +} + +.ac-issues__title { + margin: 0; + font-size: 14px; + font-weight: 720; + overflow-wrap: anywhere; +} +.ac-issues__num { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + color: var(--quiet); + margin-right: 8px; +} +.ac-issues__meta { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-top: 3px; + font-size: 11.5px; + color: var(--muted); +} +.ac-issues__repo { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + color: var(--quiet); +} + +.ac-issues__labels { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; +} +.ac-issues__label { + display: inline-flex; + align-items: center; + min-height: 20px; + padding: 0 8px; + border-radius: 999px; + font-size: 10.5px; + font-weight: 720; +} +.ac-issues__label--bug { + color: var(--red); + background: var(--red-soft); +} +.ac-issues__label--feat { + color: var(--blue); + background: var(--blue-soft); +} +/* Mockup-specified docs pair (no matching token); dark is a hand-pick. */ +.ac-issues__label--docs { + color: #1965a8; + background: #e2f5ff; +} +[data-theme='dark'] .ac-issues__label--docs { + color: #7cc0ee; + background: #12293a; +} +.ac-issues__label--good-first { + color: var(--green); + background: var(--green-soft); +} +.ac-issues__label--help { + color: var(--amber); + background: var(--amber-soft); +} +.ac-issues__label--area { + color: var(--muted); + background: var(--rail); +} + +.ac-issues__right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; +} +.ac-issues__assignees { + display: flex; +} +.ac-issues__avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: var(--rail); + border: 2px solid var(--panel); + font-size: 9px; + font-weight: 800; +} +.ac-issues__avatar + .ac-issues__avatar { + margin-left: -6px; +} +.ac-issues__comments { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 11px; + color: var(--quiet); +} + +/* --- Right meta rail (same card/kv family as the sibling screens) --- */ + +.ac-issues__rail { + border-left: 1px solid var(--line); + background: var(--soft); + overflow-y: auto; + padding: 12px; + display: grid; + gap: 12px; + align-content: start; +} +.ac-issues__card { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 10px; + padding: 12px 14px; +} +.ac-issues__card h3 { + margin: 0 0 6px; + font-size: 13px; + font-weight: 760; +} +.ac-issues__kv { + display: grid; + grid-template-columns: 110px minmax(0, 1fr); + gap: 8px; + padding: 2px 0; + font-size: 12.5px; +} +.ac-issues__kv span { + color: var(--quiet); + font-weight: 720; +} +.ac-issues__kv strong { + font-weight: 720; + overflow-wrap: anywhere; +} + +@media (max-width: 1180px) { + .ac-issues__body { + grid-template-columns: minmax(0, 1fr); + } + .ac-issues__rail { + border-left: none; + border-top: 1px solid var(--line); + } +} +@media (max-width: 760px) { + .ac-issues__toolbar { + grid-template-columns: minmax(0, 1fr); + } + .ac-issues__row { + padding: 12px 14px; + } +} diff --git a/libs/ui/src/screens/IssueList.stories.tsx b/libs/ui/src/screens/IssueList.stories.tsx new file mode 100644 index 000000000..b857751d8 --- /dev/null +++ b/libs/ui/src/screens/IssueList.stories.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react'; +import type React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { IssueList } from './IssueList'; +import type { UiIssue } from '../client/types'; + +const ISSUES: UiIssue[] = [ + { + id: 'issue#412', + number: 412, + title: 'QA fixer should accept screenshot evidence from any viewport', + state: 'open', + repo: 'obenner/auto-coding', + metaText: 'opened 14m ago by nikitos · last activity 4m ago', + labels: [ + { text: 'bug', tone: 'bug' }, + { text: 'area: qa-fixer', tone: 'area' }, + { text: 'priority: high', tone: 'feat' }, + ], + assignees: [ + { initials: 'OM', name: 'om' }, + { initials: 'NK', name: 'nikitos' }, + ], + commentsCount: 8, + }, + { + id: 'issue#411', + number: 411, + title: 'Add dark mode toggle to settings', + state: 'open', + repo: 'obenner/auto-coding', + metaText: 'opened 2h ago by OM · linked to SPEC-201', + labels: [ + { text: 'enhancement', tone: 'feat' }, + { text: 'area: frontend', tone: 'area' }, + { text: 'good first issue', tone: 'good-first' }, + ], + assignees: [{ initials: 'OM', name: 'om' }], + commentsCount: 3, + }, + { + id: 'issue#410', + number: 410, + title: 'Provider diagnostics drawer does not handle 502 from OpenRouter cleanly', + state: 'open', + repo: 'obenner/auto-coding', + metaText: 'opened 4h ago by scout-agent · auto-filed from SPEC-203 recovery', + labels: [ + { text: 'bug', tone: 'bug' }, + { text: 'area: providers', tone: 'area' }, + { text: 'needs repro', tone: 'help' }, + ], + commentsCount: 0, + }, + { + id: 'issue#407', + number: 407, + title: 'Document the new sidebar collapse behaviour (⌘\\) in the keyboard cheatsheet', + state: 'open', + repo: 'obenner/auto-coding', + metaText: 'opened 2d ago by OM', + labels: [ + { text: 'documentation', tone: 'docs' }, + { text: 'good first issue', tone: 'good-first' }, + ], + commentsCount: 1, + }, + { + id: 'issue#403', + number: 403, + title: 'i18n parity script fails on Windows due to path separator', + state: 'closed', + repo: 'obenner/auto-coding', + metaText: 'closed 3d ago by nikitos · fixed in #406', + labels: [ + { text: 'bug', tone: 'bug' }, + { text: 'area: ci', tone: 'area' }, + ], + assignees: [{ initials: 'NK', name: 'nikitos' }], + commentsCount: 4, + }, +]; + +const meta = { + title: 'Screens/IssueList', + component: IssueList, + parameters: { layout: 'fullscreen' }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + loading: false, + error: null, + issues: ISSUES, + searchPlaceholder: 'Search title, body, author, label…', + filters: [ + { id: 'open', label: 'Open', count: 38 }, + { id: 'closed', label: 'Closed', count: 214 }, + { id: 'all', label: 'All' }, + ], + activeFilterId: 'open', + onSelectIssue: () => {}, + metaSections: [ + { + title: 'Connected repository', + rows: [ + { label: 'Repository', value: 'obenner/auto-coding' }, + { label: 'Open issues', value: '38' }, + { label: 'Last synced', value: '4 minutes ago' }, + ], + }, + ], + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +function InteractiveIssueList( + args: Readonly>, +) { + const [query, setQuery] = useState(''); + const [filter, setFilter] = useState('open'); + return ( + + ); +} + +export const OpenIssues: Story = { + render: (args) => , +}; + +export const NoRail: Story = { + args: { metaSections: undefined, onSelectIssue: undefined }, +}; + +export const Loading: Story = { + args: { issues: null, loading: true, filters: undefined }, +}; + +export const ErrorState: Story = { + args: { + issues: null, + error: new Error('GitHub is not connected'), + filters: undefined, + }, +}; + +export const Empty: Story = { + args: { issues: [], filters: undefined, metaSections: undefined }, +}; diff --git a/libs/ui/src/screens/IssueList.tsx b/libs/ui/src/screens/IssueList.tsx new file mode 100644 index 000000000..27eab5124 --- /dev/null +++ b/libs/ui/src/screens/IssueList.tsx @@ -0,0 +1,276 @@ +import type { KeyboardEvent } from 'react'; +import { Input } from '../primitives/Input'; +import type { + UiIssue, + UiIssueState, + UiMetaSection, +} from '../client/types'; +import './IssueList.css'; + +const STATE_GLYPHS: Record = { + open: '○', + closed: '●', + draft: '◌', +}; + +const STATE_LABELS: Record = { + open: 'Open', + closed: 'Closed', + draft: 'Draft', +}; + +export interface IssueListFilter { + id: string; + label: string; + count?: number; +} + +export interface IssueListStateLabels { + loading?: string; + retry?: string; + empty?: string; +} + +export interface IssueListProps { + issues: UiIssue[] | null; + loading?: boolean; + error?: Error | null; + onRetry?: () => void; + searchValue?: string; + onSearchChange?: (value: string) => void; + searchPlaceholder?: string; + /** Accessible name for the search input. */ + searchLabel?: string; + filters?: IssueListFilter[]; + activeFilterId?: string; + onSelectFilter?: (id: string) => void; + /** Accessible name for the filter chip group. */ + filtersLabel?: string; + onSelectIssue?: (issue: UiIssue) => void; + /** Localized loading/retry/empty state labels; falls back to English. */ + stateLabels?: IssueListStateLabels; + /** Localized issue-state pill labels (accessible names). */ + issueStateLabels?: Partial>; + /** Accessible name for the per-row comment count. */ + commentsLabel?: string; + /** Right-rail meta cards (connected repo, sync status, …). */ + metaSections?: UiMetaSection[]; +} + +/** + * Presentational GitHub issue list mirroring the `.lazyweb` mockup: a + * search + filter-chip toolbar over a two-column body — issue rows + * (circular state pill, repo slug meta, label pills, assignee stack, + * comment count) beside an optional right meta rail. Data-agnostic — + * pair with an adapter that maps the app's issue source onto UiIssue[]. + */ +export function IssueList({ + issues, + loading = false, + error = null, + onRetry, + searchValue, + onSearchChange, + searchPlaceholder, + searchLabel, + filters, + activeFilterId, + onSelectFilter, + filtersLabel, + onSelectIssue, + stateLabels, + issueStateLabels, + commentsLabel, + metaSections, +}: Readonly) { + const hasToolbar = + onSearchChange != null || (filters != null && filters.length > 0); + const sections = metaSections ?? []; + + return ( +
+ {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 && (issues == null || issues.length === 0) && ( +

{stateLabels?.empty ?? 'No issues.'}

+ )} + + {!loading && error == null && issues != null && issues.length > 0 && ( +
0 ? '' : ' ac-issues__body--no-rail' + }`} + > +
+ {issues.map((issue) => ( + + ))} +
+ + {sections.length > 0 && ( + + )} +
+ )} +
+ ); +} + +function IssueRow({ + issue, + onSelect, + issueStateLabels, + commentsLabel, +}: Readonly<{ + issue: UiIssue; + onSelect?: (issue: UiIssue) => void; + issueStateLabels?: Partial>; + commentsLabel?: string; +}>) { + const interactive = onSelect != null; + return ( +
onSelect(issue), + onKeyDown: (event: KeyboardEvent) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelect(issue); + } + }, + } + : {})} + > + + {STATE_GLYPHS[issue.state]} + + +
+

+ #{issue.number} + {issue.title} +

+
+ {issue.repo != null && ( + {issue.repo} + )} + {issue.metaText != null && {issue.metaText}} +
+ {issue.labels != null && issue.labels.length > 0 && ( +
+ {issue.labels.map((label) => ( + + {label.text} + + ))} +
+ )} +
+ +
+ {issue.assignees != null && issue.assignees.length > 0 && ( +
+ {issue.assignees.map((assignee, index) => ( + + {assignee.initials} + + ))} +
+ )} + {issue.commentsCount != null && ( + + 💬 {issue.commentsCount} + + )} +
+
+ ); +} From 9ac3a71e2b1d5a6bcda45cad8543e7f8384c3422 Mon Sep 17 00:00:00 2001 From: Oleg Miagkov Date: Tue, 21 Jul 2026 21:14:24 +0400 Subject: [PATCH 2/3] fix(ui): address review findings on the Issues pilot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Search now matches the issue author, which both locales' search label and the helper's doc comment already promised — UiIssue gains an author field the adapter fills and filterIssues checks. - Meta rail no longer renders GitHubSyncStatus.issueCount: the connection handler derives it from a per_page=1 probe, so it is always 0 or 1. Report the loaded row count and the last sync time instead (new meta.loaded / meta.lastSynced keys, en+fr). 6 mapper tests green; tsc clean in libs/ui and the Electron app. Co-Authored-By: Claude Fable 5 --- .../__tests__/github-issues-ui.test.ts | 12 +++++++++++- .../components/GitHubIssuesPilotView.tsx | 18 ++++++++++++++---- .../src/renderer/lib/github-issues-ui.ts | 2 ++ .../src/shared/i18n/locales/en/github.json | 3 ++- .../src/shared/i18n/locales/fr/github.json | 3 ++- libs/ui/src/client/types.ts | 2 ++ 6 files changed, 33 insertions(+), 7 deletions(-) diff --git a/apps/frontend/src/renderer/__tests__/github-issues-ui.test.ts b/apps/frontend/src/renderer/__tests__/github-issues-ui.test.ts index 86f624b47..ef0042ae9 100644 --- a/apps/frontend/src/renderer/__tests__/github-issues-ui.test.ts +++ b/apps/frontend/src/renderer/__tests__/github-issues-ui.test.ts @@ -57,6 +57,7 @@ describe('mapIssueToUi', () => { 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' }, @@ -96,10 +97,19 @@ describe('filterIssues', () => { 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, 'nikitos').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); }); diff --git a/apps/frontend/src/renderer/components/GitHubIssuesPilotView.tsx b/apps/frontend/src/renderer/components/GitHubIssuesPilotView.tsx index 5d6421f05..2f2bfd24a 100644 --- a/apps/frontend/src/renderer/components/GitHubIssuesPilotView.tsx +++ b/apps/frontend/src/renderer/components/GitHubIssuesPilotView.tsx @@ -144,18 +144,28 @@ export function GitHubIssuesPilotView({ }, ] : []), - ...(state.sync.issueCount != null + // 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.openIssues'), - value: String(state.sync.issueCount), + 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, t]); + }, [state.sync, state.issues, t]); const stateLabels = useMemo( () => ({ diff --git a/apps/frontend/src/renderer/lib/github-issues-ui.ts b/apps/frontend/src/renderer/lib/github-issues-ui.ts index 230d5d3dd..9a7b36992 100644 --- a/apps/frontend/src/renderer/lib/github-issues-ui.ts +++ b/apps/frontend/src/renderer/lib/github-issues-ui.ts @@ -43,6 +43,7 @@ export function mapIssueToUi(issue: GitHubIssue): UiIssue { title: issue.title, state: issue.state === 'closed' ? 'closed' : 'open', repo: issue.repoFullName || undefined, + author: issue.author.login, labels: mapIssueLabels(issue.labels), assignees: issue.assignees.length > 0 @@ -67,6 +68,7 @@ export function filterIssues( `#${issue.number}`, issue.title, issue.repo, + issue.author, ...(issue.labels ?? []).map((label) => label.text), ...(issue.assignees ?? []).map((assignee) => assignee.name), ] diff --git a/apps/frontend/src/shared/i18n/locales/en/github.json b/apps/frontend/src/shared/i18n/locales/en/github.json index 16604e76b..9d9e40608 100644 --- a/apps/frontend/src/shared/i18n/locales/en/github.json +++ b/apps/frontend/src/shared/i18n/locales/en/github.json @@ -54,7 +54,8 @@ "meta": { "title": "Connected repository", "repository": "Repository", - "openIssues": "Open issues" + "loaded": "Loaded", + "lastSynced": "Last synced" } } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/github.json b/apps/frontend/src/shared/i18n/locales/fr/github.json index 8b56ae9c3..6e41cdc52 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/github.json +++ b/apps/frontend/src/shared/i18n/locales/fr/github.json @@ -54,7 +54,8 @@ "meta": { "title": "Dépôt connecté", "repository": "Dépôt", - "openIssues": "Issues ouvertes" + "loaded": "Chargées", + "lastSynced": "Dernière synchro" } } } diff --git a/libs/ui/src/client/types.ts b/libs/ui/src/client/types.ts index f736af604..628aec1e9 100644 --- a/libs/ui/src/client/types.ts +++ b/libs/ui/src/client/types.ts @@ -209,6 +209,8 @@ export interface UiIssue { state: UiIssueState; /** Mono repo slug shown first in the meta line (e.g. "o/auto-coding"). */ repo?: string; + /** Issue author login; rendered inside metaText, kept for filtering. */ + author?: string; /** Pre-localized meta text (e.g. "opened 14m ago by nikitos"). */ metaText?: string; labels?: UiIssueLabel[]; From d53e9d1712b4173ad3baa49aec8fc9465d3ab318 Mon Sep 17 00:00:00 2001 From: Oleg Miagkov Date: Tue, 21 Jul 2026 21:27:31 +0400 Subject: [PATCH 3/3] perf(ui): short-circuit issue filtering (CodeRabbit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Evaluate searchable fields through a small matcher with logical OR instead of building intermediate arrays per issue per keystroke — same fields and null handling, no allocations, stops at first match. Co-Authored-By: Claude Fable 5 --- .../src/renderer/lib/github-issues-ui.ts | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/frontend/src/renderer/lib/github-issues-ui.ts b/apps/frontend/src/renderer/lib/github-issues-ui.ts index 9a7b36992..433c346c5 100644 --- a/apps/frontend/src/renderer/lib/github-issues-ui.ts +++ b/apps/frontend/src/renderer/lib/github-issues-ui.ts @@ -63,16 +63,15 @@ export function filterIssues( ): UiIssue[] { const needle = query.trim().toLowerCase(); if (needle === '') return [...issues]; - return issues.filter((issue) => - [ - `#${issue.number}`, - issue.title, - issue.repo, - issue.author, - ...(issue.labels ?? []).map((label) => label.text), - ...(issue.assignees ?? []).map((assignee) => assignee.name), - ] - .filter((field): field is string => field != null) - .some((field) => field.toLowerCase().includes(needle)), + const matches = (field?: string) => + field != null && field.toLowerCase().includes(needle); + return issues.filter( + (issue) => + matches(`#${issue.number}`) || + matches(issue.title) || + matches(issue.repo) || + matches(issue.author) || + (issue.labels ?? []).some((label) => matches(label.text)) || + (issue.assignees ?? []).some((assignee) => matches(assignee.name)), ); }