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.

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