Skip to content
188 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.

Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings1/**
2 * Settings → Notifications: what you are also emailed for, how you watch
3 * repositories you create, and the repositories you watch other than the
4 * default way. The events service keeps all three (`inbox_settings`,
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.5 * `inbox_watched`); the notifications themselves are at /notifications.
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings6 */
7import { Eye, EyeOff } from "lucide-react";
8import { Form, Link, data, useNavigation } from "react-router";
9
10import type { WatchLevel } from "@g1t/contracts";
11
12import type { Route } from "./+types/notifications";
13import { ErrorText, SubmitButton } from "../../components/ui";
14import { CheckboxOption } from "../../components/ui/checkbox";
15import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
16import { EMAIL_REASONS, WATCH_CHOICES, WATCH_EVENT_LABEL, emailReasonsFromForm } from "../../lib/inbox";
17import { page } from "../../lib/meta";
18import { inbox } from "../../lib/services.server";
19import { assertSameOrigin, requireUser } from "../../lib/session.server";
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)20// Live notifications: pop-ups, browser notifications, sound (services/notify).
21import { LiveNotificationSettings } from "../../components/notifications/settings";
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings22
23export function meta(args: Route.MetaArgs) {
24 return page(args, { title: "Notifications · Settings · g1t" });
25}
26
27export async function loader({ request, context }: Route.LoaderArgs) {
28 const user = requireUser(context, request);
29 const [settings, watched] = await Promise.all([
30 inbox.settings(user.username).catch(() => null),
31 inbox.watched(user.username).catch(() => null),
32 ]);
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)33 return { settings, watched, workspaces: user.workspaces ?? [] };
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings34}
35
36export async function action({ request, context }: Route.ActionArgs) {
37 assertSameOrigin(request);
38 const user = requireUser(context, request);
39 const form = await request.formData();
40 const intent = String(form.get("intent") ?? "");
41 // One shape for every answer: what was said, beside the form that asked.
42 const answer = (saved: string | null, error: string | null = null) => ({ intent, saved, error });
43 try {
44 switch (intent) {
45 case "email":
46 await inbox.updateSettings(user.username, { email: emailReasonsFromForm(form) });
47 return answer("Saved. You'll be emailed for what is checked.");
48 case "default_watch": {
49 const level = String(form.get("default_watch") ?? "");
50 if (level !== "participating" && level !== "all") return data(answer(null, "Choose one."), { status: 400 });
51 await inbox.updateSettings(user.username, { defaultWatch: level });
52 return answer("Saved. It applies to repositories you create from now on.");
53 }
54 case "unwatch": {
55 const repoId = String(form.get("repo_id") ?? "");
56 if (!repoId) return data(answer(null, "Nothing to do."), { status: 400 });
57 await inbox.watch(user.username, repoId, String(form.get("repo") ?? ""), null);
58 return answer(null);
59 }
60 default:
61 return data(answer(null, "Nothing to do."), { status: 400 });
62 }
63 } catch {
64 return data(answer(null, "That did not save. Try again in a moment."), { status: 503 });
65 }
66}
67
68const LEVEL_LABEL: Record<WatchLevel, string> = Object.fromEntries(WATCH_CHOICES.map((choice) => [choice.level, choice.label])) as Record<
69 WatchLevel,
70 string
71>;
72
73export default function NotificationSettings({ loaderData, actionData }: Route.ComponentProps) {
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)74 const { settings, watched, workspaces } = loaderData;
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings75 const navigation = useNavigation();
76 const said = (intent: string) => (actionData?.intent === intent && navigation.state === "idle" ? actionData : null);
77 if (!settings) {
78 return <p className="rounded-lg border border-line px-4 py-6 text-sm text-muted">Your notification settings could not be loaded. Try again in a moment.</p>;
79 }
80 return (
81 <div className="space-y-10">
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)82 <LiveNotificationSettings workspaces={workspaces} />
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings83 <section aria-labelledby="email-heading">
84 <h2 id="email-heading" className="text-sm font-semibold">
85 Email
86 </h2>
87 <p className="mt-1 text-sm text-muted">
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.88 Besides your <Link to="/notifications" className="text-fg underline underline-offset-4">notifications</Link>, email me at my primary address when:
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings89 </p>
90 <Form method="post" className="mt-4 space-y-3">
91 <input type="hidden" name="intent" value="email" />
92 {EMAIL_REASONS.map((entry) => (
93 <CheckboxOption
94 key={entry.reason}
95 name="email"
96 value={entry.reason}
97 defaultChecked={settings.email.includes(entry.reason)}
98 label={entry.label}
99 description={entry.detail}
100 />
101 ))}
102 <div className="flex items-center gap-3 pt-2">
103 <SubmitButton variant="quiet" match={{ intent: "email" }} pending="Saving…">
104 Save email settings
105 </SubmitButton>
Merge branch 'worktree-agent-ad7c6d88d93adc817'106 {said("email")?.saved && <p className="text-xs text-success">{said("email")?.saved}</p>}
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings107 </div>
108 <ErrorText>{said("email")?.error}</ErrorText>
109 </Form>
110 <p className="mt-3 text-xs text-faint">Email goes only to a confirmed address, and only about repositories you can still read.</p>
111 </section>
112
113 <section aria-labelledby="watching-heading">
114 <h2 id="watching-heading" className="text-sm font-semibold">
115 Repositories you create
116 </h2>
117 <p className="mt-1 text-sm text-muted">How you watch a repository when you make it. Change any one from its Watch menu.</p>
118 <Form method="post" className="mt-4">
119 <input type="hidden" name="intent" value="default_watch" />
120 <RadioGroup name="default_watch" defaultValue={settings.defaultWatch === "participating" ? "participating" : "all"}>
121 {WATCH_CHOICES.filter((choice) => choice.level === "all" || choice.level === "participating").map((choice) => (
122 <RadioOption key={choice.level} value={choice.level} label={choice.label} description={choice.detail} />
123 ))}
124 </RadioGroup>
125 <div className="mt-4 flex items-center gap-3">
126 <SubmitButton variant="quiet" match={{ intent: "default_watch" }} pending="Saving…">
127 Save
128 </SubmitButton>
Merge branch 'worktree-agent-ad7c6d88d93adc817'129 {said("default_watch")?.saved && <p className="text-xs text-success">{said("default_watch")?.saved}</p>}
Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings130 </div>
131 <ErrorText>{said("default_watch")?.error}</ErrorText>
132 </Form>
133 </section>
134
135 <section aria-labelledby="watched-heading">
136 <h2 id="watched-heading" className="text-sm font-semibold">
137 Repositories you watch
138 </h2>
139 <p className="mt-1 text-sm text-muted">Those you watch other than the default way, which is only what you take part in.</p>
140 {watched == null ? (
141 <p className="mt-4 text-sm text-muted">This list could not be loaded. Try again in a moment.</p>
142 ) : watched.length === 0 ? (
143 <p className="mt-4 rounded-lg border border-dashed border-line px-4 py-6 text-center text-sm text-muted">
144 You watch every repository the default way.
145 </p>
146 ) : (
147 <ul className="mt-4 divide-y divide-line rounded-md border border-line">
148 {watched.map((watching) => (
149 <li key={watching.repoId} className="flex items-center gap-3 px-4 py-3">
150 {watching.level === "ignore" ? (
151 <EyeOff size={15} className="shrink-0 text-faint" aria-hidden="true" />
152 ) : (
153 <Eye size={15} className="shrink-0 text-faint" aria-hidden="true" />
154 )}
155 <div className="min-w-0 grow">
156 {watching.repo ? (
157 <Link to={`/${watching.repo}`} className="block truncate font-mono text-sm hover:underline">
158 {watching.repo}
159 </Link>
160 ) : (
161 <span className="block truncate font-mono text-sm text-muted">{watching.repoId}</span>
162 )}
163 <p className="truncate text-xs text-muted">
164 {LEVEL_LABEL[watching.level]}
165 {watching.level === "custom" && watching.events.length > 0 && `: ${watching.events.map((kind) => WATCH_EVENT_LABEL[kind]).join(", ")}`}
166 </p>
167 </div>
168 <Form method="post" className="shrink-0">
169 <input type="hidden" name="intent" value="unwatch" />
170 <input type="hidden" name="repo_id" value={watching.repoId} />
171 <input type="hidden" name="repo" value={watching.repo ?? ""} />
172 <SubmitButton
173 variant="quiet"
174 match={{ intent: "unwatch", repo_id: watching.repoId }}
175 pending="…"
176 className="rounded-md border border-line px-2.5 py-1 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg disabled:opacity-60"
177 >
178 {watching.level === "ignore" ? "Stop ignoring" : "Stop watching"}
179 </SubmitButton>
180 </Form>
181 </li>
182 ))}
183 </ul>
184 )}
185 </section>
186 </div>
187 );
188}

This file's history is long; its oldest lines are credited to the oldest commit read.