Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
547 linesCodeBlameRaw
1import { ArrowUp, Ellipsis, MessageSquare, RotateCcw, Settings2, Square, Trash2 } from "lucide-react";
2import { type FormEvent, type KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
3import { Link, useLocation } from "react-router";
4
5import { type AskSetup, type ChatLiveEvent, type ChatMessage, type Result } from "@g1t/contracts";
6
7import { askPage, askWhere } from "../lib/ask";
8import { browserTimeZone } from "../lib/time-zone";
9import { anyAnswering, applyLive, fromG1t, isClearCommand, suggestions } from "../lib/g1t-chat";
10import { Conversation, ConversationContent, ConversationEmptyState, ConversationScrollButton } from "./ai-elements/conversation";
11import { Message, MessageContent } from "./ai-elements/message";
12import type { ResponseContext } from "./ai-elements/response";
13import { Suggestion, Suggestions } from "./ai-elements/suggestion";
14import { AskedFrom, G1tAnswer } from "./chat/answer";
15import { ClearG1tDialog } from "./chat/clear-g1t";
16import { useChatLive } from "./chat/live";
17import { MarkGlyph } from "./logo";
18import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
19import { Button } from "./ui/button";
20import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
21import { Hint } from "./ui/hint";
22import { Kbd } from "./ui/kbd";
23import { SheetClose, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "./ui/sheet";
24import { Skeleton } from "./ui/skeleton";
25import { Textarea } from "./ui/textarea";
26
27/**
28 * Ask g1t: a conversation with g1t about your work, from the top bar. It
29 * is your direct message with @g1t in Chat: the same messages, the same
30 * answers, whichever you write in. The answers come from the AI provider
31 * and model an owner chose for the workspace (Integrations → Your AI
32 * providers → Ask g1t), on the workspace's own key: g1t hosts no model.
33 *
34 * Each question carries the page you are on, which g1t is told. The chat
35 * service has g1t answer, and the answer streams into the direct message
36 * over its live socket, so it shows here and in Chat at once. The panel
37 * shows the current conversation, since the latest "New conversation";
38 * everything before it stays in Chat. Clear conversation (the menu, or
39 * `/clear`) deletes all of it, here and in Chat, for good.
40 */
41
42type Setup = AskSetup & { owner: boolean };
43type SetupAnswer = { ok: boolean; value?: AskSetup; error?: { message: string }; owner: boolean };
44type Shown = ChatMessage & { pending?: boolean; failed?: boolean };
45
46const channelKey = (slug: string) => `g1t:ask-dm:${slug}`;
47
48function rememberedChannel(slug: string): string | null {
49 try {
50 return sessionStorage.getItem(channelKey(slug));
51 } catch {
52 return null;
53 }
54}
55
56function rememberChannel(slug: string, id: string) {
57 try {
58 sessionStorage.setItem(channelKey(slug), id);
59 } catch {
60 // Not kept: it is asked for again next time.
61 }
62}
63
64async function chatApi<T>(slug: string, body: Record<string, unknown>): Promise<Result<T>> {
65 try {
66 const response = await fetch(`/${slug}/-/chat/api`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
67 return (await response.json()) as Result<T>;
68 } catch {
69 return { ok: false, error: { code: "conflict", message: "g1t could not be reached. Check your connection and try again." } };
70 }
71}
72
73/** Oldest first, the newest message from each id. */
74function byId(list: Shown[]): Shown[] {
75 return [...new Map(list.map((m) => [m.id, m])).values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
76}
77
78/**
79 * The viewer's direct message with @g1t, as the panel shows it. It belongs
80 * to the top bar's button, not the panel, so what was said stays while the
81 * panel is closed; the answer itself is kept by chat either way.
82 */
83function useG1tConversation(slug: string, open: boolean) {
84 const [channelId, setChannelId] = useState<string | null>(null);
85 const [messages, setMessages] = useState<Shown[]>([]);
86 const [loaded, setLoaded] = useState(false);
87 const [problem, setProblem] = useState<string | null>(null);
88 const opening = useRef<Promise<string | null> | null>(null);
89
90 const channel = useCallback(async (): Promise<string | null> => {
91 if (channelId) return channelId;
92 opening.current ??= (async () => {
93 const remembered = rememberedChannel(slug);
94 if (remembered) return remembered;
95 const opened = await chatApi<{ channel: { id: string } }>(slug, { intent: "g1t_dm" });
96 if (!opened.ok) {
97 setProblem(opened.error.message);
98 return null;
99 }
100 rememberChannel(slug, opened.value.channel.id);
101 return opened.value.channel.id;
102 })().finally(() => {
103 opening.current = null;
104 });
105 const id = await opening.current;
106 if (id) setChannelId(id);
107 return id;
108 }, [channelId, slug]);
109
110 const load = useCallback(async () => {
111 const id = await channel();
112 if (!id) return;
113 try {
114 const response = await fetch(`/${slug}/-/chat/api?channel=${encodeURIComponent(id)}&limit=60`, { headers: { accept: "application/json" } });
115 const page = (await response.json()) as Result<{ messages: ChatMessage[] }>;
116 if (page.ok) {
117 setMessages((now) => byId([...now.filter((m) => m.pending), ...page.value.messages.filter((m) => !m.deleted_at && !m.thread_root)]));
118 setProblem(null);
119 } else if (page.error.code === "not_found") {
120 // Kept from another session that no longer has it: open it again.
121 try {
122 sessionStorage.removeItem(channelKey(slug));
123 } catch {
124 // Nothing kept.
125 }
126 setChannelId(null);
127 } else setProblem(page.error.message);
128 } catch {
129 setProblem("g1t could not be reached. Check your connection and try again.");
130 }
131 setLoaded(true);
132 }, [channel, slug]);
133
134 useEffect(() => {
135 if (open) void load();
136 }, [open, load]);
137
138 const onEvent = useCallback((event: ChatLiveEvent) => {
139 if (!channelId) return;
140 setMessages((now) => applyLive(now, event, channelId) as Shown[]);
141 }, [channelId]);
142
143 // Read up to the newest message while the panel is open and in view, as
144 // the conversation in Chat does: it is the same direct message, so what
145 // was read here is not left unread there.
146 const lastRead = useRef<string | null>(null);
147 useEffect(() => {
148 if (!open || !channelId) return;
149 const markRead = () => {
150 if (document.visibilityState !== "visible") return;
151 const newest = [...messages].reverse().find((m) => !m.pending);
152 if (!newest || newest.id === lastRead.current) return;
153 lastRead.current = newest.id;
154 void chatApi(slug, { intent: "read", channel_id: channelId, id: newest.id });
155 window.dispatchEvent(new CustomEvent("g1t:chat-read", { detail: channelId }));
156 };
157 const timer = setTimeout(markRead, 400);
158 document.addEventListener("visibilitychange", markRead);
159 window.addEventListener("focus", markRead);
160 return () => {
161 clearTimeout(timer);
162 document.removeEventListener("visibilitychange", markRead);
163 window.removeEventListener("focus", markRead);
164 };
165 }, [open, channelId, messages, slug]);
166
167 const ask = useCallback(
168 async (text: string, pathname: string) => {
169 const body = text.trim();
170 if (!body) return;
171 const id = await channel();
172 if (!id) return;
173 const pendingId = `~pending-${Date.now().toString(36)}`;
174 const pending: Shown = {
175 id: pendingId,
176 channel_id: id,
177 author: { kind: "user", id: "", name: "", display_name: "", avatar: null, role: null },
178 kind: "text",
179 body,
180 card: null,
181 thread_root: null,
182 reply_count: 0,
183 last_reply_at: null,
184 created_at: new Date().toISOString(),
185 edited_at: null,
186 deleted_at: null,
187 pending: true,
188 };
189 setMessages((now) => [...now, pending]);
190 const saved = await chatApi<ChatMessage>(slug, { intent: "post", channel_id: id, body, page: { ...askPage(pathname), time_zone: browserTimeZone() } });
191 setMessages((now) => (saved.ok ? byId([...now.filter((m) => m.id !== pendingId), saved.value]) : now.map((m) => (m.id === pendingId ? { ...m, failed: true } : m))));
192 // The answer arrives over the socket; should it have opened late, ask once more.
193 if (saved.ok) setTimeout(() => void load(), 1_500);
194 },
195 [channel, slug, load],
196 );
197
198 const act = useCallback(
199 async (intent: "g1t_stop" | "g1t_reset" | "g1t_retry") => {
200 const id = await channel();
201 if (!id) return;
202 const done = await chatApi<ChatMessage | null>(slug, { intent, channel_id: id });
203 if (!done.ok) setProblem(done.error.message);
204 else if (intent === "g1t_reset" && done.value) setMessages((now) => byId([...now, done.value as ChatMessage]));
205 },
206 [channel, slug],
207 );
208
209 /** Deletes the whole conversation for good (the chat service's `g1t_clear`): null when done, else why not. */
210 const clear = useCallback(async (): Promise<string | null> => {
211 const id = await channel();
212 if (!id) return "g1t could not be reached. Check your connection and try again.";
213 const done = await chatApi<{ cleared: number }>(slug, { intent: "g1t_clear", channel_id: id });
214 if (!done.ok) return done.error.message;
215 setMessages([]);
216 setProblem(null);
217 return null;
218 }, [channel, slug]);
219
220 return { channelId, messages, loaded, problem, ask, act, clear, load, onEvent };
221}
222
223type G1tConversation = ReturnType<typeof useG1tConversation>;
224
225/**
226 * The Ask panel's sheet (components/ask-button.tsx loads it the first time
227 * the top bar's Ask opens, so pages don't carry it until then): the
228 * conversation, kept while the panel is closed.
229 */
230export function AskSheet({ slug, open }: { slug: string; open: boolean }) {
231 const conversation = useG1tConversation(slug, open);
232 const busy = anyAnswering(conversation.messages);
233 return (
234 <SheetContent
235 side="right"
236 className="sm:max-w-xl"
237 // Esc stops an answer still coming first; pressed again, it closes the panel.
238 onEscapeKeyDown={(event) => {
239 if (!busy) return;
240 event.preventDefault();
241 void conversation.act("g1t_stop");
242 }}
243 >
244 <AskPanel slug={slug} open={open} conversation={conversation} />
245 </SheetContent>
246 );
247}
248
249/** The live socket of the direct message, while the panel is open. */
250function LiveSocket({ slug, channelId, onEvent, onReconnect }: { slug: string; channelId: string; onEvent: (event: ChatLiveEvent) => void; onReconnect: () => void }) {
251 useChatLive(slug, channelId, onEvent, onReconnect);
252 return null;
253}
254
255function AskPanel({ slug, open, conversation }: { slug: string; open: boolean; conversation: G1tConversation }) {
256 const { pathname } = useLocation();
257 const { channelId, messages, loaded, problem, ask, act, clear, load, onEvent } = conversation;
258 const [clearing, setClearing] = useState(false);
259 const [setup, setSetup] = useState<Setup | null>(null);
260 const [setupError, setSetupError] = useState<string | null>(null);
261 const [draft, setDraft] = useState("");
262 const input = useRef<HTMLTextAreaElement>(null);
263
264 // What Ask answers with, each time it opens: an owner may have changed it.
265 useEffect(() => {
266 if (!open) return;
267 let live = true;
268 fetch(`/${slug}/-/ask`, { headers: { accept: "application/json" } })
269 .then((response) => (response.ok ? (response.json() as Promise<SetupAnswer>) : Promise.reject(new Error(String(response.status)))))
270 .then((answer) => {
271 if (!live) return;
272 if (answer.ok && answer.value) {
273 setSetup({ ...answer.value, owner: answer.owner });
274 setSetupError(null);
275 } else setSetupError(answer.error?.message ?? "Ask could not be loaded.");
276 })
277 .catch(() => live && setSetupError("Ask could not be loaded. Try again in a moment."));
278 return () => {
279 live = false;
280 };
281 }, [open, slug]);
282
283 useEffect(() => {
284 if (open) requestAnimationFrame(() => input.current?.focus());
285 }, [open]);
286
287 // ⌘K or Ctrl+K, while the panel is open, goes to its box rather than the command palette.
288 useEffect(() => {
289 if (!open) return;
290 const onKey = (event: globalThis.KeyboardEvent) => {
291 if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
292 event.preventDefault();
293 event.stopImmediatePropagation();
294 input.current?.focus();
295 }
296 };
297 window.addEventListener("keydown", onKey, { capture: true });
298 return () => window.removeEventListener("keydown", onKey, { capture: true });
299 }, [open]);
300
301 // The current conversation: what came after the latest "New conversation".
302 const shown = useMemo(() => {
303 let start = 0;
304 messages.forEach((m, index) => {
305 if (m.reset && fromG1t(m)) start = index + 1;
306 });
307 return messages.slice(start);
308 }, [messages]);
309 const earlier = shown.length < messages.length;
310 const busy = anyAnswering(messages);
311 const ready = setup != null && setup.problem == null;
312 const where = askPage(pathname);
313 const lastAnswer = [...shown].reverse().find((m) => !m.pending);
314 const context = useMemo<ResponseContext>(() => ({ slug, repo: where.repo ?? null }), [slug, where.repo?.namespace, where.repo?.name]);
315
316 const send = (text: string) => {
317 // `/clear` asks to clear the conversation; it is never sent to g1t.
318 if (isClearCommand(text)) {
319 setDraft("");
320 setClearing(true);
321 return;
322 }
323 if (!ready || busy || !text.trim()) return;
324 setDraft("");
325 void ask(text, pathname);
326 };
327 const submit = (event: FormEvent) => {
328 event.preventDefault();
329 send(draft);
330 };
331 const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
332 if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
333 event.preventDefault();
334 send(draft);
335 }
336 };
337 const fresh = () => {
338 void act("g1t_reset");
339 input.current?.focus();
340 };
341
342 return (
343 <>
344 {open && channelId && <LiveSocket slug={slug} channelId={channelId} onEvent={onEvent} onReconnect={() => void load()} />}
345 <SheetHeader className="gap-1 border-b border-line pb-3">
346 <SheetTitle className="flex items-center gap-2">
347 <MarkGlyph className="h-3.5" />
348 Ask g1t
349 </SheetTitle>
350 <SheetDescription className="sr-only">A conversation with g1t about your work in {slug}. It is your direct message with g1t in Chat.</SheetDescription>
351 <div className="flex min-h-7 flex-wrap items-center gap-2 text-xs text-muted">
352 {setup == null && !setupError ? (
353 <Skeleton className="h-4 w-40" />
354 ) : ready ? (
355 <span className="min-w-0 truncate">
356 Answers with {setup.connection_name} · <span className="font-mono">{setup.model}</span>
357 </span>
358 ) : (
359 <span>Not set up yet</span>
360 )}
361 <span className="ml-auto flex items-center gap-0.5">
362 {setup?.owner && (
363 <Hint label="Choose the provider and model">
364 <Button asChild variant="ghost" size="icon-xs" aria-label="Choose the provider and model">
365 <Link to={`/${slug}/-/integrations/models#ask`}>
366 <Settings2 size={14} />
367 </Link>
368 </Button>
369 </Hint>
370 )}
371 {channelId && (
372 <Hint label="Open this conversation in Chat">
373 <Button asChild variant="ghost" size="icon-xs" aria-label="Open in Chat">
374 <Link to={`/${slug}/-/chat/dm/${channelId}`}>
375 <MessageSquare size={14} />
376 </Link>
377 </Button>
378 </Hint>
379 )}
380 <Hint label="New conversation: g1t reads only what comes next">
381 <Button variant="ghost" size="xs" onClick={fresh} disabled={shown.length === 0}>
382 <RotateCcw size={13} />
383 New conversation
384 </Button>
385 </Hint>
386 <DropdownMenu>
387 <Hint label="More">
388 <DropdownMenuTrigger asChild>
389 <Button variant="ghost" size="icon-xs" aria-label="More">
390 <Ellipsis size={14} />
391 </Button>
392 </DropdownMenuTrigger>
393 </Hint>
394 <DropdownMenuContent align="end">
395 <DropdownMenuItem variant="destructive" disabled={messages.length === 0} onSelect={() => setClearing(true)}>
396 <Trash2 />
397 Clear conversation…
398 </DropdownMenuItem>
399 </DropdownMenuContent>
400 </DropdownMenu>
401 </span>
402 </div>
403 </SheetHeader>
404
405 <Conversation aria-busy={busy}>
406 <ConversationContent>
407 {setupError && (
408 <Alert tone="danger">
409 <AlertDescription className="text-danger">{setupError}</AlertDescription>
410 </Alert>
411 )}
412 {problem && (
413 <Alert tone="danger">
414 <AlertDescription className="text-danger">{problem}</AlertDescription>
415 </Alert>
416 )}
417 {setup && setup.problem && <NotSetUp slug={slug} setup={setup} />}
418 {earlier && channelId && (
419 <p className="text-center text-xs text-faint">
420 Earlier conversations are in{" "}
421 <Link to={`/${slug}/-/chat/dm/${channelId}`} className="text-accent hover:underline">
422 your direct message with g1t
423 </Link>
424 .
425 </p>
426 )}
427 {!loaded && open ? (
428 <div className="space-y-3" aria-busy="true">
429 <Skeleton className="ml-auto h-9 w-2/3 rounded-2xl" />
430 <Skeleton className="h-4 w-11/12" />
431 <Skeleton className="h-4 w-3/4" />
432 </div>
433 ) : shown.length === 0 && ready ? (
434 <ConversationEmptyState className="pt-16">
435 <MarkGlyph className="h-7" />
436 <div className="space-y-1">
437 <h3 className="text-base font-semibold text-fg">Ask about your work</h3>
438 <p className="max-w-sm text-sm text-muted">
439 {where.repo
440 ? `g1t is told you're in ${where.repo.namespace}/${where.repo.name}${where.issue ? `, on issue #${where.issue}` : where.pull ? `, on pull request #${where.pull}` : ""}.`
441 : "g1t is told which page you're on, and reads the issue or pull request in front of you."}
442 </p>
443 </div>
444 <Suggestions className="mt-3 max-w-md justify-center">
445 {suggestions(where).map((text) => (
446 <Suggestion key={text} suggestion={text} onPick={send} />
447 ))}
448 </Suggestions>
449 </ConversationEmptyState>
450 ) : (
451 shown.map((m) =>
452 fromG1t(m) ? (
453 m.reset ? null : (
454 <Message key={m.id} from="assistant">
455 <G1tAnswer message={m} context={context} onRetry={!busy && lastAnswer?.id === m.id ? () => void act("g1t_retry") : undefined} />
456 </Message>
457 )
458 ) : (
459 <Message key={m.id} from="user">
460 <MessageContent className={m.pending && !m.failed ? "opacity-70" : undefined}>{m.body}</MessageContent>
461 <AskedFrom page={m.page} />
462 {m.failed && <span className="text-xs text-danger">Not sent. Ask again.</span>}
463 </Message>
464 ),
465 )
466 )}
467 </ConversationContent>
468 <ConversationScrollButton />
469 </Conversation>
470
471 <form onSubmit={submit} className="border-t border-line px-4 pt-3 pb-3">
472 <div className="rounded-xl border border-line bg-bg transition-shadow focus-within:border-accent-dim focus-within:ring-2 focus-within:ring-accent/25">
473 <label htmlFor="ask-g1t" className="sr-only">
474 Ask g1t
475 </label>
476 <Textarea
477 id="ask-g1t"
478 ref={input}
479 value={draft}
480 onChange={(event) => setDraft(event.target.value)}
481 onKeyDown={onKeyDown}
482 rows={2}
483 maxLength={16_000}
484 disabled={!ready}
485 placeholder={ready ? "Ask about this page, a repository, or your work" : "Ask is not set up yet"}
486 className="max-h-48 min-h-14 resize-none border-0 bg-transparent px-3 pt-2.5 pb-1 shadow-none focus-visible:ring-0"
487 />
488 <div className="flex items-center gap-2 px-2 pb-2">
489 <span className="truncate pl-1 text-xs text-faint">{askWhere(where)}</span>
490 <span className="ml-auto" />
491 {busy ? (
492 <Hint label="Stop the answer (Esc)">
493 <Button type="button" variant="outline" size="icon-sm" onClick={() => void act("g1t_stop")} aria-label="Stop">
494 <Square size={12} className="fill-current" />
495 </Button>
496 </Hint>
497 ) : (
498 <Hint label="Send (Enter)">
499 <Button type="submit" variant="accent" size="icon-sm" disabled={!ready || !draft.trim()} aria-label="Send">
500 <ArrowUp size={15} />
501 </Button>
502 </Hint>
503 )}
504 </div>
505 </div>
506 <p className="mt-2 px-1 text-[0.6875rem] leading-relaxed text-faint">
507 Answers come from your workspace&apos;s own AI provider and can be wrong. Kept in your direct message with g1t until you clear it.{" "}
508 <span className="whitespace-nowrap max-sm:hidden">
509 <Kbd>Shift</Kbd> <Kbd>Enter</Kbd> for a new line.
510 </span>
511 </p>
512 </form>
513 <ClearG1tDialog
514 open={clearing}
515 onOpenChange={(next) => {
516 setClearing(next);
517 if (!next) requestAnimationFrame(() => input.current?.focus());
518 }}
519 onClear={clear}
520 />
521 </>
522 );
523}
524
525function NotSetUp({ slug, setup }: { slug: string; setup: Setup }) {
526 return (
527 <Alert tone="info">
528 <AlertTitle>Ask needs one of your workspace&apos;s AI providers</AlertTitle>
529 <AlertDescription>
530 <p>{setup.problem}</p>
531 <p className="mt-2">
532 Ask answers with an AI provider your workspace connects, on its own key: g1t hosts no model for it.{" "}
533 {setup.owner ? (
534 // Following it closes the panel, so the page it opens is not behind it.
535 <SheetClose asChild>
536 <Link to={`/${slug}/-/integrations/models#ask`} className="text-accent hover:underline">
537 Connect a provider and choose it for Ask
538 </Link>
539 </SheetClose>
540 ) : (
541 <>Ask an owner of {slug} to connect one and choose it for Ask.</>
542 )}
543 </p>
544 </AlertDescription>
545 </Alert>
546 );
547}