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