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 }[] = [ | |
| 26 | { level: "all", label: "Everything", detail: "Every message in conversations you're in, and everything in your inbox." }, | |
| 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.