From cdf9b4267e107479215dd68abe7af5a83c78e97e Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Sun, 12 Apr 2026 17:48:47 -0400 Subject: [PATCH] Add repo sidebar activity cards for PRs, issues, and discussions Portal-based side panel outside the main content card shows latest 5 open PRs, issues, and discussions (when enabled) for the current repo. Includes animated show/hide with spring transitions, a toggle button on the card edge, and open counts from lightweight API calls (per_page=1 + Link header parsing for REST, GraphQL totalCount for discussions). --- .../components/layouts/dashboard-layout.tsx | 33 ++- .../layouts/dashboard-side-panel.tsx | 134 +++++++++ .../components/repo/code-explorer-toolbar.tsx | 2 +- .../components/repo/repo-activity-cards.tsx | 270 ++++++++++++++++++ .../components/repo/repo-overview-page.tsx | 68 +++-- apps/dashboard/src/lib/github.functions.ts | 117 +++++++- apps/dashboard/src/lib/github.query.ts | 18 ++ apps/dashboard/src/lib/github.types.ts | 20 ++ 8 files changed, 627 insertions(+), 35 deletions(-) create mode 100644 apps/dashboard/src/components/layouts/dashboard-side-panel.tsx create mode 100644 apps/dashboard/src/components/repo/repo-activity-cards.tsx diff --git a/apps/dashboard/src/components/layouts/dashboard-layout.tsx b/apps/dashboard/src/components/layouts/dashboard-layout.tsx index 81f93ee..d1acc52 100644 --- a/apps/dashboard/src/components/layouts/dashboard-layout.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-layout.tsx @@ -9,6 +9,12 @@ import { useGitHubRevalidation } from "#/lib/use-github-revalidation"; import { useHasMounted } from "#/lib/use-has-mounted"; import { DashboardBottomBar } from "./dashboard-bottombar"; import { DashboardMobileNav } from "./dashboard-mobile-nav"; +import { + SidePanelProvider, + SidePanelSlot, + SidePanelToggle, + useSidePanelSlot, +} from "./dashboard-side-panel"; import { DashboardTopbar } from "./dashboard-topbar"; const CommandPalette = lazy(() => @@ -54,6 +60,8 @@ export function DashboardLayout() { : undefined; const tabsReady = hasMounted && Boolean(pullsQuery.data && issuesQuery.data); + const sidePanel = useSidePanelSlot(); + return (
-
-
-
- + +
+
+
+ +
+
+
-
+ void; +}; + +const SidePanelContext = createContext({ + node: null, + collapsed: false, + hasContent: false, + toggle: () => {}, +}); + +export function useSidePanelSlot() { + const [node, setNode] = useState(null); + const [collapsed, setCollapsed] = useState(false); + const [hasContent, setHasContent] = useState(false); + const toggle = useCallback(() => setCollapsed((c) => !c), []); + return { + node, + setNode, + collapsed, + hasContent, + setHasContent, + toggle, + } as const; +} + +export function SidePanelProvider({ + value, + children, +}: { + value: SidePanelState; + children: React.ReactNode; +}) { + return {children}; +} + +export function SidePanelPortal({ children }: { children: React.ReactNode }) { + const { node } = useContext(SidePanelContext); + if (!node) return null; + return createPortal(children, node); +} + +export function SidePanelSlot({ + slotRef, + collapsed, + onHasContent, +}: { + slotRef: (el: HTMLDivElement | null) => void; + collapsed: boolean; + onHasContent: (v: boolean) => void; +}) { + const [hasChildren, setHasChildren] = useState(false); + const innerRef = useRef(null); + + const refCallback = useCallback( + (el: HTMLDivElement | null) => { + innerRef.current = el; + slotRef(el); + }, + [slotRef], + ); + + useEffect(() => { + const el = innerRef.current; + if (!el) return; + + const check = () => { + const has = el.childNodes.length > 0; + setHasChildren(has); + onHasContent(has); + }; + + check(); + const observer = new MutationObserver(check); + observer.observe(el, { childList: true }); + el.addEventListener("sidepanel-content", check); + return () => { + observer.disconnect(); + el.removeEventListener("sidepanel-content", check); + }; + }, [onHasContent]); + + const show = hasChildren && !collapsed; + + return ( + + +
+ + + ); +} + +export function SidePanelToggle() { + const { collapsed, toggle, hasContent } = useContext(SidePanelContext); + if (!hasContent) return null; + + return ( + + ); +} diff --git a/apps/dashboard/src/components/repo/code-explorer-toolbar.tsx b/apps/dashboard/src/components/repo/code-explorer-toolbar.tsx index 4ca5a84..ce948f7 100644 --- a/apps/dashboard/src/components/repo/code-explorer-toolbar.tsx +++ b/apps/dashboard/src/components/repo/code-explorer-toolbar.tsx @@ -89,7 +89,7 @@ function BranchSelector({
+ ); +} + +function ActivityCard({ + title, + icon: Icon, + items, + count, + viewAllHref, + renderItem, +}: { + title: string; + icon: React.ComponentType<{ + size?: number; + strokeWidth?: number; + className?: string; + }>; + items: T[] | undefined; + count?: number; + viewAllHref: string; + renderItem: (item: T) => React.ReactNode; +}) { + return ( +
+
+ +

+ {title} +

+ {count != null && ( + + {count} + + )} +
+
+ {!items ? ( + + ) : items.length === 0 ? ( +

+ No open {title.toLowerCase()} +

+ ) : ( + <> + {items.map(renderItem)} + + View all + + + )} +
+
+ ); +} + +function PullItem({ pr }: { pr: PullSummary }) { + const { icon: StateIcon, color } = getPrStateConfig(pr); + const href = `/${pr.repository.owner}/${pr.repository.name}/pulls/${pr.number}`; + + return ( + +
+ +
+
+

{pr.title}

+

+ #{pr.number} · {formatRelativeTime(pr.updatedAt)} +

+
+ + ); +} + +function IssueItem({ issue }: { issue: IssueSummary }) { + const color = + issue.state === "closed" + ? issue.stateReason === "not_planned" + ? "text-muted-foreground" + : "text-purple-500" + : "text-green-500"; + const href = `/${issue.repository.owner}/${issue.repository.name}/issues/${issue.number}`; + + return ( + +
+ +
+
+

{issue.title}

+

+ #{issue.number} · {formatRelativeTime(issue.updatedAt)} +

+
+ + ); +} + +function DiscussionItem({ + discussion, +}: { + discussion: DiscussionSummary; + repo: RepoOverview; +}) { + return ( + +
+ +
+
+

{discussion.title}

+

+ {discussion.category && ( + <> + {discussion.category} + · + + )} + {discussion.isAnswered && ( + <> + Answered + · + + )} + {formatRelativeTime(discussion.updatedAt)} +

+
+
+ ); +} + +function ActivityCardSkeleton() { + return ( +
+ {[0, 1, 2].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/apps/dashboard/src/components/repo/repo-overview-page.tsx b/apps/dashboard/src/components/repo/repo-overview-page.tsx index ef4147b..586cd3d 100644 --- a/apps/dashboard/src/components/repo/repo-overview-page.tsx +++ b/apps/dashboard/src/components/repo/repo-overview-page.tsx @@ -1,6 +1,7 @@ import { useQuery } from "@tanstack/react-query"; import { getRouteApi } from "@tanstack/react-router"; import { useState } from "react"; +import { SidePanelPortal } from "#/components/layouts/dashboard-side-panel"; import { githubRepoOverviewQueryOptions, githubRepoTreeQueryOptions, @@ -10,6 +11,7 @@ import { useRegisterTab } from "#/lib/use-register-tab"; import { CodeExplorerToolbar } from "./code-explorer-toolbar"; import { FileTree } from "./file-tree"; import { LatestCommitBar } from "./latest-commit-bar"; +import { RepoActivityCards } from "./repo-activity-cards"; import { RepoHeader } from "./repo-header"; import { RepoMarkdownFiles } from "./repo-markdown-files"; import { RepoOverviewSkeleton } from "./repo-overview-skeleton"; @@ -59,41 +61,51 @@ export function RepoOverviewPage() { if (!repoData) return ; return ( -
-
-
- + <> +
+
+
+ - + -
- - {treeQuery.data ? ( - - ) : ( - +
+ + {treeQuery.data ? ( + + ) : ( + + )} +
+ + {treeQuery.data && ( + )}
- {treeQuery.data && ( - - )} +
- -
-
+ + + + ); } diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 9e23e75..3d229e6 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -7,6 +7,7 @@ import type { ContributionWeek, CreateLabelInput, CreateReviewCommentInput, + DiscussionsResult, GitHubActor, GitHubContributionCalendar, GitHubLabel, @@ -4371,7 +4372,14 @@ export const getRepoOverview = createServerFn({ method: "GET" }) const context = await getGitHubContext(); if (!context) return null; - const [repoRes, branchesRes, tagsRes, commitsRes] = await Promise.all([ + const [ + repoRes, + branchesRes, + tagsRes, + commitsRes, + openPullsRes, + openIssuesRes, + ] = await Promise.all([ context.octokit.rest.repos.get({ owner: data.owner, repo: data.repo, @@ -4391,6 +4399,18 @@ export const getRepoOverview = createServerFn({ method: "GET" }) repo: data.repo, per_page: 1, }), + context.octokit.rest.pulls.list({ + owner: data.owner, + repo: data.repo, + state: "open", + per_page: 1, + }), + context.octokit.rest.issues.listForRepo({ + owner: data.owner, + repo: data.repo, + state: "open", + per_page: 1, + }), ]); const repo = repoRes.data; @@ -4402,6 +4422,16 @@ export const getRepoOverview = createServerFn({ method: "GET" }) const tagCount = parseLinkHeaderLastPage(tagsRes.headers.link as string | undefined) ?? tagsRes.data.length; + const openPullCount = + parseLinkHeaderLastPage( + openPullsRes.headers.link as string | undefined, + ) ?? openPullsRes.data.length; + // issues.listForRepo includes PRs, so subtract pull count for pure issues + const openIssueAndPrCount = + parseLinkHeaderLastPage( + openIssuesRes.headers.link as string | undefined, + ) ?? openIssuesRes.data.length; + const openIssueCount = Math.max(0, openIssueAndPrCount - openPullCount); const latestCommit = commitsRes.data[0] ? { @@ -4434,10 +4464,95 @@ export const getRepoOverview = createServerFn({ method: "GET" }) ownerAvatarUrl: repo.owner.avatar_url, branchCount, tagCount, + openPullCount, + openIssueCount, + hasDiscussions: !!(repo as Record).has_discussions, latestCommit, }; }); +// --------------------------------------------------------------------------- +// Repository discussions (GraphQL-only) +// --------------------------------------------------------------------------- + +type RepoDiscussionsInput = { + owner: string; + repo: string; + first?: number; +}; + +type GraphQLDiscussionsResponse = { + repository: { + discussions: { + totalCount: number; + nodes: Array<{ + number: number; + title: string; + createdAt: string; + updatedAt: string; + author: { login: string; avatarUrl: string } | null; + category: { name: string; emojiHTML: string } | null; + comments: { totalCount: number }; + answerChosenAt: string | null; + url: string; + }>; + }; + }; +}; + +export const getRepoDiscussions = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) return { discussions: [], totalCount: 0 }; + + try { + const response = + await context.octokit.graphql( + `query($owner: String!, $repo: String!, $first: Int!) { + repository(owner: $owner, name: $repo) { + discussions(first: $first, orderBy: { field: UPDATED_AT, direction: DESC }) { + totalCount + nodes { + number + title + createdAt + updatedAt + author { login avatarUrl } + category { name emojiHTML } + comments { totalCount } + answerChosenAt + url + } + } + } + }`, + { + owner: data.owner, + repo: data.repo, + first: data.first ?? 5, + }, + ); + + return { + totalCount: response.repository.discussions.totalCount, + discussions: response.repository.discussions.nodes.map((d) => ({ + number: d.number, + title: d.title, + createdAt: d.createdAt, + updatedAt: d.updatedAt, + author: d.author, + category: d.category?.name ?? null, + comments: d.comments.totalCount, + isAnswered: d.answerChosenAt !== null, + url: d.url, + })), + }; + } catch { + return { discussions: [], totalCount: 0 }; + } + }); + function parseLinkHeaderLastPage(link: string | undefined): number | null { if (!link) return null; const match = link.match(/[&?]page=(\d+)[^>]*>;\s*rel="last"/); diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts index 5ffd4d8..e8e8650 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -23,6 +23,7 @@ import { getRepoBranches, getRepoCollaborators, getRepoContributors, + getRepoDiscussions, getRepoFileContent, getRepoLabels, getRepoOverview, @@ -198,6 +199,10 @@ export const githubQueryKeys = { scope: GitHubQueryScope, input: { owner: string; repo: string }, ) => ["github", scope.userId, "repo", "contributors", input] as const, + discussions: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string }, + ) => ["github", scope.userId, "repo", "discussions", input] as const, }, issues: { mine: (scope: GitHubQueryScope) => @@ -634,3 +639,16 @@ export function githubRepoFileContentQueryOptions( meta: persistedMeta, }); } + +export function githubRepoDiscussionsQueryOptions( + scope: GitHubQueryScope, + input: { owner: string; repo: string }, +) { + return queryOptions({ + queryKey: githubQueryKeys.repo.discussions(scope, input), + queryFn: () => getRepoDiscussions({ data: input }), + staleTime: githubCachePolicy.list.staleTimeMs, + gcTime: githubCachePolicy.list.gcTimeMs, + meta: persistedMeta, + }); +} diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index d968b67..a0fbe49 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -408,6 +408,9 @@ export type RepoOverview = { ownerAvatarUrl: string; branchCount: number; tagCount: number; + openPullCount: number; + openIssueCount: number; + hasDiscussions: boolean; latestCommit: { sha: string; message: string; @@ -478,3 +481,20 @@ export type UserActivityEvent = { url: string; } | null; }; + +export type DiscussionSummary = { + number: number; + title: string; + createdAt: string; + updatedAt: string; + author: { login: string; avatarUrl: string } | null; + category: string | null; + comments: number; + isAnswered: boolean; + url: string; +}; + +export type DiscussionsResult = { + discussions: DiscussionSummary[]; + totalCount: number; +};