Skip to content
441 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";
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar13import { channelName, shownHandle, shownName } from "../../lib/chat";
Chat and workspace agents: channels, DMs and named agents you talk to14import 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
Chat controls, public profiles, shadcn selects, and no Docs tab in a project94/** Why someone may not make a channel: the workspace keeps it to its owners. */
95export const OWNERS_ONLY_CHANNELS = "Only workspace owners can create channels here.";
96
97/**
98 * What the viewer may make, from the workspace's chat settings (Settings,
99 * Chat). Until the sidebar arrives, everything: the chat service checks.
100 */
101export function useChannelCreation(): { public: boolean; private: boolean; any: boolean } {
102 const { sidebar } = useChatSidebar();
103 const open = sidebar?.can?.create_public_channels ?? true;
104 const closed = sidebar?.can?.create_private_channels ?? true;
105 return { public: open, private: closed, any: open || closed };
106}
107
108/**
109 * Making a channel: its name, what it is for, and whether it is private.
110 * Someone the workspace's settings do not let make one sees the button
111 * turned off, with why; someone who may make only one kind gets that kind.
112 */
Chat and workspace agents: channels, DMs and named agents you talk to113export function CreateChannelButton({ slug, variant = "icon" }: { slug: string; variant?: "icon" | "button" }) {
114 const [open, setOpen] = useState(false);
115 const [name, setName] = useState("");
116 const [topic, setTopic] = useState("");
117 const [secret, setSecret] = useState(false);
118 const [error, setError] = useState<string | null>(null);
119 const [busy, setBusy] = useState(false);
120 const send = useChatSend(slug);
121 const navigate = useNavigate();
Chat controls, public profiles, shadcn selects, and no Docs tab in a project122 const may = useChannelCreation();
123 // Only one kind allowed: that kind, and the switch says why it is fixed.
124 const isPrivate = !may.public ? true : !may.private ? false : secret;
Chat and workspace agents: channels, DMs and named agents you talk to125 const clean = channelName(name);
126 const submit = async () => {
127 if (!clean) return setError("Give the channel a name.");
128 setBusy(true);
129 setError(null);
Chat controls, public profiles, shadcn selects, and no Docs tab in a project130 const made = await send<{ to: string }>({ intent: "create_channel", name: clean, topic, private: isPrivate });
Chat and workspace agents: channels, DMs and named agents you talk to131 setBusy(false);
132 if (!made.ok) return setError(made.error.message);
133 setOpen(false);
134 setName("");
135 setTopic("");
136 navigate(made.value.to);
137 };
Chat controls, public profiles, shadcn selects, and no Docs tab in a project138 if (!may.any) {
139 // Shown off, not hidden, so nobody wonders where it went.
140 return (
141 <Hint label={OWNERS_ONLY_CHANNELS} disabled>
142 {variant === "icon" ? (
143 <button type="button" aria-label="Create a channel" disabled className={`${ICON_BUTTON} opacity-40`}>
144 <Plus size={14} />
145 </button>
146 ) : (
147 <button type="button" disabled className={`${QUIET_BUTTON} opacity-50`}>
148 <Lock size={14} />
149 Create a channel
150 </button>
151 )}
152 </Hint>
153 );
154 }
Chat and workspace agents: channels, DMs and named agents you talk to155 return (
156 <>
157 {variant === "icon" ? (
158 <Hint label="Create a channel">
159 <button type="button" aria-label="Create a channel" onClick={() => setOpen(true)} className={ICON_BUTTON}>
160 <Plus size={14} />
161 </button>
162 </Hint>
163 ) : (
164 <button type="button" onClick={() => setOpen(true)} className={QUIET_BUTTON}>
165 <Hash size={15} />
166 Create a channel
167 </button>
168 )}
169 <Dialog open={open} onOpenChange={setOpen}>
170 <DialogContent>
171 <DialogHeader>
172 <DialogTitle>Create a channel</DialogTitle>
173 <DialogDescription>A place for one topic: a project, a team, a launch. Invite people and agents once it's made.</DialogDescription>
174 </DialogHeader>
175 <form
176 className="grid gap-4"
177 onSubmit={(event) => {
178 event.preventDefault();
179 void submit();
180 }}
181 >
182 <label className="grid gap-1.5">
183 <span className="text-sm font-medium text-muted">Name</span>
184 <span className="flex h-10 items-center gap-1.5 rounded-md border border-line bg-bg px-3 focus-within:border-accent-dim">
Chat controls, public profiles, shadcn selects, and no Docs tab in a project185 {isPrivate ? <Lock size={14} className="text-faint" /> : <Hash size={14} className="text-faint" />}
Chat and workspace agents: channels, DMs and named agents you talk to186 <input
187 autoFocus
188 value={name}
189 onChange={(event) => setName(event.target.value)}
190 placeholder="release-train"
191 maxLength={80}
192 autoComplete="off"
193 data-1p-ignore
194 className="min-w-0 grow bg-transparent text-sm outline-none placeholder:text-faint"
195 />
196 </span>
197 {name && clean !== name && <span className="text-xs text-faint">It will be #{clean || "…"}</span>}
198 </label>
199 <label className="grid gap-1.5">
200 <span className="text-sm font-medium text-muted">
201 Topic <span className="font-normal text-faint">(optional)</span>
202 </span>
203 <input
204 value={topic}
205 onChange={(event) => setTopic(event.target.value)}
206 placeholder="What this channel is for"
207 maxLength={250}
208 autoComplete="off"
209 data-1p-ignore
210 className="h-10 rounded-md border border-line bg-bg px-3 text-sm outline-none placeholder:text-faint focus:border-accent-dim"
211 />
212 </label>
213 <label className="flex items-start justify-between gap-4 rounded-lg border border-line bg-bg/60 p-3">
214 <span>
Chat controls, public profiles, shadcn selects, and no Docs tab in a project215 <span className="flex items-center gap-1.5 text-sm font-medium">
216 {isPrivate ? <Lock size={13} className="text-muted" /> : <Hash size={13} className="text-muted" />}
217 {isPrivate ? "Private" : "Public"}
218 </span>
219 <span className="mt-0.5 block text-xs text-muted">
220 {!may.public
221 ? "Only workspace owners can create public channels here, so this one is private: only people and agents you invite can find and read it."
222 : !may.private
223 ? "Only workspace owners can create private channels here, so this one is public: anyone in the workspace can find, read and join it."
224 : isPrivate
225 ? "Only people and agents you invite can find and read it."
226 : "Anyone in the workspace can find, read and join it. Turn on to keep it to the people and agents you invite."}
227 </span>
Chat and workspace agents: channels, DMs and named agents you talk to228 </span>
Chat controls, public profiles, shadcn selects, and no Docs tab in a project229 <Switch checked={isPrivate} onCheckedChange={setSecret} disabled={!may.public || !may.private} aria-label="Private" />
Chat and workspace agents: channels, DMs and named agents you talk to230 </label>
231 {error && <p className="text-sm text-danger">{error}</p>}
232 <DialogFooter>
233 <button type="button" onClick={() => setOpen(false)} className={QUIET_BUTTON}>
234 Cancel
235 </button>
236 <button
237 type="submit"
238 disabled={busy || !clean}
239 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"
240 >
241 {busy ? "Creating…" : "Create channel"}
242 </button>
243 </DialogFooter>
244 </form>
245 </DialogContent>
246 </Dialog>
247 </>
248 );
249}
250
251/** Someone a new message can go to. */
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar252type Pickable = { key: string; kind: "user" | "agent"; name: string; handle: string; display: string; avatar: string | null; role?: string | null };
Chat and workspace agents: channels, DMs and named agents you talk to253
254/**
255 * A new direct message: choose people, agents or both, then open the
256 * conversation between you (the same one each time for the same people).
257 */
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)258export 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 to259 const [open, setOpen] = useState(false);
260 return (
261 <>
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)262 {variant === "fab" ? (
263 // A phone's list: the round button above the tab bar.
264 <button
265 type="button"
266 aria-label="New message"
267 onClick={() => setOpen(true)}
268 className="flex size-14 items-center justify-center rounded-2xl bg-accent text-bg shadow-lg shadow-black/50 transition-transform active:scale-95"
269 >
270 <SquarePen size={22} />
271 </button>
272 ) : variant === "icon" ? (
Chat and workspace agents: channels, DMs and named agents you talk to273 <Hint label="New message">
274 <button type="button" aria-label="New message" onClick={() => setOpen(true)} className={ICON_BUTTON.replace("size-7", "size-8")}>
275 <SquarePen size={15} />
276 </button>
277 </Hint>
278 ) : (
279 <button type="button" onClick={() => setOpen(true)} className={QUIET_BUTTON}>
280 <SquarePen size={15} />
281 {children ?? "New message"}
282 </button>
283 )}
284 <Dialog open={open} onOpenChange={setOpen}>
Chat on a phone: the list and its new-message button follow the real top and tab bar heights; the new-message sheet keeps clear of the home indicator without extra padding285 <DialogContent className="max-w-md gap-0 p-0 max-sm:pb-[env(safe-area-inset-bottom)]" showClose={false}>
Chat and workspace agents: channels, DMs and named agents you talk to286 {open && <NewMessage slug={slug} onDone={() => setOpen(false)} />}
287 </DialogContent>
288 </Dialog>
289 </>
290 );
291}
292
293function NewMessage({ slug, onDone }: { slug: string; onDone: () => void }) {
294 const data = useChatData();
295 const [query, setQuery] = useState("");
296 const [chosen, setChosen] = useState<Pickable[]>([]);
297 const [active, setActive] = useState(0);
298 const [error, setError] = useState<string | null>(null);
299 const [busy, setBusy] = useState(false);
300 const input = useRef<HTMLInputElement>(null);
301 const send = useChatSend(slug);
302 const navigate = useNavigate();
303 const everyone = useMemo<Pickable[]>(() => {
304 const me = data?.me.username.toLowerCase();
305 return [
306 ...(data?.agents ?? []).map((agent) => ({
307 key: `agent:${agent.id}`,
308 kind: "agent" as const,
309 name: agent.handle,
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar310 handle: agent.handle,
Chat and workspace agents: channels, DMs and named agents you talk to311 display: agent.display_name,
312 avatar: agent.avatar,
313 role: agent.role,
314 })),
315 ...(data?.people ?? [])
316 .filter((person) => person.name.toLowerCase() !== me)
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar317 .map((person) => ({ key: `user:${person.name}`, kind: "user" as const, name: person.name, handle: shownHandle(person), display: shownName(person), avatar: person.avatar })),
Chat and workspace agents: channels, DMs and named agents you talk to318 ];
319 }, [data]);
320 const q = query.trim().toLowerCase().replace(/^@/, "");
321 const picked = new Set(chosen.map((p) => p.key));
322 const options = everyone.filter(
323 (p) => !picked.has(p.key) && (!q || p.name.toLowerCase().includes(q) || p.display.toLowerCase().includes(q)),
324 );
325 const take = (person: Pickable) => {
326 setChosen((now) => [...now, person]);
327 setQuery("");
328 setActive(0);
329 input.current?.focus();
330 };
331 const go = async () => {
332 if (chosen.length === 0) return;
333 setBusy(true);
334 setError(null);
335 const opened = await send<{ to: string }>({ intent: "dm", members: chosen.map((p) => p.key) });
336 setBusy(false);
337 if (!opened.ok) return setError(opened.error.message);
338 onDone();
339 navigate(opened.value.to);
340 };
341 return (
342 <div className="flex max-h-[min(36rem,calc(100dvh-2rem))] flex-col">
343 <div className="border-b border-line px-5 pt-5 pb-4">
344 <DialogTitle>New message</DialogTitle>
345 <DialogDescription className="mt-1">To one person, a few, or an agent. Agents answer in the conversation.</DialogDescription>
346 <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">
347 <span className="pl-1 text-sm text-faint">To:</span>
348 {chosen.map((person) => (
349 <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)350 {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 to351 {person.display}
352 <button
353 type="button"
354 aria-label={`Remove ${person.display}`}
355 onClick={() => setChosen((now) => now.filter((p) => p.key !== person.key))}
356 className="rounded p-0.5 text-faint hover:bg-line hover:text-fg"
357 >
358 <X size={12} />
359 </button>
360 </span>
361 ))}
362 <input
363 ref={input}
364 autoFocus
365 value={query}
366 onChange={(event) => {
367 setQuery(event.target.value);
368 setActive(0);
369 }}
370 onKeyDown={(event) => {
371 if (event.key === "ArrowDown" || event.key === "ArrowUp") {
372 event.preventDefault();
373 const count = Math.max(1, options.length);
374 setActive((index) => (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count);
375 } else if (event.key === "Enter") {
376 event.preventDefault();
377 const person = options[active];
378 if (person && q) take(person);
379 else void go();
380 } else if (event.key === "Backspace" && !query && chosen.length) {
381 setChosen((now) => now.slice(0, -1));
382 }
383 }}
384 placeholder={chosen.length ? "" : "Name or @handle"}
385 aria-label="Who to message"
386 autoComplete="off"
387 data-1p-ignore
388 className="h-7 min-w-24 grow bg-transparent px-1 text-sm outline-none placeholder:text-faint"
389 />
390 </div>
391 </div>
392 <ul role="listbox" aria-label="People and agents" className="min-h-0 grow overflow-y-auto p-1.5">
393 {options.length === 0 && (
394 <li className="flex items-center gap-2 px-3 py-6 text-sm text-muted">
395 <Search size={14} />
396 {everyone.length === 0 ? "No one else is in this workspace yet." : "No one matches."}
397 </li>
398 )}
399 {options.map((person, index) => (
400 <li
401 key={person.key}
402 role="option"
403 aria-selected={index === active}
404 onMouseEnter={() => setActive(index)}
405 onClick={() => take(person)}
406 className={`flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 ${index === active ? "bg-raised" : ""}`}
407 >
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)408 {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 to409 <span className="min-w-0 grow">
410 <span className="flex items-center gap-1.5 text-sm font-medium">
411 <span className="truncate">{person.display}</span>
412 {person.kind === "agent" && <AgentPill />}
413 </span>
414 <span className="block truncate text-xs text-faint">
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar415 @{person.handle}
Chat and workspace agents: channels, DMs and named agents you talk to416 {person.role ? ` · ${person.role}` : ""}
417 </span>
418 </span>
419 {index === active && <Check size={14} className="text-faint" />}
420 </li>
421 ))}
422 </ul>
423 <div className="flex items-center justify-between gap-3 border-t border-line px-5 py-3">
424 <span className="min-w-0 truncate text-sm text-danger">{error}</span>
425 <div className="flex shrink-0 gap-2">
426 <button type="button" onClick={onDone} className={QUIET_BUTTON}>
427 Cancel
428 </button>
429 <button
430 type="button"
431 disabled={busy || chosen.length === 0}
432 onClick={() => void go()}
433 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"
434 >
435 {busy ? "Opening…" : "Open conversation"}
436 </button>
437 </div>
438 </div>
439 </div>
440 );
441}

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