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.
| 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) | 1 | import { BellRing, Send } from "lucide-react"; |
| 2 | import { useEffect, useState } from "react"; | |
| 3 | ||
| 4 | import type { Membership, NotifyLevel, NotifyPreferences, NotifyPreferencesChange, NotifyStatus } from "@g1t/contracts"; | |
| 5 | ||
| 6 | import { Button } from "../ui"; | |
| 7 | import { RadioGroup, RadioOption } from "../ui/radio-group"; | |
| 8 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; | |
| 9 | import { Switch } from "../ui/switch"; | |
| 10 | import { | |
| 11 | desktopBridge, | |
| 12 | disablePush, | |
| 13 | enablePush, | |
| 14 | notifyStatus, | |
| 15 | permission, | |
| 16 | pushChoice, | |
| 17 | pushSupported, | |
| 18 | savePreferences, | |
| 19 | sendTest, | |
| 20 | setSound, | |
| 21 | soundOn, | |
| 22 | chime, | |
| 23 | } from "../../lib/notify-client"; | |
| 24 | ||
| 25 | const LEVELS: { level: NotifyLevel; label: string; detail: string }[] = [ | |
| 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. | 26 | { level: "all", label: "Everything", detail: "Every message in conversations you're in, and everything in your notifications." }, |
| 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) | 27 | { |
| 28 | level: "dms_mentions", | |
| 29 | label: "Direct messages, mentions and replies", | |
| 30 | detail: "Messages to you, @mentions, replies in your threads, and agents or reviews waiting on you.", | |
| 31 | }, | |
| 32 | { level: "none", label: "Nothing", detail: "No pop-ups or browser notifications. Counts still update." }, | |
| 33 | ]; | |
| 34 | ||
| 35 | type Device = "on" | "off" | "blocked" | "unsupported" | "unavailable" | "desktop"; | |
| 36 | ||
| 37 | function deviceState(status: NotifyStatus | null): Device { | |
| 38 | if (desktopBridge()) return "desktop"; | |
| 39 | if (!pushSupported()) return "unsupported"; | |
| 40 | if (status && !status.vapid_public_key) return "unavailable"; | |
| 41 | if (permission() === "denied") return "blocked"; | |
| 42 | return permission() === "granted" && status?.subscribed && pushChoice() !== "off" ? "on" : "off"; | |
| 43 | } | |
| 44 | ||
| 45 | const DEVICE_LINE: Record<Device, string> = { | |
| 46 | on: "On. This browser shows a notification when you're away from g1t.", | |
| 47 | off: "Off. You'll only see notifications while g1t is open.", | |
| 48 | blocked: "Blocked by your browser. Allow notifications for this site in its settings, then come back.", | |
| 49 | unsupported: "This browser can't show notifications.", | |
| 50 | unavailable: "Browser notifications aren't set up on this server.", | |
| 51 | desktop: "The desktop app shows notifications itself.", | |
| 52 | }; | |
| 53 | ||
| 54 | /** | |
| 55 | * Settings → Notifications, the live part: what pops up and is pushed, | |
| 56 | * this browser's notifications, the sound, and a test. Kept by the notify | |
| 57 | * service (services/notify); the sound is this browser's own. | |
| 58 | */ | |
| 59 | export function LiveNotificationSettings({ workspaces, initial = null }: { workspaces: Membership[]; initial?: NotifyStatus | null }) { | |
| 60 | const [status, setStatus] = useState<NotifyStatus | null>(initial); | |
| 61 | const [prefs, setPrefs] = useState<NotifyPreferences | null>(initial?.preferences ?? null); | |
| 62 | const [device, setDevice] = useState<Device>("off"); | |
| 63 | const [sound, setSoundState] = useState(false); | |
| 64 | const [busy, setBusy] = useState<string | null>(null); | |
| 65 | const [said, setSaid] = useState<string | null>(null); | |
| 66 | const [failed, setFailed] = useState(false); | |
| 67 | ||
| 68 | useEffect(() => { | |
| 69 | setSoundState(soundOn()); | |
| 70 | if (initial) { | |
| 71 | setDevice(deviceState(initial)); | |
| 72 | return; | |
| 73 | } | |
| 74 | notifyStatus() | |
| 75 | .then((s) => { | |
| 76 | setStatus(s); | |
| 77 | setPrefs(s?.preferences ?? null); | |
| 78 | setDevice(deviceState(s)); | |
| 79 | if (!s) setFailed(true); | |
| 80 | }) | |
| 81 | .catch(() => setFailed(true)); | |
| 82 | }, [initial]); | |
| 83 | ||
| 84 | const save = async (change: NotifyPreferencesChange) => { | |
| 85 | if (prefs) { | |
| 86 | const workspaces = { ...prefs.workspaces }; | |
| 87 | for (const [slug, level] of Object.entries(change.workspaces ?? {})) { | |
| 88 | if (level) workspaces[slug] = level; | |
| 89 | else delete workspaces[slug]; | |
| 90 | } | |
| 91 | setPrefs({ level: change.level ?? prefs.level, workspaces }); | |
| 92 | } | |
| 93 | setBusy("prefs"); | |
| 94 | const saved = await savePreferences(change).catch(() => null); | |
| 95 | setBusy(null); | |
| 96 | if (saved) { | |
| 97 | setPrefs(saved); | |
| 98 | setSaid("Saved."); | |
| 99 | } else setSaid("That didn't save. Try again in a moment."); | |
| 100 | }; | |
| 101 | ||
| 102 | const toggleDevice = async (on: boolean) => { | |
| 103 | setBusy("device"); | |
| 104 | if (on) { | |
| 105 | const result = await enablePush(); | |
| 106 | setDevice(result === "on" ? "on" : result === "denied" ? (permission() === "denied" ? "blocked" : "off") : deviceState(status)); | |
| 107 | if (result === "failed") setSaid("This browser couldn't be set up. Try again in a moment."); | |
| 108 | } else { | |
| 109 | await disablePush().catch(() => {}); | |
| 110 | setDevice("off"); | |
| 111 | } | |
| 112 | setBusy(null); | |
| 113 | }; | |
| 114 | ||
| 115 | const test = async () => { | |
| 116 | setBusy("test"); | |
| 117 | const sent = await sendTest().catch(() => null); | |
| 118 | setBusy(null); | |
| 119 | if (!sent) setSaid("The test didn't send. Try again in a moment."); | |
| 120 | else if (sent.pushed > 0) setSaid(`Sent. Also pushed to ${sent.pushed === 1 ? "1 browser" : `${sent.pushed} browsers`}.`); | |
| 121 | else setSaid("Sent. It shows here; turn on browser notifications to get it when you're away."); | |
| 122 | }; | |
| 123 | ||
| 124 | if (failed) { | |
| 125 | return ( | |
| 126 | <section aria-labelledby="live-heading"> | |
| 127 | <h2 id="live-heading" className="text-sm font-semibold"> | |
| 128 | Pop-ups and browser notifications | |
| 129 | </h2> | |
| 130 | <p className="mt-3 rounded-lg border border-line px-4 py-6 text-sm text-muted">These settings could not be loaded. Try again in a moment.</p> | |
| 131 | </section> | |
| 132 | ); | |
| 133 | } | |
| 134 | ||
| 135 | const deviceOn = device === "on"; | |
| 136 | const deviceLocked = device === "unsupported" || device === "unavailable" || device === "blocked" || device === "desktop"; | |
| 137 | ||
| 138 | return ( | |
| 139 | <section aria-labelledby="live-heading" className="space-y-6"> | |
| 140 | <div> | |
| 141 | <h2 id="live-heading" className="text-sm font-semibold"> | |
| 142 | Pop-ups and browser notifications | |
| 143 | </h2> | |
| 144 | <p className="mt-1 text-sm text-muted">What pops up while g1t is open, and what reaches you when it isn't.</p> | |
| 145 | </div> | |
| 146 | ||
| 147 | <div> | |
| 148 | <h3 className="text-[0.8125rem] font-medium text-fg">Notify me about</h3> | |
| 149 | <RadioGroup | |
| 150 | className="mt-3" | |
| 151 | value={prefs?.level ?? "dms_mentions"} | |
| 152 | disabled={!prefs} | |
| 153 | onValueChange={(value) => void save({ level: value as NotifyLevel })} | |
| 154 | aria-label="Notify me about" | |
| 155 | > | |
| 156 | {LEVELS.map((choice) => ( | |
| 157 | <RadioOption key={choice.level} value={choice.level} label={choice.label} description={choice.detail} /> | |
| 158 | ))} | |
| 159 | </RadioGroup> | |
| 160 | </div> | |
| 161 | ||
| 162 | {workspaces.length > 1 && prefs && ( | |
| 163 | <div> | |
| 164 | <h3 className="text-[0.8125rem] font-medium text-fg">For a workspace</h3> | |
| 165 | <ul className="mt-3 divide-y divide-line rounded-lg border border-line"> | |
| 166 | {workspaces.map((membership) => ( | |
| 167 | <li key={membership.slug} className="flex items-center gap-3 px-3.5 py-2.5"> | |
| 168 | <span className="min-w-0 grow truncate text-sm">{membership.name || membership.slug}</span> | |
| 169 | <Select | |
| 170 | value={prefs.workspaces[membership.slug] ?? "default"} | |
| 171 | onValueChange={(value) => void save({ workspaces: { [membership.slug]: value === "default" ? null : (value as NotifyLevel) } })} | |
| 172 | > | |
| 173 | <SelectTrigger size="sm" className="w-44" aria-label={`Notifications for ${membership.name || membership.slug}`}> | |
| 174 | <SelectValue /> | |
| 175 | </SelectTrigger> | |
| 176 | <SelectContent> | |
| 177 | <SelectItem value="default">As above</SelectItem> | |
| 178 | <SelectItem value="all">Everything</SelectItem> | |
| 179 | <SelectItem value="dms_mentions">DMs and mentions</SelectItem> | |
| 180 | <SelectItem value="none">Nothing</SelectItem> | |
| 181 | </SelectContent> | |
| 182 | </Select> | |
| 183 | </li> | |
| 184 | ))} | |
| 185 | </ul> | |
| 186 | </div> | |
| 187 | )} | |
| 188 | ||
| 189 | <div className="divide-y divide-line rounded-xl border border-line bg-surface"> | |
| 190 | <div className="flex items-start gap-4 p-4"> | |
| 191 | <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent/12 text-accent"> | |
| 192 | <BellRing size={16} /> | |
| 193 | </span> | |
| 194 | <div className="min-w-0 grow"> | |
| 195 | <p className="text-sm font-medium text-fg">Browser notifications</p> | |
| 196 | <p className="mt-0.5 text-xs text-muted" role="status"> | |
| 197 | {DEVICE_LINE[device]} | |
| 198 | </p> | |
| 199 | </div> | |
| 200 | <Switch | |
| 201 | checked={deviceOn} | |
| 202 | disabled={deviceLocked || busy === "device" || !status} | |
| 203 | onCheckedChange={(on) => void toggleDevice(on)} | |
| 204 | aria-label="Browser notifications" | |
| 205 | /> | |
| 206 | </div> | |
| 207 | <div className="flex items-start gap-4 p-4"> | |
| 208 | <div className="min-w-0 grow pl-12"> | |
| 209 | <p className="text-sm font-medium text-fg">Sound</p> | |
| 210 | <p className="mt-0.5 text-xs text-muted">A soft chime with each pop-up, in this browser.</p> | |
| 211 | </div> | |
| 212 | <Switch | |
| 213 | checked={sound} | |
| 214 | onCheckedChange={(on) => { | |
| 215 | setSound(on); | |
| 216 | setSoundState(on); | |
| 217 | if (on) chime(); | |
| 218 | }} | |
| 219 | aria-label="Sound" | |
| 220 | /> | |
| 221 | </div> | |
| 222 | </div> | |
| 223 | ||
| 224 | <div className="flex flex-wrap items-center gap-3"> | |
| 225 | <Button type="button" variant="quiet" onClick={() => void test()} disabled={busy === "test" || !status}> | |
| 226 | <Send size={14} /> | |
| 227 | {busy === "test" ? "Sending…" : "Send a test notification"} | |
| 228 | </Button> | |
| 229 | {said && <p className="text-xs text-muted">{said}</p>} | |
| 230 | </div> | |
| 231 | </section> | |
| 232 | ); | |
| 233 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.