Skip to content
148 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them.1/**
2 * Notifications, in full: the same tabs as the panel in the top bar, with
3 * what was saved and what is done, a page at a time. Its sidebar
4 * (components/shell.tsx) chooses between those and narrows them by why you
5 * were told; below 1024px, where the sidebar is a drawer, the page has
6 * them too. Every notifications form posts here, the panel's included.
7 */
8import { Bell, Bookmark, Check } from "lucide-react";
9import { Link, data, useNavigate } from "react-router";
10
11import type { Route } from "./+types/notifications";
12import { InboxCard, InboxEmpty, InboxTabs, MarkAllRead } from "../components/inbox";
13import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select";
14import { cn } from "../lib/cn";
15import { REASON_FILTERS, inboxReason, inboxTab, inboxView, markFromForm, severityOf, tabCount } from "../lib/inbox";
16import { page } from "../lib/meta";
17import { inbox } from "../lib/services.server";
18import { assertSameOrigin, requireUser } from "../lib/session.server";
19
20const PAGE_ITEMS = 50;
21
22export function meta(args: Route.MetaArgs) {
23 return page(args, { title: "Notifications · g1t" });
24}
25
26export async function loader({ request, context }: Route.LoaderArgs) {
27 const user = requireUser(context, request);
28 const url = new URL(request.url);
29 const tab = inboxTab(url.searchParams.get("tab"));
30 const view = inboxView(url.searchParams.get("view"));
31 const reason = inboxReason(url.searchParams.get("reason"));
32 const before = url.searchParams.get("before");
33 const [list, counts] = await Promise.all([
34 inbox.list(user, { view, severity: severityOf(tab), reason, before, limit: PAGE_ITEMS }).catch(() => null),
35 inbox.counts(user.username).catch(() => null),
36 ]);
37 return { tab, view, reason, before, items: list?.items ?? null, next: list?.next ?? null, counts };
38}
39
40export async function action({ request, context }: Route.ActionArgs) {
41 assertSameOrigin(request);
42 const user = requireUser(context, request);
43 const mark = markFromForm(await request.formData(), Date.now());
44 if (!mark) return data({ error: "Nothing to do." }, { status: 400 });
45 try {
46 await inbox.mark(user.username, mark);
47 return { error: null };
48 } catch {
49 return data({ error: "That did not save. Try again in a moment." }, { status: 503 });
50 }
51}
52
53const VIEWS = [
54 { view: "inbox", label: "Everything", icon: <Bell size={14} /> },
55 { view: "saved", label: "Saved", icon: <Bookmark size={14} /> },
56 { view: "done", label: "Done", icon: <Check size={14} /> },
57] as const;
58
59export default function NotificationsPage({ loaderData }: Route.ComponentProps) {
60 const { tab, view, reason, before, items, next, counts } = loaderData;
61 const navigate = useNavigate();
62 const address = (changes: Record<string, string | null>) => {
63 const params = new URLSearchParams();
64 const merged = { tab: tab === "all" ? null : tab, view: view === "inbox" ? null : view, reason, ...changes };
65 for (const [name, value] of Object.entries(merged)) if (value) params.set(name, value);
66 const query = params.toString();
67 return query ? `/notifications?${query}` : "/notifications";
68 };
69
70 return (
71 <main className="mx-auto max-w-3xl px-4 py-8 sm:px-6">
72 <div className="flex flex-wrap items-center justify-between gap-3">
73 <div>
74 <h1 className="text-2xl font-semibold tracking-tight">Notifications</h1>
75 <p className="mt-1 text-sm text-muted">What needs you, and what you follow. What is waiting on you comes first.</p>
76 </div>
77 {view === "inbox" && <MarkAllRead tab={tab} disabled={tabCount(counts, tab) === 0} />}
78 </div>
79
80 <nav aria-label="Notifications views" className="mt-6 flex gap-1 border-b border-line lg:hidden">
81 {VIEWS.map((entry) => (
82 <Link
83 key={entry.view}
84 to={address({ view: entry.view === "inbox" ? null : entry.view, before: null })}
85 aria-current={view === entry.view ? "page" : undefined}
86 className={cn(
87 "-mb-px flex items-center gap-2 border-b-2 px-3 pb-3 text-sm transition-colors",
88 view === entry.view ? "border-accent font-medium text-fg" : "border-transparent text-muted hover:text-fg",
89 )}
90 >
91 {entry.icon}
92 {entry.label}
93 </Link>
94 ))}
95 </nav>
96
97 <div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
98 <div className="min-w-0 grow">
99 <InboxTabs tab={tab} counts={view === "inbox" ? counts : null} onChange={(next) => navigate(address({ tab: next === "all" ? null : next, before: null }))} />
100 </div>
101 {/* Why you were told: a review asked of you, a mention, what you watch. */}
102 <Select value={reason ?? "any"} onValueChange={(value) => navigate(address({ reason: value === "any" ? null : value, before: null }))}>
103 <SelectTrigger size="sm" aria-label="Reason" className="sm:w-44 lg:hidden">
104 <SelectValue />
105 </SelectTrigger>
106 <SelectContent align="end">
107 {REASON_FILTERS.map((entry) => (
108 <SelectItem key={entry.reason ?? "any"} value={entry.reason ?? "any"}>
109 {entry.label}
110 </SelectItem>
111 ))}
112 </SelectContent>
113 </Select>
114 </div>
115
116 <div className="mt-4">
117 {items == null ? (
118 <p className="rounded-lg border border-line px-4 py-6 text-sm text-muted">Notifications could not be loaded. Try again in a moment.</p>
119 ) : items.length === 0 ? (
120 <InboxEmpty tab={tab} view={view} />
121 ) : (
122 <ul className="space-y-2">
123 {items.map((item) => (
124 <InboxCard key={item.id} item={item} />
125 ))}
126 </ul>
127 )}
128 </div>
129
130 {(before || next) && (
131 <div className="mt-6 flex justify-between text-sm">
132 {before ? (
133 <Link to={address({ before: null })} className="text-muted hover:text-fg">
134 Newest
135 </Link>
136 ) : (
137 <span />
138 )}
139 {next && (
140 <Link to={address({ before: next })} className="font-medium text-accent hover:underline">
141 Older
142 </Link>
143 )}
144 </div>
145 )}
146 </main>
147 );
148}