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

Chat and workspace agents: channels, DMs and named agents you talk to1import { Check, Hash, Lock, Plus, Search, SquarePen, X } from "lucide-react";
2import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
3import { useFetcher, useNavigate, useRouteLoaderData } from "react-router";
4
5import type { ChatSidebar, Result } from "@g1t/contracts";
6
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)7import { AgentPill } from "./marks";
8import { AgentAvatar } from "../agent-avatar";
Chat and workspace agents: channels, DMs and named agents you talk to9import { Avatar } from "../ui";
10import { Hint } from "../ui/hint";
11import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../ui/dialog";
12import { Switch } from "../ui/switch";
13import { channelName } from "../../lib/chat";
14import type { ChatLayoutData } from "../../routes/workspace/chat/layout";
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)15import { useLiveCounts } from "../../lib/notify-client";
16import { overlayEntries, unlisted } from "../../lib/notify-store";
Chat and workspace agents: channels, DMs and named agents you talk to17
18/** Chat mode's data, while a chat page is open; undefined elsewhere. */
19export function useChatData(): ChatLayoutData | undefined {
20 return useRouteLoaderData("routes/workspace/chat/layout") as ChatLayoutData | undefined;
21}
22
23/** The sidebar's own fetcher: every component that refreshes it shares this one. */
24export const SIDEBAR_FETCHER = "chat-sidebar";
25
26/** The sidebar as last read: refreshed in place, else what the page loaded. */
27export function useChatSidebar(): { sidebar: ChatSidebar | null; refresh: () => void } {
28 const data = useChatData();
29 const fetcher = useFetcher<Result<ChatSidebar>>({ key: SIDEBAR_FETCHER });
30 const slug = data?.slug;
31 const refresh = useCallback(() => {
32 if (slug) fetcher.load(`/${slug}/-/chat/api`);
33 // eslint-disable-next-line react-hooks/exhaustive-deps
34 }, [slug]);
35 const fresh = fetcher.data?.ok ? fetcher.data.value : null;
36 const sidebar = fresh ?? data?.sidebar ?? null;
37 // A conversation read on its page reads as read everywhere at once, until
38 // the service's next answer says so itself.
39 const [read, setRead] = useState<ReadonlySet<string>>(new Set());
40 useEffect(() => {
41 const onRead = (event: Event) => setRead((now) => new Set(now).add(String((event as CustomEvent).detail)));
42 window.addEventListener("g1t:chat-read", onRead);
43 return () => window.removeEventListener("g1t:chat-read", onRead);
44 }, []);
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)45 // Notify: the feed socket's counts (lib/notify-client.ts) over the sidebar as read, live.
46 const live = useLiveCounts(slug);
47 useEffect(() => setRead(new Set()), [sidebar, live]);
48 const shown = useMemo(() => {
49 const counted = sidebar && live ? { ...sidebar, entries: overlayEntries(sidebar.entries, live) } : sidebar;
50 return counted && read.size > 0
51 ? { ...counted, entries: counted.entries.map((e) => (read.has(e.channel.id) ? { ...e, unread: 0, mentions: 0 } : e)) }
52 : counted;
53 }, [sidebar, read, live]);
54 // A conversation the feed counts that the sidebar does not list (a new DM): read the sidebar again.
55 const missing = sidebar ? unlisted(sidebar.entries, live).join(",") : "";
56 useEffect(() => {
57 if (missing) refresh();
58 }, [missing, refresh]);
Chat and workspace agents: channels, DMs and named agents you talk to59 return { sidebar: shown, refresh };
60}
61
62/**
63 * Sends one request to the chat's JSON route and answers with the
64 * service's result; a network failure is a failure like any other. Doing
65 * something that changes the sidebar refreshes it.
66 */
67export function useChatSend(slug: string) {
68 const fetcher = useFetcher<Result<ChatSidebar>>({ key: SIDEBAR_FETCHER });
69 return useCallback(
70 async <T = unknown,>(body: Record<string, unknown>): Promise<Result<T>> => {
71 try {
72 const response = await fetch(`/${slug}/-/chat/api`, {
73 method: "POST",
74 headers: { "content-type": "application/json" },
75 body: JSON.stringify(body),
76 });
77 const result = (await response.json()) as Result<T>;
78 if (result.ok && body.intent !== "post" && body.intent !== "read") fetcher.load(`/${slug}/-/chat/api`);
79 return result;
80 } catch {
81 return { ok: false, error: { code: "conflict", message: "Couldn't reach g1t. Check your connection and try again." } };
82 }
83 },
84 // eslint-disable-next-line react-hooks/exhaustive-deps
85 [slug],
86 );
87}
88
89const QUIET_BUTTON =
90 "inline-flex h-9 items-center gap-2 rounded-md border border-line px-3 text-sm font-medium text-fg/90 transition-colors hover:border-line-strong hover:bg-surface hover:text-fg";
91const ICON_BUTTON =
92 "flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent";
93
94/** Making a channel: its name, what it is for, and whether it is private. */
95export function CreateChannelButton({ slug, variant = "icon" }: { slug: string; variant?: "icon" | "button" }) {
96 const [open, setOpen] = useState(false);
97 const [name, setName] = useState("");
98 const [topic, setTopic] = useState("");
99 const [secret, setSecret] = useState(false);
100 const [error, setError] = useState<string | null>(null);
101 const [busy, setBusy] = useState(false);
102 const send = useChatSend(slug);
103 const navigate = useNavigate();
104 const clean = channelName(name);
105 const submit = async () => {
106 if (!clean) return setError("Give the channel a name.");
107 setBusy(true);
108 setError(null);
109 const made = await send<{ to: string }>({ intent: "create_channel", name: clean, topic, private: secret });
110 setBusy(false);
111 if (!made.ok) return setError(made.error.message);
112 setOpen(false);
113 setName("");
114 setTopic("");
115 navigate(made.value.to);
116 };
117 return (
118 <>
119 {variant === "icon" ? (
120 <Hint label="Create a channel">
121 <button type="button" aria-label="Create a channel" onClick={() => setOpen(true)} className={ICON_BUTTON}>
122 <Plus size={14} />
123 </button>
124 </Hint>
125 ) : (
126 <button type="button" onClick={() => setOpen(true)} className={QUIET_BUTTON}>
127 <Hash size={15} />
128 Create a channel
129 </button>
130 )}
131 <Dialog open={open} onOpenChange={setOpen}>
132 <DialogContent>
133 <DialogHeader>
134 <DialogTitle>Create a channel</DialogTitle>
135 <DialogDescription>A place for one topic: a project, a team, a launch. Invite people and agents once it's made.</DialogDescription>
136 </DialogHeader>
137 <form
138 className="grid gap-4"
139 onSubmit={(event) => {
140 event.preventDefault();
141 void submit();
142 }}
143 >
144 <label className="grid gap-1.5">
145 <span className="text-sm font-medium text-muted">Name</span>
146 <span className="flex h-10 items-center gap-1.5 rounded-md border border-line bg-bg px-3 focus-within:border-accent-dim">
147 {secret ? <Lock size={14} className="text-faint" /> : <Hash size={14} className="text-faint" />}
148 <input
149 autoFocus
150 value={name}
151 onChange={(event) => setName(event.target.value)}
152 placeholder="release-train"
153 maxLength={80}
154 autoComplete="off"
155 data-1p-ignore
156 className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint"
157 />
158 </span>
159 {name && clean !== name && <span className="text-xs text-faint">It will be #{clean || "…"}</span>}
160 </label>
161 <label className="grid gap-1.5">
162 <span className="text-sm font-medium text-muted">
163 Topic <span className="font-normal text-faint">(optional)</span>
164 </span>
165 <input
166 value={topic}
167 onChange={(event) => setTopic(event.target.value)}
168 placeholder="What this channel is for"
169 maxLength={250}
170 autoComplete="off"
171 data-1p-ignore
172 className="h-10 rounded-md border border-line bg-bg px-3 text-sm outline-none placeholder:text-faint focus:border-accent-dim"
173 />
174 </label>
175 <label className="flex items-start justify-between gap-4 rounded-lg border border-line bg-bg/60 p-3">
176 <span>
177 <span className="block text-sm font-medium">Private</span>
178 <span className="mt-0.5 block text-xs text-muted">Only people and agents you invite can find and read it.</span>
179 </span>
180 <Switch checked={secret} onCheckedChange={setSecret} aria-label="Private" />
181 </label>
182 {error && <p className="text-sm text-danger">{error}</p>}
183 <DialogFooter>
184 <button type="button" onClick={() => setOpen(false)} className={QUIET_BUTTON}>
185 Cancel
186 </button>
187 <button
188 type="submit"
189 disabled={busy || !clean}
190 className="inline-flex h-9 items-center justify-center rounded-md bg-accent px-3.5 text-sm font-medium text-bg transition-colors hover:bg-accent-hover disabled:opacity-50"
191 >
192 {busy ? "Creating…" : "Create channel"}
193 </button>
194 </DialogFooter>
195 </form>
196 </DialogContent>
197 </Dialog>
198 </>
199 );
200}
201
202/** Someone a new message can go to. */
203type Pickable = { key: string; kind: "user" | "agent"; name: string; display: string; avatar: string | null; role?: string | null };
204
205/**
206 * A new direct message: choose people, agents or both, then open the
207 * conversation between you (the same one each time for the same people).
208 */
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)209export function NewMessageButton({ slug, variant = "icon", children }: { slug: string; variant?: "icon" | "button" | "fab"; children?: ReactNode }) {
Chat and workspace agents: channels, DMs and named agents you talk to210 const [open, setOpen] = useState(false);
211 return (
212 <>
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)213 {variant === "fab" ? (
214 // A phone's list: the round button above the tab bar.
215 <button
216 type="button"
217 aria-label="New message"
218 onClick={() => setOpen(true)}
219 className="flex size-14 items-center justify-center rounded-2xl bg-accent text-bg shadow-lg shadow-black/50 transition-transform active:scale-95"
220 >
221 <SquarePen size={22} />
222 </button>
223 ) : variant === "icon" ? (
Chat and workspace agents: channels, DMs and named agents you talk to224 <Hint label="New message">
225 <button type="button" aria-label="New message" onClick={() => setOpen(true)} className={ICON_BUTTON.replace("size-7", "size-8")}>
226 <SquarePen size={15} />
227 </button>
228 </Hint>
229 ) : (
230 <button type="button" onClick={() => setOpen(true)} className={QUIET_BUTTON}>
231 <SquarePen size={15} />
232 {children ?? "New message"}
233 </button>
234 )}
235 <Dialog open={open} onOpenChange={setOpen}>
236 <DialogContent className="max-w-md gap-0 p-0" showClose={false}>
237 {open && <NewMessage slug={slug} onDone={() => setOpen(false)} />}
238 </DialogContent>
239 </Dialog>
240 </>
241 );
242}
243
244function NewMessage({ slug, onDone }: { slug: string; onDone: () => void }) {
245 const data = useChatData();
246 const [query, setQuery] = useState("");
247 const [chosen, setChosen] = useState<Pickable[]>([]);
248 const [active, setActive] = useState(0);
249 const [error, setError] = useState<string | null>(null);
250 const [busy, setBusy] = useState(false);
251 const input = useRef<HTMLInputElement>(null);
252 const send = useChatSend(slug);
253 const navigate = useNavigate();
254 const everyone = useMemo<Pickable[]>(() => {
255 const me = data?.me.username.toLowerCase();
256 return [
257 ...(data?.agents ?? []).map((agent) => ({
258 key: `agent:${agent.id}`,
259 kind: "agent" as const,
260 name: agent.handle,
261 display: agent.display_name,
262 avatar: agent.avatar,
263 role: agent.role,
264 })),
265 ...(data?.people ?? [])
266 .filter((person) => person.name.toLowerCase() !== me)
267 .map((person) => ({ key: `user:${person.name}`, kind: "user" as const, name: person.name, display: person.display_name, avatar: person.avatar })),
268 ];
269 }, [data]);
270 const q = query.trim().toLowerCase().replace(/^@/, "");
271 const picked = new Set(chosen.map((p) => p.key));
272 const options = everyone.filter(
273 (p) => !picked.has(p.key) && (!q || p.name.toLowerCase().includes(q) || p.display.toLowerCase().includes(q)),
274 );
275 const take = (person: Pickable) => {
276 setChosen((now) => [...now, person]);
277 setQuery("");
278 setActive(0);
279 input.current?.focus();
280 };
281 const go = async () => {
282 if (chosen.length === 0) return;
283 setBusy(true);
284 setError(null);
285 const opened = await send<{ to: string }>({ intent: "dm", members: chosen.map((p) => p.key) });
286 setBusy(false);
287 if (!opened.ok) return setError(opened.error.message);
288 onDone();
289 navigate(opened.value.to);
290 };
291 return (
292 <div className="flex max-h-[min(36rem,calc(100dvh-2rem))] flex-col">
293 <div className="border-b border-line px-5 pt-5 pb-4">
294 <DialogTitle>New message</DialogTitle>
295 <DialogDescription className="mt-1">To one person, a few, or an agent. Agents answer in the conversation.</DialogDescription>
296 <div className="mt-4 flex min-h-10 flex-wrap items-center gap-1.5 rounded-md border border-line bg-bg px-2 py-1.5 focus-within:border-accent-dim">
297 <span className="pl-1 text-sm text-faint">To:</span>
298 {chosen.map((person) => (
299 <span key={person.key} className="flex h-6 items-center gap-1.5 rounded-md bg-raised pr-1 pl-1 text-[0.8125rem]">
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)300 {person.kind === "agent" ? <AgentAvatar agent={{ id: person.key.slice(6), handle: person.name, avatar: person.avatar }} size={16} /> : <Avatar name={person.name} image={person.avatar} size={16} />}
Chat and workspace agents: channels, DMs and named agents you talk to301 {person.display}
302 <button
303 type="button"
304 aria-label={`Remove ${person.display}`}
305 onClick={() => setChosen((now) => now.filter((p) => p.key !== person.key))}
306 className="rounded p-0.5 text-faint hover:bg-line hover:text-fg"
307 >
308 <X size={12} />
309 </button>
310 </span>
311 ))}
312 <input
313 ref={input}
314 autoFocus
315 value={query}
316 onChange={(event) => {
317 setQuery(event.target.value);
318 setActive(0);
319 }}
320 onKeyDown={(event) => {
321 if (event.key === "ArrowDown" || event.key === "ArrowUp") {
322 event.preventDefault();
323 const count = Math.max(1, options.length);
324 setActive((index) => (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count);
325 } else if (event.key === "Enter") {
326 event.preventDefault();
327 const person = options[active];
328 if (person && q) take(person);
329 else void go();
330 } else if (event.key === "Backspace" && !query && chosen.length) {
331 setChosen((now) => now.slice(0, -1));
332 }
333 }}
334 placeholder={chosen.length ? "" : "Name or @handle"}
335 aria-label="Who to message"
336 autoComplete="off"
337 data-1p-ignore
338 className="h-7 min-w-24 grow bg-transparent px-1 text-sm outline-none placeholder:text-faint"
339 />
340 </div>
341 </div>
342 <ul role="listbox" aria-label="People and agents" className="min-h-0 grow overflow-y-auto p-1.5">
343 {options.length === 0 && (
344 <li className="flex items-center gap-2 px-3 py-6 text-sm text-muted">
345 <Search size={14} />
346 {everyone.length === 0 ? "No one else is in this workspace yet." : "No one matches."}
347 </li>
348 )}
349 {options.map((person, index) => (
350 <li
351 key={person.key}
352 role="option"
353 aria-selected={index === active}
354 onMouseEnter={() => setActive(index)}
355 onClick={() => take(person)}
356 className={`flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 ${index === active ? "bg-raised" : ""}`}
357 >
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)358 {person.kind === "agent" ? <AgentAvatar agent={{ id: person.key.slice(6), handle: person.name, avatar: person.avatar }} size={28} /> : <Avatar name={person.name} image={person.avatar} size={28} />}
Chat and workspace agents: channels, DMs and named agents you talk to359 <span className="min-w-0 grow">
360 <span className="flex items-center gap-1.5 text-sm font-medium">
361 <span className="truncate">{person.display}</span>
362 {person.kind === "agent" && <AgentPill />}
363 </span>
364 <span className="block truncate text-xs text-faint">
365 @{person.name}
366 {person.role ? ` · ${person.role}` : ""}
367 </span>
368 </span>
369 {index === active && <Check size={14} className="text-faint" />}
370 </li>
371 ))}
372 </ul>
373 <div className="flex items-center justify-between gap-3 border-t border-line px-5 py-3">
374 <span className="min-w-0 truncate text-sm text-danger">{error}</span>
375 <div className="flex shrink-0 gap-2">
376 <button type="button" onClick={onDone} className={QUIET_BUTTON}>
377 Cancel
378 </button>
379 <button
380 type="button"
381 disabled={busy || chosen.length === 0}
382 onClick={() => void go()}
383 className="inline-flex h-9 items-center justify-center rounded-md bg-accent px-3.5 text-sm font-medium text-bg transition-colors hover:bg-accent-hover disabled:opacity-50"
384 >
385 {busy ? "Opening…" : "Open conversation"}
386 </button>
387 </div>
388 </div>
389 </div>
390 );
391}

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