diff --git a/apps/dashboard/src/components/inbox/inbox-page.tsx b/apps/dashboard/src/components/inbox/inbox-page.tsx
new file mode 100644
index 0000000..86124a5
--- /dev/null
+++ b/apps/dashboard/src/components/inbox/inbox-page.tsx
@@ -0,0 +1,839 @@
+import {
+ ArchiveDownIcon,
+ CheckIcon,
+ CommentIcon,
+ ExternalLinkIcon,
+ GitMergeIcon,
+ GitPullRequestClosedIcon,
+ GitPullRequestIcon,
+ InboxIcon,
+ IssuesIcon,
+ MoreHorizontalIcon,
+ NotificationIcon,
+} from "@diffkit/icons";
+import { Button } from "@diffkit/ui/components/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@diffkit/ui/components/dropdown-menu";
+import { Spinner } from "@diffkit/ui/components/spinner";
+import { cn } from "@diffkit/ui/lib/utils";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { getRouteApi, Link } from "@tanstack/react-router";
+import { AnimatePresence, motion } from "motion/react";
+import { memo, useCallback, useMemo, useState } from "react";
+import { IssueDetailContent } from "#/components/issues/detail/issue-detail-page";
+import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading";
+import { PullDetailContent } from "#/components/pulls/detail/pull-detail-page";
+import { formatRelativeTime } from "#/lib/format-relative-time";
+import {
+ markAllNotificationsRead,
+ markNotificationDone,
+ markNotificationRead,
+} from "#/lib/github.functions";
+import {
+ type GitHubQueryScope,
+ githubIssuePageQueryOptions,
+ githubNotificationsQueryOptions,
+ githubPullPageQueryOptions,
+ githubQueryKeys,
+} from "#/lib/github.query";
+import type {
+ NotificationItem,
+ NotificationParticipant,
+ NotificationsResult,
+} from "#/lib/github.types";
+import { useHasMounted } from "#/lib/use-has-mounted";
+
+const routeApi = getRouteApi("/_protected/inbox");
+
+type InboxFilter = "unread" | "all";
+
+const AVATAR_MAX_VISIBLE = 4;
+const AVATAR_SIZE = 24;
+const AVATAR_OVERLAP = 8;
+
+// Hoisted regexes — avoids recreation per call (js-hoist-regexp)
+const RE_PULLS_NUMBER = /\/(?:pulls|issues)\/(\d+)$/;
+const RE_API_PULLS = /\/repos\/[^/]+\/[^/]+\/pulls\/(\d+)$/;
+const RE_API_ISSUES = /\/repos\/[^/]+\/[^/]+\/issues\/(\d+)$/;
+const RE_API_COMMITS = /\/repos\/[^/]+\/[^/]+\/commits\/([a-f0-9]+)$/;
+const RE_API_RELEASES = /\/repos\/[^/]+\/[^/]+\/releases\/(\d+)$/;
+
+function AvatarStack({
+ participants,
+}: {
+ participants: NotificationParticipant[] | undefined;
+}) {
+ if (!participants || participants.length === 0) return null;
+
+ const visible = participants.slice(0, AVATAR_MAX_VISIBLE);
+ const overflow = participants.length - AVATAR_MAX_VISIBLE;
+ const totalWidth =
+ AVATAR_SIZE +
+ (visible.length - 1 + (overflow > 0 ? 1 : 0)) *
+ (AVATAR_SIZE - AVATAR_OVERLAP);
+
+ return (
+
+ {visible.map((p, i) => (
+

+ ))}
+ {overflow > 0 && (
+
+ +{overflow}
+
+ )}
+
+ );
+}
+
+export function InboxPage() {
+ const { user } = routeApi.useRouteContext();
+ const scope = useMemo(() => ({ userId: user.id }), [user.id]);
+ const hasMounted = useHasMounted();
+ const [selectedId, setSelectedId] = useState(null);
+ const [filter, setFilter] = useState("unread");
+
+ const queryInput = { all: filter === "all" };
+ const query = useQuery({
+ ...githubNotificationsQueryOptions(scope, queryInput),
+ enabled: hasMounted,
+ });
+
+ const queryClient = useQueryClient();
+ const notifications = query.data?.notifications ?? [];
+ const selected = notifications.find((n) => n.id === selectedId) ?? null;
+
+ const handleSelect = useCallback(
+ (notification: NotificationItem) => {
+ setSelectedId(notification.id);
+
+ // Prefetch the next notification's detail data
+ const idx = notifications.findIndex((n) => n.id === notification.id);
+ const next = idx >= 0 ? notifications[idx + 1] : null;
+ if (next) {
+ const parsed = parseSubjectRef(next);
+ if (parsed?.type === "PullRequest") {
+ queryClient.prefetchQuery(
+ githubPullPageQueryOptions(scope, {
+ owner: parsed.owner,
+ repo: parsed.repo,
+ pullNumber: parsed.number,
+ }),
+ );
+ } else if (parsed?.type === "Issue") {
+ queryClient.prefetchQuery(
+ githubIssuePageQueryOptions(scope, {
+ owner: parsed.owner,
+ repo: parsed.repo,
+ issueNumber: parsed.number,
+ }),
+ );
+ }
+ }
+ },
+ [notifications, queryClient, scope],
+ );
+
+ if (query.error) throw query.error;
+ if (!hasMounted || (!query.data && query.isLoading))
+ return ;
+
+ return (
+
+
+
+
+ );
+}
+
+const InboxSidebar = memo(function InboxSidebar({
+ notifications,
+ selectedId,
+ onSelect,
+ filter,
+ onFilterChange,
+ scope,
+ isRefetching,
+}: {
+ notifications: NotificationItem[];
+ selectedId: string | null;
+ onSelect: (notification: NotificationItem) => void;
+ filter: InboxFilter;
+ onFilterChange: (filter: InboxFilter) => void;
+ scope: GitHubQueryScope;
+ isRefetching: boolean;
+}) {
+ const queryClient = useQueryClient();
+ const queryKey = githubQueryKeys.notifications.list(scope, {
+ all: filter === "all",
+ });
+ const markAllRead = useMutation({
+ mutationFn: () => markAllNotificationsRead(),
+ onMutate: () => {
+ const prev = queryClient.getQueryData(queryKey);
+ if (prev) {
+ queryClient.setQueryData(queryKey, {
+ notifications: prev.notifications.map((n) => ({
+ ...n,
+ unread: false,
+ })),
+ });
+ }
+ return { prev };
+ },
+ onError: (_err, _vars, ctx) => {
+ if (ctx?.prev) queryClient.setQueryData(queryKey, ctx.prev);
+ },
+ });
+
+ const markAllDone = useMutation({
+ mutationFn: async () => {
+ const readNotifications = notifications.filter((n) => !n.unread);
+ await Promise.all(
+ readNotifications.map((n) =>
+ markNotificationDone({ data: { threadId: n.id } }),
+ ),
+ );
+ },
+ onMutate: () => {
+ const prev = queryClient.getQueryData(queryKey);
+ if (prev) {
+ queryClient.setQueryData(queryKey, {
+ notifications: prev.notifications.filter((n) => n.unread),
+ });
+ }
+ return { prev };
+ },
+ onError: (_err, _vars, ctx) => {
+ if (ctx?.prev) queryClient.setQueryData(queryKey, ctx.prev);
+ },
+ });
+
+ const hasUnread = notifications.some((n) => n.unread);
+
+ return (
+
+ );
+});
+
+const reasonLabels: Record = {
+ assign: "Assigned",
+ author: "Author",
+ comment: "Comment",
+ ci_activity: "CI",
+ invitation: "Invited",
+ manual: "Subscribed",
+ mention: "Mentioned",
+ review_requested: "Review requested",
+ security_alert: "Security",
+ state_change: "State change",
+ subscribed: "Subscribed",
+ team_mention: "Team mention",
+};
+
+function getSubjectIcon(type: string, state: string | null) {
+ switch (type) {
+ case "PullRequest":
+ if (state === "merged") return GitMergeIcon;
+ if (state === "closed") return GitPullRequestClosedIcon;
+ return GitPullRequestIcon;
+ case "Issue":
+ return IssuesIcon;
+ case "Commit":
+ return GitMergeIcon;
+ case "Discussion":
+ return CommentIcon;
+ default:
+ return NotificationIcon;
+ }
+}
+
+function getSubjectColor(type: string, state: string | null) {
+ switch (type) {
+ case "PullRequest":
+ if (state === "merged") return "text-purple-500";
+ if (state === "closed") return "text-red-500";
+ return "text-green-500";
+ case "Issue":
+ if (state === "closed") return "text-red-500";
+ return "text-green-500";
+ case "Commit":
+ return "text-blue-500";
+ default:
+ return "text-muted-foreground";
+ }
+}
+
+function extractSubjectNumber(url: string | null): number | null {
+ if (!url) return null;
+ const match = url.match(RE_PULLS_NUMBER);
+ return match ? Number.parseInt(match[1], 10) : null;
+}
+
+const InboxRow = memo(function InboxRow({
+ notification,
+ isSelected,
+ onSelect,
+ scope,
+ filter,
+}: {
+ notification: NotificationItem;
+ isSelected: boolean;
+ onSelect: (notification: NotificationItem) => void;
+ scope: GitHubQueryScope;
+ filter: InboxFilter;
+}) {
+ const queryClient = useQueryClient();
+ const Icon = getSubjectIcon(
+ notification.subject.type,
+ notification.subjectState,
+ );
+ const iconColor = getSubjectColor(
+ notification.subject.type,
+ notification.subjectState,
+ );
+ const number = extractSubjectNumber(notification.subject.url);
+
+ const queryKey = githubQueryKeys.notifications.list(scope, {
+ all: filter === "all",
+ });
+
+ const markDone = useMutation({
+ mutationFn: () =>
+ markNotificationDone({ data: { threadId: notification.id } }),
+ onMutate: () => {
+ const prev = queryClient.getQueryData(queryKey);
+ if (prev) {
+ queryClient.setQueryData(queryKey, {
+ notifications: prev.notifications.filter(
+ (n) => n.id !== notification.id,
+ ),
+ });
+ }
+ return { prev };
+ },
+ onError: (_err, _vars, ctx) => {
+ if (ctx?.prev) queryClient.setQueryData(queryKey, ctx.prev);
+ },
+ });
+
+ const markRead = useMutation({
+ mutationFn: () =>
+ markNotificationRead({ data: { threadId: notification.id } }),
+ onMutate: () => {
+ const prev = queryClient.getQueryData(queryKey);
+ if (prev) {
+ queryClient.setQueryData(queryKey, {
+ notifications: prev.notifications.map((n) =>
+ n.id === notification.id ? { ...n, unread: false } : n,
+ ),
+ });
+ }
+ return { prev };
+ },
+ onError: (_err, _vars, ctx) => {
+ if (ctx?.prev) queryClient.setQueryData(queryKey, ctx.prev);
+ },
+ });
+
+ return (
+ {
+ onSelect(notification);
+ if (notification.unread) {
+ markRead.mutate();
+ }
+ }}
+ onKeyDown={(e) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onSelect(notification);
+ if (notification.unread) {
+ markRead.mutate();
+ }
+ }
+ }}
+ className={cn(
+ "group flex w-full cursor-pointer items-start gap-3 border-b border-border/50 px-4 py-3 text-left transition-colors last:border-b-0",
+ "hover:[&:not(:has([data-action]:hover))]:bg-surface-1",
+ isSelected && "bg-surface-1",
+ )}
+ >
+
+
+
+
+
+
+
+ {notification.repository.fullName}
+ {number ? ` #${number}` : ""}
+
+ {notification.unread && (
+
+ )}
+
+
+ {notification.subject.title}
+
+
+
+
+ {reasonLabels[notification.reason] ?? notification.reason}
+
+ ·
+
+ {formatRelativeTime(notification.updatedAt)}
+
+
+
+
+
+
+
+ {notification.unread && (
+
+ )}
+
+
+
+ );
+});
+
+const InboxPreview = memo(function InboxPreview({
+ notification,
+ userId,
+}: {
+ notification: NotificationItem | null;
+ scope: GitHubQueryScope;
+ userId: string;
+}) {
+ if (!notification) {
+ return (
+
+
+
+
+
+
+
Select a notification
+
+ Choose a notification from the list to preview
+
+
+
+
+ );
+ }
+
+ const parsed = parseSubjectRef(notification);
+ const internalHref = buildInternalHref(notification);
+
+ return (
+
+
+
+
+

+
{notification.repository.fullName}
+
+ {parsed?.number ?
#{parsed.number} : null}
+
+ {internalHref && (
+
}
+ asChild
+ >
+
Open in tab
+
+ )}
+
+
+
+
+
+ );
+});
+
+type ParsedSubjectRef = {
+ type: "PullRequest" | "Issue";
+ owner: string;
+ repo: string;
+ number: number;
+};
+
+function parseSubjectRef(
+ notification: NotificationItem,
+): ParsedSubjectRef | null {
+ const url = notification.subject.url;
+ if (!url) return null;
+ const owner = notification.repository.owner.login;
+ const repo = notification.repository.name;
+
+ const pullMatch = url.match(RE_API_PULLS);
+ if (pullMatch) {
+ return {
+ type: "PullRequest",
+ owner,
+ repo,
+ number: Number.parseInt(pullMatch[1], 10),
+ };
+ }
+
+ const issueMatch = url.match(RE_API_ISSUES);
+ if (issueMatch) {
+ return {
+ type: "Issue",
+ owner,
+ repo,
+ number: Number.parseInt(issueMatch[1], 10),
+ };
+ }
+
+ return null;
+}
+
+function InboxPreviewContent({
+ notification,
+ parsed,
+ userId,
+}: {
+ notification: NotificationItem;
+ parsed: ParsedSubjectRef | null;
+ userId: string;
+}) {
+ if (parsed?.type === "PullRequest") {
+ return (
+
+ );
+ }
+
+ if (parsed?.type === "Issue") {
+ return (
+
+ );
+ }
+
+ return ;
+}
+
+function InboxPreviewFallback({
+ notification,
+}: {
+ notification: NotificationItem;
+}) {
+ const Icon = getSubjectIcon(
+ notification.subject.type,
+ notification.subjectState,
+ );
+ const iconColor = getSubjectColor(
+ notification.subject.type,
+ notification.subjectState,
+ );
+ const ghUrl = buildGitHubUrl(notification);
+
+ return (
+
+
+
+
+
+
+
+ {notification.subject.title}
+
+
+
+ {notification.subject.type}
+
+ ·
+
+ {reasonLabels[notification.reason] ?? notification.reason}
+
+ ·
+ {formatRelativeTime(notification.updatedAt)}
+
+
+
+
+
+
+

+
+
+ {notification.repository.fullName}
+
+
+ {notification.repository.private
+ ? "Private repository"
+ : "Public repository"}
+
+
+
+
+
+ {ghUrl && (
+
+ View on GitHub →
+
+ )}
+
+ );
+}
+
+function buildGitHubUrl(notification: NotificationItem): string | null {
+ const { subject, repository } = notification;
+ const base = `https://github.com/${repository.fullName}`;
+
+ if (!subject.url) return base;
+
+ // subject.url is an API URL like https://api.github.com/repos/owner/repo/pulls/123
+ const pullMatch = subject.url.match(RE_API_PULLS);
+ if (pullMatch) return `${base}/pull/${pullMatch[1]}`;
+
+ const issueMatch = subject.url.match(RE_API_ISSUES);
+ if (issueMatch) return `${base}/issues/${issueMatch[1]}`;
+
+ const commitMatch = subject.url.match(RE_API_COMMITS);
+ if (commitMatch) return `${base}/commit/${commitMatch[1]}`;
+
+ const releaseMatch = subject.url.match(RE_API_RELEASES);
+ if (releaseMatch) return `${base}/releases`;
+
+ return base;
+}
+
+function buildInternalHref(notification: NotificationItem): string | null {
+ const { subject, repository } = notification;
+ const owner = repository.owner.login;
+ const repo = repository.name;
+
+ if (!subject.url) return null;
+
+ const pullMatch = subject.url.match(RE_API_PULLS);
+ if (pullMatch) return `/${owner}/${repo}/pull/${pullMatch[1]}`;
+
+ const issueMatch = subject.url.match(RE_API_ISSUES);
+ if (issueMatch) return `/${owner}/${repo}/issues/${issueMatch[1]}`;
+
+ return null;
+}
diff --git a/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx b/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx
index 517f658..7e714b4 100644
--- a/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx
+++ b/apps/dashboard/src/components/issues/detail/issue-detail-page.tsx
@@ -25,8 +25,34 @@ const routeApi = getRouteApi("/_protected/$owner/$repo/issues/$issueId");
export function IssueDetailPage() {
const { user } = routeApi.useRouteContext();
const { owner, repo, issueId } = routeApi.useParams();
- const issueNumber = Number(issueId);
- const scope = useMemo(() => ({ userId: user.id }), [user.id]);
+
+ return (
+
+ );
+}
+
+export type IssueDetailContentProps = {
+ owner: string;
+ repo: string;
+ issueNumber: number;
+ userId: string;
+ registerTab?: boolean;
+};
+
+export function IssueDetailContent({
+ owner,
+ repo,
+ issueNumber,
+ userId,
+ registerTab = false,
+}: IssueDetailContentProps) {
+ const scope = useMemo(() => ({ userId }), [userId]);
const input = useMemo(
() => ({ owner, repo, issueNumber }),
[owner, repo, issueNumber],
@@ -63,12 +89,12 @@ export function IssueDetailPage() {
const eventPagination = pageQuery.data?.eventPagination;
useRegisterTab(
- issue
+ registerTab && issue
? {
type: "issue",
title: issue.title,
number: issue.number,
- url: `/${owner}/${repo}/issues/${issueId}`,
+ url: `/${owner}/${repo}/issues/${issueNumber}`,
repo: `${owner}/${repo}`,
iconColor: getIssueStateConfig(issue).color,
}
diff --git a/apps/dashboard/src/components/layouts/dashboard-mobile-nav.tsx b/apps/dashboard/src/components/layouts/dashboard-mobile-nav.tsx
index b9edb3a..6009653 100644
--- a/apps/dashboard/src/components/layouts/dashboard-mobile-nav.tsx
+++ b/apps/dashboard/src/components/layouts/dashboard-mobile-nav.tsx
@@ -1,6 +1,7 @@
import {
GitPullRequestIcon,
HomeIcon,
+ InboxIcon,
IssuesIcon,
MoonIcon,
ReviewsIcon,
@@ -69,6 +70,7 @@ export function DashboardMobileNav({
const navItems: MobileNavItem[] = [
{ to: "/", label: "Overview", icon: HomeIcon },
+ { to: "/inbox", label: "Inbox", icon: InboxIcon },
{
to: "/pulls",
label: "Pulls",
diff --git a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx
index 9a14c26..1baa9b7 100644
--- a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx
+++ b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx
@@ -3,6 +3,7 @@ import {
ExternalLinkIcon,
GitPullRequestIcon,
HomeIcon,
+ InboxIcon,
IssuesIcon,
LogOutIcon,
MoreHorizontalIcon,
@@ -28,7 +29,10 @@ import { Link, useRouter } from "@tanstack/react-router";
import { useEffect, useMemo, useRef, useState } from "react";
import { DashboardTabs } from "#/components/layouts/dashboard-tabs";
import { signOutToLogin } from "#/lib/auth-actions";
-import { githubViewerQueryOptions } from "#/lib/github.query";
+import {
+ githubNotificationsQueryOptions,
+ githubViewerQueryOptions,
+} from "#/lib/github.query";
import { useGlobalShortcuts } from "#/lib/shortcuts";
import { type Tab, useTabs } from "#/lib/tab-store";
import { useHasMounted } from "#/lib/use-has-mounted";
@@ -53,9 +57,16 @@ type NavItem = {
label: string;
icon: typeof HomeIcon;
count?: number;
+ dot?: boolean;
};
-const primaryNavRoutes = ["/", "/pulls", "/issues", "/reviews"] as const;
+const primaryNavRoutes = [
+ "/",
+ "/inbox",
+ "/pulls",
+ "/issues",
+ "/reviews",
+] as const;
const MAX_TAB_SHORTCUTS = 9;
export function DashboardTopbar({
@@ -71,6 +82,11 @@ export function DashboardTopbar({
enabled: hasMounted,
});
const viewerLogin = viewerQuery.data?.login;
+ const notificationsQuery = useQuery({
+ ...githubNotificationsQueryOptions({ userId: user.id }, { all: false }),
+ enabled: hasMounted,
+ });
+ const hasUnread = (notificationsQuery.data?.notifications?.length ?? 0) > 0;
// Store router in a ref — only used imperatively (navigate, preload),
// never read during render, so we avoid subscribing to state changes.
const router = useRouter();
@@ -88,6 +104,7 @@ export function DashboardTopbar({
const navItems = useMemo(
() => [
{ to: "/", label: "Overview", icon: HomeIcon },
+ { to: "/inbox", label: "Inbox", icon: InboxIcon, dot: hasUnread },
{
to: "/pulls",
label: "Pull Requests",
@@ -107,7 +124,7 @@ export function DashboardTopbar({
count: counts.reviews,
},
],
- [counts.pulls, counts.issues, counts.reviews],
+ [counts.pulls, counts.issues, counts.reviews, hasUnread],
);
useEffect(() => {
@@ -284,7 +301,9 @@ export function DashboardTopbar({
>
{item.label}
- {typeof item.count === "number" ? (
+ {item.dot ? (
+
+ ) : typeof item.count === "number" ? (
)}
- {pr.headRef && pr.baseRef && (
+ {pr.headRef && pr.baseRef && pr.state !== "closed" && (
{pr.headRef}
diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx
index 1d00804..4ec975c 100644
--- a/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx
+++ b/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx
@@ -63,17 +63,23 @@ export function PullDetailHeader({
{pr.author.login}
- wants to merge into
-
- from
-
+ {(pr.isMerged || pr.state !== "closed") && (
+ <>
+
+ {pr.isMerged ? "merged into" : "wants to merge into"}
+
+
+ from
+
+ >
+ )}
>
)}
diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx
index f185516..1bb9876 100644
--- a/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx
+++ b/apps/dashboard/src/components/pulls/detail/pull-detail-page.tsx
@@ -28,8 +28,34 @@ const routeApi = getRouteApi("/_protected/$owner/$repo/pull/$pullId");
export function PullDetailPage() {
const { user } = routeApi.useRouteContext();
const { owner, repo, pullId } = routeApi.useParams();
- const pullNumber = Number(pullId);
- const scope = useMemo(() => ({ userId: user.id }), [user.id]);
+
+ return (
+
+ );
+}
+
+export type PullDetailContentProps = {
+ owner: string;
+ repo: string;
+ pullNumber: number;
+ userId: string;
+ registerTab?: boolean;
+};
+
+export function PullDetailContent({
+ owner,
+ repo,
+ pullNumber,
+ userId,
+ registerTab = false,
+}: PullDetailContentProps) {
+ const scope = useMemo(() => ({ userId }), [userId]);
const input = useMemo(
() => ({ owner, repo, pullNumber }),
[owner, repo, pullNumber],
@@ -92,12 +118,12 @@ export function PullDetailPage() {
const viewer = viewerQuery.data ?? null;
useRegisterTab(
- pr
+ registerTab && pr
? {
type: "pull",
title: pr.title,
number: pr.number,
- url: `/${owner}/${repo}/pull/${pullId}`,
+ url: `/${owner}/${repo}/pull/${pullNumber}`,
repo: `${owner}/${repo}`,
iconColor: getPrStateConfig(pr).color,
}
@@ -114,7 +140,7 @@ export function PullDetailPage() {
diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts
index f8928db..3c6cc0a 100644
--- a/apps/dashboard/src/lib/github.functions.ts
+++ b/apps/dashboard/src/lib/github.functions.ts
@@ -18,6 +18,9 @@ import type {
IssueSummary,
MyIssuesResult,
MyPullsResult,
+ NotificationItem,
+ NotificationParticipant,
+ NotificationsResult,
OrgTeam,
PinnedRepo,
PullComment,
@@ -6917,6 +6920,195 @@ export const getRepoContributors = createServerFn({ method: "GET" })
});
});
+// ── Notifications ──────────────────────────────────────────────────────
+
+type GetNotificationsInput = {
+ all?: boolean;
+ participating?: boolean;
+};
+
+export const getNotifications = createServerFn({ method: "GET" })
+ .inputValidator(identityValidator)
+ .handler(async ({ data }): Promise => {
+ const context = await getGitHubContext();
+ if (!context) {
+ return { notifications: [] };
+ }
+
+ const response =
+ await context.octokit.rest.activity.listNotificationsForAuthenticatedUser(
+ {
+ all: data.all ?? false,
+ participating: data.participating ?? false,
+ per_page: 50,
+ },
+ );
+
+ // Batch-fetch participants for PR/Issue notifications in parallel
+ const participantMap = new Map();
+ const stateMap = new Map();
+ const fetchable = response.data.filter(
+ (n) =>
+ n.subject.url &&
+ (n.subject.type === "PullRequest" || n.subject.type === "Issue"),
+ );
+
+ await Promise.allSettled(
+ fetchable.map(async (n) => {
+ try {
+ const seen = new Set();
+ const participants: NotificationParticipant[] = [];
+ const add = (login: string, avatarUrl: string) => {
+ if (seen.has(login)) return;
+ seen.add(login);
+ participants.push({ login, avatarUrl });
+ };
+
+ // Fetch subject detail + comments (and reviews for PRs) in parallel
+ const subjectUrl = n.subject.url!;
+ const commentsUrl = `${subjectUrl}/comments`;
+ const isPR = n.subject.type === "PullRequest";
+ const reviewsUrl = isPR ? `${subjectUrl}/reviews` : null;
+
+ const [subjectRes, commentsRes, reviewsRes] = await Promise.all([
+ context.octokit.request("GET {url}", { url: subjectUrl }),
+ context.octokit
+ .request("GET {url}", { url: commentsUrl, per_page: 100 })
+ .catch(() => null),
+ reviewsUrl
+ ? context.octokit
+ .request("GET {url}", { url: reviewsUrl, per_page: 100 })
+ .catch(() => null)
+ : null,
+ ]);
+
+ const d = subjectRes.data as {
+ user?: { login: string; avatar_url: string };
+ assignees?: Array<{ login: string; avatar_url: string }>;
+ requested_reviewers?: Array<{ login: string; avatar_url: string }>;
+ state?: string;
+ merged?: boolean;
+ };
+
+ // Extract subject state (open/closed/merged)
+ if (d.state) {
+ const state = d.merged
+ ? "merged"
+ : d.state === "closed"
+ ? "closed"
+ : "open";
+ stateMap.set(n.id, state);
+ }
+
+ if (d.user) add(d.user.login, d.user.avatar_url);
+ for (const a of d.assignees ?? []) add(a.login, a.avatar_url);
+ for (const r of d.requested_reviewers ?? [])
+ add(r.login, r.avatar_url);
+
+ // Add commenters
+ if (commentsRes?.data && Array.isArray(commentsRes.data)) {
+ for (const c of commentsRes.data as Array<{
+ user?: { login: string; avatar_url: string };
+ }>) {
+ if (c.user) add(c.user.login, c.user.avatar_url);
+ }
+ }
+
+ // Add reviewers (PRs only)
+ if (reviewsRes?.data && Array.isArray(reviewsRes.data)) {
+ for (const r of reviewsRes.data as Array<{
+ user?: { login: string; avatar_url: string };
+ }>) {
+ if (r.user) add(r.user.login, r.user.avatar_url);
+ }
+ }
+
+ participantMap.set(n.id, participants);
+ } catch {
+ // Silently skip — participant data is best-effort
+ }
+ }),
+ );
+
+ const notifications: NotificationItem[] = response.data.map((n) => ({
+ id: n.id,
+ unread: n.unread,
+ reason: n.reason as NotificationItem["reason"],
+ subject: {
+ title: n.subject.title,
+ url: n.subject.url ?? null,
+ latestCommentUrl: n.subject.latest_comment_url ?? null,
+ type: n.subject.type as NotificationItem["subject"]["type"],
+ },
+ repository: {
+ id: n.repository.id,
+ name: n.repository.name,
+ fullName: n.repository.full_name,
+ owner: {
+ login: n.repository.owner.login,
+ avatarUrl: n.repository.owner.avatar_url,
+ url: n.repository.owner.html_url ?? n.repository.owner.url,
+ type: n.repository.owner.type ?? "User",
+ },
+ private: n.repository.private,
+ },
+ participants: participantMap.get(n.id) ?? [],
+ subjectState: stateMap.get(n.id) ?? null,
+ updatedAt: n.updated_at,
+ lastReadAt: n.last_read_at ?? null,
+ url: n.url,
+ }));
+
+ return { notifications };
+ });
+
+type MarkNotificationReadInput = { threadId: string };
+
+export const markNotificationRead = createServerFn({ method: "POST" })
+ .inputValidator(identityValidator)
+ .handler(async ({ data }): Promise<{ ok: boolean }> => {
+ const context = await getGitHubContext();
+ if (!context) {
+ return { ok: false };
+ }
+
+ await context.octokit.rest.activity.markThreadAsRead({
+ thread_id: Number.parseInt(data.threadId, 10),
+ });
+
+ return { ok: true };
+ });
+
+export const markNotificationDone = createServerFn({ method: "POST" })
+ .inputValidator(identityValidator)
+ .handler(async ({ data }): Promise<{ ok: boolean }> => {
+ const context = await getGitHubContext();
+ if (!context) {
+ return { ok: false };
+ }
+
+ await context.octokit.rest.activity.markThreadAsDone({
+ thread_id: Number.parseInt(data.threadId, 10),
+ });
+
+ return { ok: true };
+ });
+
+export const markAllNotificationsRead = createServerFn({
+ method: "POST",
+}).handler(async (): Promise<{ ok: boolean }> => {
+ const context = await getGitHubContext();
+ if (!context) {
+ return { ok: false };
+ }
+
+ await context.octokit.rest.activity.markNotificationsAsRead({
+ last_read_at: new Date().toISOString(),
+ });
+
+ return { ok: true };
+});
+
type RevalidationSignalTimestampsInput = { signalKeys: string[] };
export const getRevalidationSignalTimestamps = createServerFn({
diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts
index 82b3a36..28993e8 100644
--- a/apps/dashboard/src/lib/github.query.ts
+++ b/apps/dashboard/src/lib/github.query.ts
@@ -10,6 +10,7 @@ import {
getIssuesFromUser,
getMyIssues,
getMyPulls,
+ getNotifications,
getOrgTeams,
getPullComments,
getPullFileSummaries,
@@ -222,6 +223,12 @@ export const githubQueryKeys = {
comments: (scope: GitHubQueryScope, input: IssueFromRepoQueryInput) =>
["github", scope.userId, "issues", "comments", input] as const,
},
+ notifications: {
+ list: (
+ scope: GitHubQueryScope,
+ input: { all?: boolean; participating?: boolean },
+ ) => ["github", scope.userId, "notifications", "list", input] as const,
+ },
};
export function githubViewerQueryOptions(scope: GitHubQueryScope) {
@@ -673,3 +680,16 @@ export function githubRepoDiscussionsQueryOptions(
meta: persistedMeta,
});
}
+
+export function githubNotificationsQueryOptions(
+ scope: GitHubQueryScope,
+ input: { all?: boolean; participating?: boolean } = {},
+) {
+ return queryOptions({
+ queryKey: githubQueryKeys.notifications.list(scope, input),
+ queryFn: () => getNotifications({ 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 199d0a4..da7a66c 100644
--- a/apps/dashboard/src/lib/github.types.ts
+++ b/apps/dashboard/src/lib/github.types.ts
@@ -537,3 +537,61 @@ export type DiscussionsResult = {
discussions: DiscussionSummary[];
totalCount: number;
};
+
+export type NotificationSubject = {
+ title: string;
+ url: string | null;
+ latestCommentUrl: string | null;
+ type:
+ | "CheckSuite"
+ | "Commit"
+ | "Discussion"
+ | "Issue"
+ | "PullRequest"
+ | "Release"
+ | "RepositoryVulnerabilityAlert"
+ | "RepositoryDependabotAlertsThread"
+ | "RepositoryAdvisory"
+ | (string & {});
+};
+
+export type NotificationParticipant = {
+ login: string;
+ avatarUrl: string;
+};
+
+export type NotificationItem = {
+ id: string;
+ unread: boolean;
+ reason:
+ | "assign"
+ | "author"
+ | "comment"
+ | "ci_activity"
+ | "invitation"
+ | "manual"
+ | "mention"
+ | "review_requested"
+ | "security_alert"
+ | "state_change"
+ | "subscribed"
+ | "team_mention"
+ | (string & {});
+ subject: NotificationSubject;
+ repository: {
+ id: number;
+ name: string;
+ fullName: string;
+ owner: GitHubActor;
+ private: boolean;
+ };
+ participants: NotificationParticipant[];
+ subjectState: "open" | "closed" | "merged" | null;
+ updatedAt: string;
+ lastReadAt: string | null;
+ url: string;
+};
+
+export type NotificationsResult = {
+ notifications: NotificationItem[];
+};
diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts
index 5014470..c3ed90b 100644
--- a/apps/dashboard/src/routeTree.gen.ts
+++ b/apps/dashboard/src/routeTree.gen.ts
@@ -20,6 +20,7 @@ import { Route as ProtectedSettingsRouteImport } from './routes/_protected/setti
import { Route as ProtectedReviewsRouteImport } from './routes/_protected/reviews'
import { Route as ProtectedPullsRouteImport } from './routes/_protected/pulls'
import { Route as ProtectedIssuesRouteImport } from './routes/_protected/issues'
+import { Route as ProtectedInboxRouteImport } from './routes/_protected/inbox'
import { Route as ProtectedSettingsIndexRouteImport } from './routes/_protected/settings/index'
import { Route as ProtectedOwnerIndexRouteImport } from './routes/_protected/$owner/index'
import { Route as ApiWebhooksGithubRouteImport } from './routes/api/webhooks/github'
@@ -89,6 +90,11 @@ const ProtectedIssuesRoute = ProtectedIssuesRouteImport.update({
path: '/issues',
getParentRoute: () => ProtectedRoute,
} as any)
+const ProtectedInboxRoute = ProtectedInboxRouteImport.update({
+ id: '/inbox',
+ path: '/inbox',
+ getParentRoute: () => ProtectedRoute,
+} as any)
const ProtectedSettingsIndexRoute = ProtectedSettingsIndexRouteImport.update({
id: '/',
path: '/',
@@ -173,6 +179,7 @@ export interface FileRoutesByFullPath {
'/privacy': typeof PrivacyRoute
'/setup': typeof SetupRoute
'/terms': typeof TermsRoute
+ '/inbox': typeof ProtectedInboxRoute
'/issues': typeof ProtectedIssuesRoute
'/pulls': typeof ProtectedPullsRoute
'/reviews': typeof ProtectedReviewsRoute
@@ -198,6 +205,7 @@ export interface FileRoutesByTo {
'/privacy': typeof PrivacyRoute
'/setup': typeof SetupRoute
'/terms': typeof TermsRoute
+ '/inbox': typeof ProtectedInboxRoute
'/issues': typeof ProtectedIssuesRoute
'/pulls': typeof ProtectedPullsRoute
'/reviews': typeof ProtectedReviewsRoute
@@ -225,6 +233,7 @@ export interface FileRoutesById {
'/privacy': typeof PrivacyRoute
'/setup': typeof SetupRoute
'/terms': typeof TermsRoute
+ '/_protected/inbox': typeof ProtectedInboxRoute
'/_protected/issues': typeof ProtectedIssuesRoute
'/_protected/pulls': typeof ProtectedPullsRoute
'/_protected/reviews': typeof ProtectedReviewsRoute
@@ -254,6 +263,7 @@ export interface FileRouteTypes {
| '/privacy'
| '/setup'
| '/terms'
+ | '/inbox'
| '/issues'
| '/pulls'
| '/reviews'
@@ -279,6 +289,7 @@ export interface FileRouteTypes {
| '/privacy'
| '/setup'
| '/terms'
+ | '/inbox'
| '/issues'
| '/pulls'
| '/reviews'
@@ -305,6 +316,7 @@ export interface FileRouteTypes {
| '/privacy'
| '/setup'
| '/terms'
+ | '/_protected/inbox'
| '/_protected/issues'
| '/_protected/pulls'
| '/_protected/reviews'
@@ -418,6 +430,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ProtectedIssuesRouteImport
parentRoute: typeof ProtectedRoute
}
+ '/_protected/inbox': {
+ id: '/_protected/inbox'
+ path: '/inbox'
+ fullPath: '/inbox'
+ preLoaderRoute: typeof ProtectedInboxRouteImport
+ parentRoute: typeof ProtectedRoute
+ }
'/_protected/settings/': {
id: '/_protected/settings/'
path: '/'
@@ -533,6 +552,7 @@ const ProtectedSettingsRouteWithChildren =
ProtectedSettingsRoute._addFileChildren(ProtectedSettingsRouteChildren)
interface ProtectedRouteChildren {
+ ProtectedInboxRoute: typeof ProtectedInboxRoute
ProtectedIssuesRoute: typeof ProtectedIssuesRoute
ProtectedPullsRoute: typeof ProtectedPullsRoute
ProtectedReviewsRoute: typeof ProtectedReviewsRoute
@@ -549,6 +569,7 @@ interface ProtectedRouteChildren {
}
const ProtectedRouteChildren: ProtectedRouteChildren = {
+ ProtectedInboxRoute: ProtectedInboxRoute,
ProtectedIssuesRoute: ProtectedIssuesRoute,
ProtectedPullsRoute: ProtectedPullsRoute,
ProtectedReviewsRoute: ProtectedReviewsRoute,
diff --git a/apps/dashboard/src/routes/_protected/inbox.tsx b/apps/dashboard/src/routes/_protected/inbox.tsx
new file mode 100644
index 0000000..f35f6f5
--- /dev/null
+++ b/apps/dashboard/src/routes/_protected/inbox.tsx
@@ -0,0 +1,24 @@
+import { createFileRoute } from "@tanstack/react-router";
+import { InboxPage } from "#/components/inbox/inbox-page";
+import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading";
+import { githubNotificationsQueryOptions } from "#/lib/github.query";
+import { buildSeo, formatPageTitle } from "#/lib/seo";
+
+export const Route = createFileRoute("/_protected/inbox")({
+ ssr: false,
+ loader: async ({ context }) => {
+ const scope = { userId: context.user.id };
+ void context.queryClient.prefetchQuery(
+ githubNotificationsQueryOptions(scope),
+ );
+ },
+ pendingComponent: DashboardContentLoading,
+ head: ({ match }) =>
+ buildSeo({
+ path: match.pathname,
+ title: formatPageTitle("Inbox"),
+ description: "GitHub notifications inbox",
+ robots: "noindex",
+ }),
+ component: InboxPage,
+});
diff --git a/packages/icons/src/archive-down-icon.tsx b/packages/icons/src/archive-down-icon.tsx
new file mode 100644
index 0000000..20dd0bf
--- /dev/null
+++ b/packages/icons/src/archive-down-icon.tsx
@@ -0,0 +1,27 @@
+import type { SVGProps } from "react";
+
+export function ArchiveDownIcon(
+ props: SVGProps & { size?: number }
+) {
+ const { size = 24, width, height, ...rest } = props;
+ return (
+
+ );
+}
diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts
index 3fc4608..97e4d8b 100644
--- a/packages/icons/src/index.ts
+++ b/packages/icons/src/index.ts
@@ -68,6 +68,7 @@ export {
ViewIcon,
WifiDisconnected01Icon as WifiOffIcon,
} from "@hugeicons/react";
+export { ArchiveDownIcon } from "./archive-down-icon";
export { GitHubLogo, GitHubWordmarkLogo, XLogo } from "./brand-logos";
export { PenIcon } from "./pen-icon";
export { SeparatorHorizontalIcon } from "./separator-horizontal-icon";