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 settings | 1 | /** |
| 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 settings | 6 | */ |
| 7 | import { Eye, EyeOff } from "lucide-react"; | |
| 8 | import { Form, Link, data, useNavigation } from "react-router"; | |
| 9 | ||
| 10 | import type { WatchLevel } from "@g1t/contracts"; | |
| 11 | ||
| 12 | import type { Route } from "./+types/notifications"; | |
| 13 | import { ErrorText, SubmitButton } from "../../components/ui"; | |
| 14 | import { CheckboxOption } from "../../components/ui/checkbox"; | |
| 15 | import { RadioGroup, RadioOption } from "../../components/ui/radio-group"; | |
| 16 | import { EMAIL_REASONS, WATCH_CHOICES, WATCH_EVENT_LABEL, emailReasonsFromForm } from "../../lib/inbox"; | |
| 17 | import { page } from "../../lib/meta"; | |
| 18 | import { inbox } from "../../lib/services.server"; | |
| 19 | import { 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). |
| 21 | import { LiveNotificationSettings } from "../../components/notifications/settings"; | |
| Inbox on the site: reasons, a reason filter, Subscribe, Watch and settings | 22 | |
| 23 | export function meta(args: Route.MetaArgs) { | |
| 24 | return page(args, { title: "Notifications · Settings · g1t" }); | |
| 25 | } | |
| 26 | ||
| 27 | export 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 settings | 34 | } |
| 35 | ||
| 36 | export 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 | ||
| 68 | const LEVEL_LABEL: Record<WatchLevel, string> = Object.fromEntries(WATCH_CHOICES.map((choice) => [choice.level, choice.label])) as Record< | |
| 69 | WatchLevel, | |
| 70 | string | |
| 71 | >; | |
| 72 | ||
| 73 | export 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 settings | 75 | 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 settings | 83 | <section aria-labelledby="email-heading"> |
| 84 | <h2 id="email-heading" className="text-sm font-semibold"> | |
| 85 | ||
| 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 settings | 89 | </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 settings | 107 | </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 settings | 130 | </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.