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