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

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)1import { BellRing, Send } from "lucide-react";
2import { useEffect, useState } from "react";
3
4import type { Membership, NotifyLevel, NotifyPreferences, NotifyPreferencesChange, NotifyStatus } from "@g1t/contracts";
5
6import { Button } from "../ui";
7import { RadioGroup, RadioOption } from "../ui/radio-group";
8import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select";
9import { Switch } from "../ui/switch";
10import {
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
25const 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
35type Device = "on" | "off" | "blocked" | "unsupported" | "unavailable" | "desktop";
36
37function 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
45const 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 */
59export 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.