diff --git a/src/app/components/dashboard/DashboardPage.tsx b/src/app/components/dashboard/DashboardPage.tsx
index 395e9ee2..7b148e50 100644
--- a/src/app/components/dashboard/DashboardPage.tsx
+++ b/src/app/components/dashboard/DashboardPage.tsx
@@ -8,11 +8,9 @@ import IssuesTab from "./IssuesTab";
import PullRequestsTab from "./PullRequestsTab";
import { config } from "../../stores/config";
import { viewState, updateViewState } from "../../stores/view";
-import type { Issue, PullRequest, WorkflowRun, ApiError } from "../../services/api";
+import type { Issue, PullRequest, WorkflowRun } from "../../services/api";
import { createPollCoordinator, fetchAllData, type DashboardData } from "../../services/poll";
import { clearAuth, user, onAuthCleared, DASHBOARD_STORAGE_KEY } from "../../stores/auth";
-import { getErrors, dismissError } from "../../lib/errors";
-import ErrorBannerList from "../shared/ErrorBannerList";
// ── Shared dashboard store (module-level to survive navigation) ─────────────
@@ -22,7 +20,6 @@ interface DashboardStore {
issues: Issue[];
pullRequests: PullRequest[];
workflowRuns: WorkflowRun[];
- errors: ApiError[];
loading: boolean;
lastRefreshedAt: Date | null;
}
@@ -31,7 +28,6 @@ const initialDashboardState: DashboardStore = {
issues: [],
pullRequests: [],
workflowRuns: [],
- errors: [],
loading: true,
lastRefreshedAt: null,
};
@@ -51,7 +47,6 @@ function loadCachedDashboard(): DashboardStore {
issues: parsed.issues as Issue[],
pullRequests: parsed.pullRequests as PullRequest[],
workflowRuns: parsed.workflowRuns as WorkflowRun[],
- errors: [],
loading: false,
lastRefreshedAt: typeof parsed.lastRefreshedAt === "string" ? new Date(parsed.lastRefreshedAt) : null,
};
@@ -93,7 +88,6 @@ async function pollFetch(): Promise
{
issues: data.issues,
pullRequests: data.pullRequests,
workflowRuns: data.workflowRuns,
- errors: data.errors,
loading: false,
lastRefreshedAt: now,
});
@@ -191,19 +185,12 @@ export default function DashboardPage() {
onRefresh={() => _coordinator()?.manualRefresh()}
/>
- {/* Global error banner */}
- ({ source: e.source, message: e.message, retryable: e.retryable }))}
- onDismiss={(index) => dismissError(getErrors()[index].id)}
- />
-
@@ -211,7 +198,6 @@ export default function DashboardPage() {
@@ -219,7 +205,6 @@ export default function DashboardPage() {
diff --git a/src/app/components/dashboard/IssuesTab.tsx b/src/app/components/dashboard/IssuesTab.tsx
index 356ebb70..bcfa1d08 100644
--- a/src/app/components/dashboard/IssuesTab.tsx
+++ b/src/app/components/dashboard/IssuesTab.tsx
@@ -2,11 +2,10 @@ import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
import { createStore } from "solid-js/store";
import { config } from "../../stores/config";
import { viewState, setSortPreference, setTabFilter, resetTabFilter, resetAllTabFilters, ignoreItem, unignoreItem, type IssueFilterField } from "../../stores/view";
-import type { Issue, ApiError } from "../../services/api";
+import type { Issue } from "../../services/api";
import ItemRow from "./ItemRow";
import IgnoreBadge from "./IgnoreBadge";
import SortIcon from "../shared/SortIcon";
-import ErrorBannerList from "../shared/ErrorBannerList";
import PaginationControls from "../shared/PaginationControls";
import FilterChips from "../shared/FilterChips";
import type { FilterChipGroupDef } from "../shared/FilterChips";
@@ -19,7 +18,6 @@ import { groupByRepo, computePageLayout, slicePageGroups } from "../../lib/group
export interface IssuesTabProps {
issues: Issue[];
loading?: boolean;
- errors?: ApiError[];
userLogin: string;
}
@@ -162,8 +160,6 @@ export default function IssuesTab(props: IssuesTabProps) {
return (
-
({ source: e.repo, message: e.message, retryable: e.retryable }))} />
-
{/* Column headers */}
-
({ source: e.repo, message: e.message, retryable: e.retryable }))} />
-
{/* Column headers */}
getUnreadCount();
+
const coreRL = () => getCoreRateLimit();
const searchRL = () => getSearchRateLimit();
@@ -20,95 +35,125 @@ export default function Header() {
}
return (
-
-
- GitHub Tracker
-
+ <>
+
+
+ GitHub Tracker
+
-
+
-
-
-
Rate Limits
-
-
- {(rl) => (
-
- {formatLimit(rl().remaining, 5000, "hr")}
-
- )}
-
-
- {(rl) => (
-
- {formatLimit(rl().remaining, 30, "min")}
-
- )}
-
+
+
+
Rate Limits
+
+
+ {(rl) => (
+
+ {formatLimit(rl().remaining, 5000, "hr")}
+
+ )}
+
+
+ {(rl) => (
+
+ {formatLimit(rl().remaining, 30, "min")}
+
+ )}
+
+
-
-
+
-
- {(u) => (
-
- )}
-
+
+
-
-
-
+
+
0}>
+
+ {unreadCount() > 9 ? "9+" : unreadCount()}
+
+
+
-
-
+
+
+
+
setDrawerOpen(false)} />
+
+ >
);
}
diff --git a/src/app/components/shared/ErrorBannerList.tsx b/src/app/components/shared/ErrorBannerList.tsx
deleted file mode 100644
index 11b3124e..00000000
--- a/src/app/components/shared/ErrorBannerList.tsx
+++ /dev/null
@@ -1,59 +0,0 @@
-import { For, Show } from "solid-js";
-
-export interface ErrorBannerItem {
- source: string;
- message: string;
- retryable?: boolean;
-}
-
-export default function ErrorBannerList(props: {
- errors?: ErrorBannerItem[];
- onDismiss?: (index: number) => void;
-}) {
- return (
- 0}>
-
-
- {(err, index) => (
-
-
-
- {err.source}: {err.message}
- {err.retryable && " (will retry)"}
-
-
-
-
-
- )}
-
-
-
- );
-}
diff --git a/src/app/components/shared/NotificationDrawer.tsx b/src/app/components/shared/NotificationDrawer.tsx
new file mode 100644
index 00000000..0dc847a6
--- /dev/null
+++ b/src/app/components/shared/NotificationDrawer.tsx
@@ -0,0 +1,200 @@
+import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js";
+import {
+ getNotifications,
+ markAllAsRead,
+ clearNotifications,
+ dismissError,
+ addMutedSource,
+} from "../../lib/errors";
+import { relativeTime } from "../../lib/format";
+import { severityConfig } from "./ToastContainer";
+
+interface NotificationDrawerProps {
+ open: boolean;
+ onClose: () => void;
+}
+
+export default function NotificationDrawer(props: NotificationDrawerProps) {
+ const [visible, setVisible] = createSignal(false);
+ const [closing, setClosing] = createSignal(false);
+ let closeTimeoutHandle: ReturnType | undefined;
+ let closeButtonRef: HTMLButtonElement | undefined;
+
+ const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
+ const animDelay = reducedMotion ? 0 : 300;
+
+ createEffect(() => {
+ if (props.open) {
+ // Clear any pending close timeout on re-open
+ if (closeTimeoutHandle !== undefined) {
+ clearTimeout(closeTimeoutHandle);
+ closeTimeoutHandle = undefined;
+ }
+ setClosing(false);
+ setVisible(true);
+ queueMicrotask(() => closeButtonRef?.focus());
+ } else {
+ if (visible()) {
+ setClosing(true);
+ closeTimeoutHandle = setTimeout(() => {
+ setVisible(false);
+ closeTimeoutHandle = undefined;
+ }, animDelay);
+ }
+ }
+ });
+
+ onCleanup(() => {
+ if (closeTimeoutHandle !== undefined) clearTimeout(closeTimeoutHandle);
+ });
+
+ // Escape key handler
+ createEffect(() => {
+ if (!visible()) return;
+ const handler = (e: KeyboardEvent) => {
+ if (e.key === "Escape") props.onClose();
+ };
+ document.addEventListener("keydown", handler);
+ onCleanup(() => document.removeEventListener("keydown", handler));
+ });
+
+ const sortedNotifications = createMemo(() => getNotifications().slice().reverse());
+
+ function handleDismissAll() {
+ const current = getNotifications();
+ for (const n of current) addMutedSource(n.source);
+ clearNotifications();
+ }
+
+ return (
+
+ <>
+ {/* Overlay */}
+
+ {/* Drawer panel */}
+
+ {/* Header */}
+
+
+ Notifications
+
+
+
+
+
+
+ {/* Notification list */}
+
+
0}
+ fallback={
+
+ No notifications
+
+ }
+ >
+
+
+ {(notif) => {
+ const cfg = severityConfig(notif.severity);
+ return (
+ -
+
+
+
+ {notif.source}: {notif.message}
+ {notif.retryable && (
+
+ (will retry)
+
+ )}
+
+
+ {relativeTime(new Date(notif.timestamp).toISOString())}
+
+
+
+
+ );
+ }}
+
+
+
+
+
+ >
+
+ );
+}
diff --git a/src/app/components/shared/ToastContainer.tsx b/src/app/components/shared/ToastContainer.tsx
new file mode 100644
index 00000000..ddd04434
--- /dev/null
+++ b/src/app/components/shared/ToastContainer.tsx
@@ -0,0 +1,226 @@
+import { createEffect, createSignal, For, onCleanup } from "solid-js";
+import {
+ getNotifications,
+ isMuted,
+ type AppNotification,
+ type NotificationSeverity,
+} from "../../lib/errors";
+export interface SeverityConfig {
+ path: string;
+ secondaryPath?: string;
+ iconClass: string;
+ borderClass: string;
+ bgClass: string;
+ textClass: string;
+ borderColorClass: string;
+}
+
+export function severityConfig(severity: NotificationSeverity): SeverityConfig {
+ switch (severity) {
+ case "error":
+ return {
+ path: "M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z",
+ iconClass: "text-red-500 dark:text-red-400",
+ borderClass: "border-l-red-400",
+ bgClass: "bg-red-50 dark:bg-red-900/30",
+ textClass: "text-red-800 dark:text-red-200",
+ borderColorClass: "border-red-200 dark:border-red-800",
+ };
+ case "warning":
+ return {
+ path: "M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495z",
+ secondaryPath:
+ "M10 12a.75.75 0 01-.75-.75v-3.5a.75.75 0 011.5 0v3.5A.75.75 0 0110 12zm0 3a1 1 0 100-2 1 1 0 000 2z",
+ iconClass: "text-yellow-500 dark:text-yellow-400",
+ borderClass: "border-l-yellow-400",
+ bgClass: "bg-yellow-50 dark:bg-yellow-900/30",
+ textClass: "text-yellow-800 dark:text-yellow-200",
+ borderColorClass: "border-yellow-200 dark:border-yellow-800",
+ };
+ case "info":
+ return {
+ path: "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z",
+ iconClass: "text-blue-500 dark:text-blue-400",
+ borderClass: "border-l-blue-400",
+ bgClass: "bg-blue-50 dark:bg-blue-900/30",
+ textClass: "text-blue-800 dark:text-blue-200",
+ borderColorClass: "border-blue-200 dark:border-blue-800",
+ };
+ }
+}
+
+interface ToastItem {
+ notification: AppNotification;
+ dismissing: boolean;
+}
+
+export default function ToastContainer() {
+ // seenTimestamps: notification ID → last-seen timestamp (detect new/updated)
+ const seenTimestamps = new Map();
+ // lastToastedAt: source → timestamp of last toast shown (cooldown tracking)
+ const lastToastedAt = new Map();
+ // visibleToasts: IDs of toasts currently on screen
+ const [visibleToasts, setVisibleToasts] = createSignal