From ab1787f0c414135cb3f9897ac4f1a2e6d8b1a75b Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Sun, 24 May 2026 18:40:49 -0700 Subject: [PATCH 1/2] Add My PRs tab to My Work --- scripts/test-my-prs-section.ts | 85 +++++ .../features/github-work/MyWorkView.tsx | 355 ++++++++++++++---- .../features/github-work/my-pull-requests.ts | 37 ++ tests/github-work/suite.ts | 1 + 4 files changed, 403 insertions(+), 75 deletions(-) create mode 100644 scripts/test-my-prs-section.ts create mode 100644 src/renderer/features/github-work/my-pull-requests.ts diff --git a/scripts/test-my-prs-section.ts b/scripts/test-my-prs-section.ts new file mode 100644 index 0000000..803324d --- /dev/null +++ b/scripts/test-my-prs-section.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import type { PullRequestSummary } from "../src/shared/domain/github-work.js"; +import { + filterMyPullRequests, + myPullRequestKey, + myPullRequestStatusCounts +} from "../src/renderer/features/github-work/my-pull-requests.js"; + +const basePr: PullRequestSummary = { + id: "base", + repoId: "repo", + number: 1, + title: "Base PR", + body: null, + authorLogin: "octocat", + assigneeLogins: [], + requestedReviewerLogins: [], + state: "open", + isDraft: false, + merged: false, + repoFullName: "octo/repo", + headSha: "head", + baseSha: "base", + baseBranch: "main", + headBranch: "feature", + additions: 1, + deletions: 0, + changedFiles: 1, + commitsCount: 1, + commentsCount: 0, + reviewCommentsCount: 0, + reviewState: null, + checkState: "unknown", + checkCount: 0, + labels: [], + htmlUrl: "https://github.com/octo/repo/pull/1", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-02T00:00:00Z", + closedAt: null, + mergedAt: null, + lastSyncedAt: "2026-01-02T00:00:00Z" +}; + +const prs: PullRequestSummary[] = [ + pr({ id: "created", number: 2, authorLogin: "mona", updatedAt: "2026-01-05T00:00:00Z" }), + pr({ id: "assigned", number: 3, assigneeLogins: ["mona"], updatedAt: "2026-01-04T00:00:00Z" }), + pr({ id: "review", number: 4, requestedReviewerLogins: ["MONA"], updatedAt: "2026-01-06T00:00:00Z" }), + pr({ id: "mentioned", number: 5, body: "cc @mona", updatedAt: "2026-01-03T00:00:00Z" }), + pr({ id: "draft", number: 8, authorLogin: "mona", isDraft: true, updatedAt: "2026-01-06T12:00:00Z" }), + pr({ + id: "closed", + number: 6, + authorLogin: "mona", + state: "closed", + updatedAt: "2026-01-07T00:00:00Z", + closedAt: "2026-01-07T00:00:00Z" + }), + pr({ + id: "merged", + number: 7, + authorLogin: "mona", + state: "closed", + merged: true, + updatedAt: "2026-01-08T00:00:00Z", + mergedAt: "2026-01-08T00:00:00Z" + }) +]; + +const mine = filterMyPullRequests(prs, "mona"); +const counts = myPullRequestStatusCounts(mine); + +assert.deepEqual( + mine.map((item) => item.number), + [6, 8, 2] +); +assert.equal(counts.open, 1); +assert.equal(counts.draft, 1); +assert.equal(counts.closed, 1); +assert.equal(myPullRequestKey(mine[0]!), "repo:6"); + +console.log("My PRs section tests ok"); + +function pr(overrides: Partial): PullRequestSummary { + return { ...basePr, ...overrides }; +} diff --git a/src/renderer/features/github-work/MyWorkView.tsx b/src/renderer/features/github-work/MyWorkView.tsx index dddf5cc..bd898dd 100644 --- a/src/renderer/features/github-work/MyWorkView.tsx +++ b/src/renderer/features/github-work/MyWorkView.tsx @@ -5,8 +5,9 @@ import { GitPullRequestIcon as GitHubPullRequestIcon, IssueOpenedIcon as GitHubIssueOpenedIcon } from "@primer/octicons-react"; -import { Archive, BellOff, ChevronDown, ChevronRight, Clock, Copy, ExternalLink, MoreHorizontal } from "lucide-react"; +import { Archive, BellOff, ChevronDown, ChevronRight, Clock, Copy, ExternalLink, MoreHorizontal, RefreshCw } from "lucide-react"; import type { AuthState } from "../../../shared/domain/auth"; +import type { PullRequestSummary } from "../../../shared/domain/github-work"; import type { AttentionItem, AttentionLane } from "../../../shared/attention"; import { myWorkLaneCopy, myWorkLaneOrder } from "../../../shared/product-coherence"; import { pageCountFor, paginateItems, PaginationFooter } from "../../components/PaginationFooter"; @@ -23,11 +24,13 @@ import { type WorkPriorityGroupId } from "./work-query-language"; import { MyWorkSearchInput } from "./EntitySearchInput"; +import { filterMyPullRequests, myPullRequestKey, myPullRequestStatusCounts } from "./my-pull-requests"; const ENTITY_PAGE_SIZE = 50; const ENTITY_LIST_STALE_TIME_MS = 5 * 60_000; const ENTITY_LIST_GC_TIME_MS = 30 * 60_000; const myWorkLanes: Array<{ id: AttentionLane; label: string }> = myWorkLaneOrder.map((id) => ({ id, label: myWorkLaneCopy[id].label })); +type MyWorkTabId = AttentionLane | "my_prs"; function WorkItemTypeIcon({ kind, state = "open" }: { kind: EntityQueryKind; state?: string }) { const iconClass = kind === "pr" ? "text-purple-900" : state === "closed" ? "text-red-900" : "text-green-900"; @@ -54,6 +57,8 @@ export function MyWorkView({ const [query, setQuery] = useState(""); const lane = useAppPreferencesStore((state) => state.myWorkLane); const setLane = useAppPreferencesStore((state) => state.setMyWorkLane); + const [activeTab, setActiveTab] = useState("my_prs"); + const [userSelectedTab, setUserSelectedTab] = useState(false); const [page, setPage] = useState(1); const [collapsedGroups, setCollapsedGroups] = useState>>({ low_priority: true }); const queryClient = useQueryClient(); @@ -67,6 +72,14 @@ export function MyWorkView({ gcTime: ENTITY_LIST_GC_TIME_MS, refetchOnWindowFocus: false }); + const { data: myPrs = [], isFetching: myPrsFetching } = useQuery({ + queryKey: ["myPrs", authAccountKey], + queryFn: window.fallback.prs.listMine, + enabled: auth.status === "connected", + staleTime: ENTITY_LIST_STALE_TIME_MS, + gcTime: ENTITY_LIST_GC_TIME_MS, + refetchOnWindowFocus: false + }); const counts = useMemo( () => myWorkLanes.map((item) => @@ -98,22 +111,51 @@ export function MyWorkView({ mutationFn: (id: string) => window.fallback.notifications.unmute(id), onSuccess: () => invalidateAttentionQueries(queryClient) }); + const refreshMyPrs = useMutation({ + mutationFn: window.fallback.prs.refreshMine, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["myPrs"] }), + queryClient.invalidateQueries({ queryKey: ["myWorkAttention"] }) + ]); + } + }); + const login = auth.status === "connected" ? auth.login : undefined; + const authoredMyPrs = useMemo(() => filterMyPullRequests(myPrs, login), [login, myPrs]); + const authoredMyPrKeys = useMemo(() => new Set(authoredMyPrs.map(myPullRequestKey)), [authoredMyPrs]); + const myPrStatusCounts = useMemo(() => myPullRequestStatusCounts(authoredMyPrs), [authoredMyPrs]); + const workItems = useMemo(() => withoutAuthoredPrAttentionItems(allItems, authoredMyPrKeys), [allItems, authoredMyPrKeys]); const visible = useMemo(() => { const parsed = parseWorkQuery(query); - const filtered = allItems.filter((item) => matchesWorkQuery(item, parsed)); + const filtered = workItems.filter((item) => matchesWorkQuery(item, parsed)); return [...filtered].sort(compareWorkItems); - }, [allItems, query]); - const pageRows = useMemo(() => paginateItems(visible, page, ENTITY_PAGE_SIZE), [page, visible]); - const groupedPageRows = useMemo(() => groupWorkRows(pageRows), [pageRows]); - const loading = isFetching && allItems.length === 0; - const needsMeCount = counts[0]?.length ?? 0; + }, [query, workItems]); + const visibleMyPrs = useMemo(() => filterPullRequestsForMyWorkTab(authoredMyPrs, query), [authoredMyPrs, query]); + const workPageRows = useMemo(() => paginateItems(visible, page, ENTITY_PAGE_SIZE), [page, visible]); + const prPageRows = useMemo(() => paginateItems(visibleMyPrs, page, ENTITY_PAGE_SIZE), [page, visibleMyPrs]); + const groupedPageRows = useMemo(() => groupWorkRows(workPageRows), [workPageRows]); + const loading = activeTab === "my_prs" ? myPrsFetching && authoredMyPrs.length === 0 : isFetching && workItems.length === 0; + const needsMeCount = withoutAuthoredPrAttentionItems(counts[0] ?? [], authoredMyPrKeys).length; const laneEmptyCopy = myWorkLaneCopy[lane]; const canCollapseGroups = query.trim() === ""; + const activeItemCount = activeTab === "my_prs" ? visibleMyPrs.length : visible.length; - useEffect(() => setPage(1), [auth.status, authAccountKey, lane, query]); + useEffect(() => setPage(1), [activeTab, auth.status, authAccountKey, lane, query]); + useEffect(() => { + if (userSelectedTab || auth.status !== "connected" || myPrsFetching) return; + if (authoredMyPrs.length > 0) { + setActiveTab("my_prs"); + return; + } + setLane("needs_me"); + setActiveTab("needs_me"); + }, [auth.status, authoredMyPrs.length, myPrsFetching, setLane, userSelectedTab]); + useEffect(() => { + if (activeTab !== "my_prs") setActiveTab(lane); + }, [activeTab, lane]); useEffect(() => { - setPage((current) => Math.min(current, pageCountFor(visible.length, ENTITY_PAGE_SIZE))); - }, [visible.length]); + setPage((current) => Math.min(current, pageCountFor(activeItemCount, ENTITY_PAGE_SIZE))); + }, [activeItemCount]); return (
@@ -126,100 +168,263 @@ export function MyWorkView({

- {loading ? "Loading..." : `${compactCount(visible.length)} items`} + {loading ? "Loading..." : `${compactCount(activeItemCount)} items`}
+ {myWorkLanes.map((item, index) => ( ))}
- +
- {auth.status !== "connected" && visible.length === 0 && ( -
Connect GitHub to build your work queue.
- )} - {auth.status === "connected" && allItems.length === 0 && !loading && ( -
-
{laneEmptyCopy.emptyTitle}
-
{laneEmptyCopy.emptyDetail}
-
- )} - {allItems.length > 0 && visible.length === 0 && ( -
No work items match this search.
- )} - {groupedPageRows.map(({ group, rows }) => ( - - +
+ {auth.status !== "connected" && ( +
Connect GitHub to see your PRs.
+ )} + {auth.status === "connected" && authoredMyPrs.length === 0 && !myPrsFetching && ( +
No authored PRs are cached yet.
+ )} + {authoredMyPrs.length > 0 && visibleMyPrs.length === 0 && ( +
No authored PRs match this search.
+ )} + {prPageRows.map((pr) => ( + onPrClick({ repoId: pr.repoId, number: pr.number })} /> + ))} + + ) : ( + <> + {auth.status !== "connected" && visible.length === 0 && ( +
Connect GitHub to build your work queue.
+ )} + {auth.status === "connected" && workItems.length === 0 && !loading && ( +
+
{laneEmptyCopy.emptyTitle}
+
{laneEmptyCopy.emptyDetail}
- - {(!collapsedGroups[group.id] || !canCollapseGroups) && - rows.map((item) => ( - - item.entityType === "pull_request" && item.number - ? onPrClick({ repoId: item.repoId, number: item.number }) - : item.entityType === "issue" && item.number - ? onIssueClick({ repoId: item.repoId, number: item.number }) - : item.htmlUrl - ? void window.fallback.shell.openExternal(item.htmlUrl) - : undefined - } - onDone={() => (item.doneAt ? undoDone.mutate(item.id) : markDone.mutate(item.id))} - onSnoozeUntil={(until) => - item.snoozedUntil ? unsnooze.mutate(item.id) : snooze.mutate({ id: item.id, until: until ?? tomorrowIso() }) - } - onMute={() => (item.muted ? unmute.mutate(item.id) : mute.mutate(item.id))} - /> - ))} - - ))} - + )} + {workItems.length > 0 && visible.length === 0 && ( +
No work items match this search.
+ )} + {groupedPageRows.map(({ group, rows }) => ( + + + {(!collapsedGroups[group.id] || !canCollapseGroups) && + rows.map((item) => ( + + item.entityType === "pull_request" && item.number + ? onPrClick({ repoId: item.repoId, number: item.number }) + : item.entityType === "issue" && item.number + ? onIssueClick({ repoId: item.repoId, number: item.number }) + : item.htmlUrl + ? void window.fallback.shell.openExternal(item.htmlUrl) + : undefined + } + onDone={() => (item.doneAt ? undoDone.mutate(item.id) : markDone.mutate(item.id))} + onSnoozeUntil={(until) => + item.snoozedUntil ? unsnooze.mutate(item.id) : snooze.mutate({ id: item.id, until: until ?? tomorrowIso() }) + } + onMute={() => (item.muted ? unmute.mutate(item.id) : mute.mutate(item.id))} + /> + ))} + + ))} + + )} + ); } +function MyPullRequestRow({ pr, onClick }: { pr: PullRequestSummary; onClick: () => void }) { + const status = myPullRequestStatus(pr); + return ( +
+ +
+ +
+
+ +
+
+ ); +} + +function myPullRequestPreview(pr: PullRequestSummary): string { + const stats = [ + pr.checkState === "failing" + ? "Checks failing" + : pr.checkState === "pending" + ? "Checks pending" + : pr.checkState === "passing" + ? "Checks passing" + : null, + pr.reviewState ? reviewStateLabel(pr.reviewState) : null, + pr.changedFiles != null ? `${compactCount(pr.changedFiles)} changed files` : null + ].filter((item): item is string => Boolean(item)); + return stats.length > 0 ? stats.join(" · ") : "No review or check signal cached"; +} + +function myPullRequestDetail(pr: PullRequestSummary): string { + const parts = [ + pr.headBranch && pr.baseBranch ? `${pr.headBranch} into ${pr.baseBranch}` : pr.headBranch || pr.baseBranch, + pr.commentsCount != null || pr.reviewCommentsCount != null + ? `${compactCount((pr.commentsCount ?? 0) + (pr.reviewCommentsCount ?? 0))} comments` + : null + ].filter((item): item is string => Boolean(item)); + return parts.length > 0 ? parts.join(" · ") : "Authored by you"; +} + +function reviewStateLabel(value: string): string { + if (value === "changes requested") return "Changes requested"; + if (value === "approved") return "Approved"; + if (value === "reviewed") return "Reviewed"; + return value; +} + +function myPullRequestStatus(pr: PullRequestSummary): { label: string; className: string } | null { + if (pr.merged) return { label: "Merged", className: "bg-purple-500/10 text-purple-300" }; + if (pr.state === "closed") return { label: "Closed", className: "bg-red-500/10 text-red-300" }; + if (pr.isDraft) return { label: "Draft", className: "bg-white/[0.06] text-neutral-400" }; + if (pr.checkState === "failing") return { label: "Checks failing", className: "bg-red-500/10 text-red-300" }; + if (pr.checkState === "pending") return { label: "Checks pending", className: "bg-amber-500/10 text-amber-300" }; + return null; +} + +function withoutAuthoredPrAttentionItems(items: AttentionItem[], authoredPrKeys: Set): AttentionItem[] { + return items.filter((item) => !isAuthoredPrAttentionItem(item, authoredPrKeys)); +} + +function isAuthoredPrAttentionItem(item: AttentionItem, authoredPrKeys: Set): boolean { + return item.entityType === "pull_request" && item.number != null && authoredPrKeys.has(`${item.repoId}:${item.number}`); +} + +function filterPullRequestsForMyWorkTab(prs: PullRequestSummary[], query: string): PullRequestSummary[] { + const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean); + if (terms.length === 0) return prs; + return prs.filter((pr) => { + const haystack = [pr.title, pr.repoFullName, pr.number, pr.headBranch, pr.baseBranch, pr.state, pr.isDraft ? "draft" : null] + .filter((value) => value != null) + .join(" ") + .toLowerCase(); + return terms.every((term) => haystack.includes(term)); + }); +} + function AttentionWorkRow({ item, onClick, diff --git a/src/renderer/features/github-work/my-pull-requests.ts b/src/renderer/features/github-work/my-pull-requests.ts new file mode 100644 index 0000000..5ba18b7 --- /dev/null +++ b/src/renderer/features/github-work/my-pull-requests.ts @@ -0,0 +1,37 @@ +import type { PullRequestSummary } from "../../../shared/domain/github-work"; + +export function filterMyPullRequests(pullRequests: PullRequestSummary[], login?: string): PullRequestSummary[] { + return pullRequests.filter((pr) => sameLogin(pr.authorLogin, login) && !pr.merged).sort(byUpdatedDesc); +} + +export function myPullRequestKey(pr: Pick): string { + return `${pr.repoId}:${pr.number}`; +} + +export function myPullRequestStatusCounts(pullRequests: PullRequestSummary[]): { + open: number; + draft: number; + closed: number; +} { + return pullRequests.reduce( + (counts, pr) => { + if (pr.state === "closed") counts.closed += 1; + else if (pr.isDraft) counts.draft += 1; + else counts.open += 1; + return counts; + }, + { open: 0, draft: 0, closed: 0 } + ); +} + +function sameLogin(value: string | null | undefined, login?: string): boolean { + return Boolean(value && login && value.toLowerCase() === login.toLowerCase()); +} + +function byUpdatedDesc(a: { updatedAt: string | null }, b: { updatedAt: string | null }): number { + return timestamp(b.updatedAt) - timestamp(a.updatedAt); +} + +function timestamp(value: string | null): number { + return value ? Date.parse(value) : 0; +} diff --git a/tests/github-work/suite.ts b/tests/github-work/suite.ts index d7eca2b..09f8d8b 100644 --- a/tests/github-work/suite.ts +++ b/tests/github-work/suite.ts @@ -4,6 +4,7 @@ await runScriptTests([ "scripts/test-github-work-cache-persistence.ts", "scripts/test-entity-query-filters.ts", "scripts/test-entity-filter-suggestions.ts", + "scripts/test-my-prs-section.ts", "scripts/test-saved-entity-searches.ts", "scripts/test-offline-actions.ts", "scripts/test-pr-review-drafts.ts", From 7d2ea600b16102223b8f47215ca05b0f58cdae52 Mon Sep 17 00:00:00 2001 From: Gal Zahavi <38544478+galz10@users.noreply.github.com> Date: Sun, 24 May 2026 20:50:19 -0700 Subject: [PATCH 2/2] Fix My PRs tab behavior --- scripts/test-my-prs-section.ts | 25 ++- .../features/github-work/MyPullRequestRow.tsx | 87 ++++++++++ .../features/github-work/MyWorkView.tsx | 156 ++++-------------- .../features/github-work/my-pull-requests.ts | 7 +- 4 files changed, 143 insertions(+), 132 deletions(-) create mode 100644 src/renderer/features/github-work/MyPullRequestRow.tsx diff --git a/scripts/test-my-prs-section.ts b/scripts/test-my-prs-section.ts index 803324d..8bb4e13 100644 --- a/scripts/test-my-prs-section.ts +++ b/scripts/test-my-prs-section.ts @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import type { PullRequestSummary } from "../src/shared/domain/github-work.js"; import { + authoredMyPullRequests, filterMyPullRequests, - myPullRequestKey, myPullRequestStatusCounts } from "../src/renderer/features/github-work/my-pull-requests.js"; @@ -66,17 +66,32 @@ const prs: PullRequestSummary[] = [ }) ]; -const mine = filterMyPullRequests(prs, "mona"); -const counts = myPullRequestStatusCounts(mine); +const authored = authoredMyPullRequests(prs, "mona"); +const activeMine = filterMyPullRequests(prs, "mona", ""); +const closedMine = filterMyPullRequests(prs, "mona", "is:closed"); +const allMine = filterMyPullRequests(prs, "mona", "is:all"); +const repoQualifiedMine = filterMyPullRequests(prs, "mona", "repo:octo/repo type:pr"); +const counts = myPullRequestStatusCounts(authored); assert.deepEqual( - mine.map((item) => item.number), + activeMine.map((item) => item.number), + [8, 2] +); +assert.deepEqual( + closedMine.map((item) => item.number), + [6] +); +assert.deepEqual( + allMine.map((item) => item.number), [6, 8, 2] ); +assert.deepEqual( + repoQualifiedMine.map((item) => item.number), + [8, 2] +); assert.equal(counts.open, 1); assert.equal(counts.draft, 1); assert.equal(counts.closed, 1); -assert.equal(myPullRequestKey(mine[0]!), "repo:6"); console.log("My PRs section tests ok"); diff --git a/src/renderer/features/github-work/MyPullRequestRow.tsx b/src/renderer/features/github-work/MyPullRequestRow.tsx new file mode 100644 index 0000000..7267e37 --- /dev/null +++ b/src/renderer/features/github-work/MyPullRequestRow.tsx @@ -0,0 +1,87 @@ +import { GitPullRequestIcon as GitHubPullRequestIcon } from "@primer/octicons-react"; +import type { PullRequestSummary } from "../../../shared/domain/github-work"; +import { compactCount, formatRelative } from "../../lib/format"; + +export function MyPullRequestRow({ pr, onClick }: { pr: PullRequestSummary; onClick: () => void }) { + const status = myPullRequestStatus(pr); + return ( +
+ + + +
+ +
+
+ +
+
+ ); +} + +function myPullRequestPreview(pr: PullRequestSummary): string { + const stats = [ + pr.checkState === "failing" + ? "Checks failing" + : pr.checkState === "pending" + ? "Checks pending" + : pr.checkState === "passing" + ? "Checks passing" + : null, + pr.reviewState ? reviewStateLabel(pr.reviewState) : null, + pr.changedFiles != null ? `${compactCount(pr.changedFiles)} changed files` : null + ].filter((item): item is string => Boolean(item)); + return stats.length > 0 ? stats.join(" - ") : "No review or check signal cached"; +} + +function myPullRequestDetail(pr: PullRequestSummary): string { + const parts = [ + pr.headBranch && pr.baseBranch ? `${pr.headBranch} into ${pr.baseBranch}` : pr.headBranch || pr.baseBranch, + pr.commentsCount != null || pr.reviewCommentsCount != null + ? `${compactCount((pr.commentsCount ?? 0) + (pr.reviewCommentsCount ?? 0))} comments` + : null + ].filter((item): item is string => Boolean(item)); + return parts.length > 0 ? parts.join(" - ") : "Authored by you"; +} + +function reviewStateLabel(value: string): string { + if (value === "changes requested") return "Changes requested"; + if (value === "approved") return "Approved"; + if (value === "reviewed") return "Reviewed"; + return value; +} + +function myPullRequestStatus(pr: PullRequestSummary): { label: string; className: string } { + if (pr.merged) return { label: "Merged", className: "bg-purple-500/10 text-purple-300" }; + if (pr.state === "closed") return { label: "Closed", className: "bg-red-500/10 text-red-300" }; + if (pr.isDraft) return { label: "Draft", className: "bg-white/[0.06] text-neutral-400" }; + if (pr.checkState === "failing") return { label: "Checks failing", className: "bg-red-500/10 text-red-300" }; + if (pr.checkState === "pending") return { label: "Checks pending", className: "bg-amber-500/10 text-amber-300" }; + return { label: "Open", className: "bg-green-500/10 text-green-300" }; +} diff --git a/src/renderer/features/github-work/MyWorkView.tsx b/src/renderer/features/github-work/MyWorkView.tsx index bd898dd..d7858c8 100644 --- a/src/renderer/features/github-work/MyWorkView.tsx +++ b/src/renderer/features/github-work/MyWorkView.tsx @@ -7,7 +7,6 @@ import { } from "@primer/octicons-react"; import { Archive, BellOff, ChevronDown, ChevronRight, Clock, Copy, ExternalLink, MoreHorizontal, RefreshCw } from "lucide-react"; import type { AuthState } from "../../../shared/domain/auth"; -import type { PullRequestSummary } from "../../../shared/domain/github-work"; import type { AttentionItem, AttentionLane } from "../../../shared/attention"; import { myWorkLaneCopy, myWorkLaneOrder } from "../../../shared/product-coherence"; import { pageCountFor, paginateItems, PaginationFooter } from "../../components/PaginationFooter"; @@ -23,8 +22,9 @@ import { type EntityQueryKind, type WorkPriorityGroupId } from "./work-query-language"; -import { MyWorkSearchInput } from "./EntitySearchInput"; -import { filterMyPullRequests, myPullRequestKey, myPullRequestStatusCounts } from "./my-pull-requests"; +import { MyWorkSearchInput, SimpleSearchInput } from "./EntitySearchInput"; +import { MyPullRequestRow } from "./MyPullRequestRow"; +import { authoredMyPullRequests, filterMyPullRequests, myPullRequestStatusCounts } from "./my-pull-requests"; const ENTITY_PAGE_SIZE = 50; const ENTITY_LIST_STALE_TIME_MS = 5 * 60_000; @@ -121,21 +121,21 @@ export function MyWorkView({ } }); const login = auth.status === "connected" ? auth.login : undefined; - const authoredMyPrs = useMemo(() => filterMyPullRequests(myPrs, login), [login, myPrs]); - const authoredMyPrKeys = useMemo(() => new Set(authoredMyPrs.map(myPullRequestKey)), [authoredMyPrs]); + const authoredMyPrs = useMemo(() => authoredMyPullRequests(myPrs, login), [login, myPrs]); + const activeAuthoredMyPrs = useMemo(() => filterMyPullRequests(myPrs, login, ""), [login, myPrs]); const myPrStatusCounts = useMemo(() => myPullRequestStatusCounts(authoredMyPrs), [authoredMyPrs]); - const workItems = useMemo(() => withoutAuthoredPrAttentionItems(allItems, authoredMyPrKeys), [allItems, authoredMyPrKeys]); const visible = useMemo(() => { const parsed = parseWorkQuery(query); - const filtered = workItems.filter((item) => matchesWorkQuery(item, parsed)); + const filtered = allItems.filter((item) => matchesWorkQuery(item, parsed)); return [...filtered].sort(compareWorkItems); - }, [query, workItems]); - const visibleMyPrs = useMemo(() => filterPullRequestsForMyWorkTab(authoredMyPrs, query), [authoredMyPrs, query]); + }, [allItems, query]); + const visibleMyPrs = useMemo(() => filterMyPullRequests(myPrs, login, query), [login, myPrs, query]); + const laneCounts = useMemo(() => counts.map((items) => items.length), [counts]); const workPageRows = useMemo(() => paginateItems(visible, page, ENTITY_PAGE_SIZE), [page, visible]); const prPageRows = useMemo(() => paginateItems(visibleMyPrs, page, ENTITY_PAGE_SIZE), [page, visibleMyPrs]); const groupedPageRows = useMemo(() => groupWorkRows(workPageRows), [workPageRows]); - const loading = activeTab === "my_prs" ? myPrsFetching && authoredMyPrs.length === 0 : isFetching && workItems.length === 0; - const needsMeCount = withoutAuthoredPrAttentionItems(counts[0] ?? [], authoredMyPrKeys).length; + const loading = activeTab === "my_prs" ? myPrsFetching && authoredMyPrs.length === 0 : isFetching && allItems.length === 0; + const needsMeCount = laneCounts[0] ?? 0; const laneEmptyCopy = myWorkLaneCopy[lane]; const canCollapseGroups = query.trim() === ""; const activeItemCount = activeTab === "my_prs" ? visibleMyPrs.length : visible.length; @@ -143,13 +143,13 @@ export function MyWorkView({ useEffect(() => setPage(1), [activeTab, auth.status, authAccountKey, lane, query]); useEffect(() => { if (userSelectedTab || auth.status !== "connected" || myPrsFetching) return; - if (authoredMyPrs.length > 0) { + if (activeAuthoredMyPrs.length > 0) { setActiveTab("my_prs"); return; } setLane("needs_me"); setActiveTab("needs_me"); - }, [auth.status, authoredMyPrs.length, myPrsFetching, setLane, userSelectedTab]); + }, [activeAuthoredMyPrs.length, auth.status, myPrsFetching, setLane, userSelectedTab]); useEffect(() => { if (activeTab !== "my_prs") setActiveTab(lane); }, [activeTab, lane]); @@ -187,8 +187,8 @@ export function MyWorkView({ }`} > My PRs - {authoredMyPrs.length ? ( - {compactCount(authoredMyPrs.length)} + {activeAuthoredMyPrs.length ? ( + {compactCount(activeAuthoredMyPrs.length)} ) : null} {myWorkLanes.map((item, index) => ( @@ -207,15 +207,25 @@ export function MyWorkView({ }`} > {item.label} - {withoutAuthoredPrAttentionItems(counts[index] ?? [], authoredMyPrKeys).length ? ( - - {compactCount(withoutAuthoredPrAttentionItems(counts[index] ?? [], authoredMyPrKeys).length)} - + {laneCounts[index] ? ( + {compactCount(laneCounts[index] ?? 0)} ) : null} ))} - + {activeTab === "my_prs" ? ( + + ) : ( + + )}
@@ -258,13 +268,13 @@ export function MyWorkView({ {auth.status !== "connected" && visible.length === 0 && (
Connect GitHub to build your work queue.
)} - {auth.status === "connected" && workItems.length === 0 && !loading && ( + {auth.status === "connected" && allItems.length === 0 && !loading && (
{laneEmptyCopy.emptyTitle}
{laneEmptyCopy.emptyDetail}
)} - {workItems.length > 0 && visible.length === 0 && ( + {allItems.length > 0 && visible.length === 0 && (
No work items match this search.
)} {groupedPageRows.map(({ group, rows }) => ( @@ -323,108 +333,6 @@ export function MyWorkView({ ); } -function MyPullRequestRow({ pr, onClick }: { pr: PullRequestSummary; onClick: () => void }) { - const status = myPullRequestStatus(pr); - return ( -
- -
- -
-
- -
-
- ); -} - -function myPullRequestPreview(pr: PullRequestSummary): string { - const stats = [ - pr.checkState === "failing" - ? "Checks failing" - : pr.checkState === "pending" - ? "Checks pending" - : pr.checkState === "passing" - ? "Checks passing" - : null, - pr.reviewState ? reviewStateLabel(pr.reviewState) : null, - pr.changedFiles != null ? `${compactCount(pr.changedFiles)} changed files` : null - ].filter((item): item is string => Boolean(item)); - return stats.length > 0 ? stats.join(" · ") : "No review or check signal cached"; -} - -function myPullRequestDetail(pr: PullRequestSummary): string { - const parts = [ - pr.headBranch && pr.baseBranch ? `${pr.headBranch} into ${pr.baseBranch}` : pr.headBranch || pr.baseBranch, - pr.commentsCount != null || pr.reviewCommentsCount != null - ? `${compactCount((pr.commentsCount ?? 0) + (pr.reviewCommentsCount ?? 0))} comments` - : null - ].filter((item): item is string => Boolean(item)); - return parts.length > 0 ? parts.join(" · ") : "Authored by you"; -} - -function reviewStateLabel(value: string): string { - if (value === "changes requested") return "Changes requested"; - if (value === "approved") return "Approved"; - if (value === "reviewed") return "Reviewed"; - return value; -} - -function myPullRequestStatus(pr: PullRequestSummary): { label: string; className: string } | null { - if (pr.merged) return { label: "Merged", className: "bg-purple-500/10 text-purple-300" }; - if (pr.state === "closed") return { label: "Closed", className: "bg-red-500/10 text-red-300" }; - if (pr.isDraft) return { label: "Draft", className: "bg-white/[0.06] text-neutral-400" }; - if (pr.checkState === "failing") return { label: "Checks failing", className: "bg-red-500/10 text-red-300" }; - if (pr.checkState === "pending") return { label: "Checks pending", className: "bg-amber-500/10 text-amber-300" }; - return null; -} - -function withoutAuthoredPrAttentionItems(items: AttentionItem[], authoredPrKeys: Set): AttentionItem[] { - return items.filter((item) => !isAuthoredPrAttentionItem(item, authoredPrKeys)); -} - -function isAuthoredPrAttentionItem(item: AttentionItem, authoredPrKeys: Set): boolean { - return item.entityType === "pull_request" && item.number != null && authoredPrKeys.has(`${item.repoId}:${item.number}`); -} - -function filterPullRequestsForMyWorkTab(prs: PullRequestSummary[], query: string): PullRequestSummary[] { - const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean); - if (terms.length === 0) return prs; - return prs.filter((pr) => { - const haystack = [pr.title, pr.repoFullName, pr.number, pr.headBranch, pr.baseBranch, pr.state, pr.isDraft ? "draft" : null] - .filter((value) => value != null) - .join(" ") - .toLowerCase(); - return terms.every((term) => haystack.includes(term)); - }); -} - function AttentionWorkRow({ item, onClick, diff --git a/src/renderer/features/github-work/my-pull-requests.ts b/src/renderer/features/github-work/my-pull-requests.ts index 5ba18b7..54541d9 100644 --- a/src/renderer/features/github-work/my-pull-requests.ts +++ b/src/renderer/features/github-work/my-pull-requests.ts @@ -1,11 +1,12 @@ import type { PullRequestSummary } from "../../../shared/domain/github-work"; +import { filterPullRequests, queryWithDefaultOpen } from "./entity-query"; -export function filterMyPullRequests(pullRequests: PullRequestSummary[], login?: string): PullRequestSummary[] { +export function authoredMyPullRequests(pullRequests: PullRequestSummary[], login?: string): PullRequestSummary[] { return pullRequests.filter((pr) => sameLogin(pr.authorLogin, login) && !pr.merged).sort(byUpdatedDesc); } -export function myPullRequestKey(pr: Pick): string { - return `${pr.repoId}:${pr.number}`; +export function filterMyPullRequests(pullRequests: PullRequestSummary[], login: string | undefined, query: string): PullRequestSummary[] { + return filterPullRequests(authoredMyPullRequests(pullRequests, login), queryWithDefaultOpen(query), login); } export function myPullRequestStatusCounts(pullRequests: PullRequestSummary[]): {