Skip to content
385 linesCodeBlameRaw
1import { ArrowUp, CircleAlert, RotateCcw, Settings2, Square } from "lucide-react";
2import { type FormEvent, type KeyboardEvent, useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
3import { Link, useLocation } from "react-router";
4
5import { ASK_MAX_MESSAGE_CHARS, type AskEvent, type AskSetup } from "@g1t/contracts";
6
7import { EMPTY_ASK, type Turn, askPage, askReducer, cutOff, history, readLines } from "../lib/ask";
8import { MessageText, type TextContext } from "./chat/text";
9import { MarkGlyph } from "./logo";
10import { Alert, AlertDescription, AlertTitle } from "./ui/alert";
11import { Badge } from "./ui/badge";
12import { Button } from "./ui/button";
13import { Hint } from "./ui/hint";
14import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet";
15import { Skeleton } from "./ui/skeleton";
16
17/**
18 * Ask g1t: a conversation with g1t about your work, from the top bar. The
19 * answers come from the AI provider and model an owner chose for the
20 * workspace (Integrations → Your AI providers), on the workspace's own key:
21 * g1t hosts no model for it. Each question goes to `-/ask`
22 * (routes/workspace/ask.ts) with the page you are on, and the answer
23 * streams back.
24 *
25 * The conversation lives in this browser tab (session storage), so it
26 * survives moving between pages and reloading, and goes with the tab.
27 * Keeping conversations on the server comes later.
28 */
29
30type Setup = AskSetup & { owner: boolean };
31type SetupAnswer = { ok: boolean; value?: AskSetup; error?: { message: string }; owner: boolean };
32
33const storageKey = (slug: string) => `g1t:ask:${slug}`;
34
35function load(slug: string) {
36 try {
37 const kept = sessionStorage.getItem(storageKey(slug));
38 return kept ? (JSON.parse(kept) as unknown) : null;
39 } catch {
40 return null;
41 }
42}
43
44function save(slug: string, value: unknown) {
45 try {
46 sessionStorage.setItem(storageKey(slug), JSON.stringify(value));
47 } catch {
48 // Storage full or blocked: the conversation stays for this page.
49 }
50}
51
52/**
53 * The conversation and what it can do. It belongs to the top bar's button,
54 * not the panel, so closing the panel keeps an answer coming.
55 */
56function useAskConversation(slug: string) {
57 const { pathname } = useLocation();
58 const [state, dispatch] = useReducer(askReducer, EMPTY_ASK);
59 const [draft, setDraft] = useState("");
60 const controller = useRef<AbortController | null>(null);
61 const restored = useRef(false);
62
63 // The conversation kept in this tab, once; then kept as it changes.
64 useEffect(() => {
65 if (!restored.current) {
66 restored.current = true;
67 const kept = load(slug);
68 if (kept) dispatch({ type: "restore", state: kept as never });
69 return;
70 }
71 save(slug, state);
72 }, [slug, state]);
73
74 // Leaving the workspace stops an answer still coming.
75 useEffect(() => () => controller.current?.abort(), []);
76
77 const ask = useCallback(
78 async (question: string) => {
79 const text = question.trim();
80 if (!text || state.busy) return;
81 const messages = history(state.turns, text);
82 const id = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
83 dispatch({ type: "ask", question: text, id });
84 setDraft("");
85 const abort = new AbortController();
86 controller.current = abort;
87 let ended = false;
88 const take = (events: AskEvent[]) => {
89 for (const event of events) {
90 dispatch({ type: "event", event });
91 if (event.type === "done" || event.type === "error") ended = true;
92 }
93 };
94 try {
95 const response = await fetch(`/${slug}/-/ask`, {
96 method: "POST",
97 headers: { "content-type": "application/json", accept: "application/x-ndjson" },
98 body: JSON.stringify({ messages, page: askPage(pathname) }),
99 signal: abort.signal,
100 });
101 if (!response.body) throw new Error("no body");
102 const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
103 let rest = "";
104 for (;;) {
105 const { value, done } = await reader.read();
106 if (done) break;
107 const read = readLines(rest, value);
108 rest = read.rest;
109 take(read.events);
110 }
111 take(readLines(rest, "\n").events);
112 if (!ended) dispatch({ type: "failed", message: "The answer broke off. Ask again." });
113 } catch {
114 if (abort.signal.aborted) dispatch({ type: "stop" });
115 else dispatch({ type: "failed", message: "Ask could not be reached. Check your connection and ask again." });
116 } finally {
117 if (controller.current === abort) controller.current = null;
118 }
119 },
120 [pathname, slug, state.busy, state.turns],
121 );
122
123 const stop = useCallback(() => {
124 controller.current?.abort();
125 dispatch({ type: "stop" });
126 }, []);
127
128 const fresh = useCallback(() => {
129 controller.current?.abort();
130 dispatch({ type: "reset" });
131 setDraft("");
132 }, []);
133
134 return { state, draft, setDraft, ask, stop, fresh };
135}
136
137type Conversation = ReturnType<typeof useAskConversation>;
138
139/** The top bar's Ask: the lavender pixel 1 beside its words. Opens the conversation beside the page. */
140export function AskButton({ slug }: { slug: string }) {
141 const [open, setOpen] = useState(false);
142 const conversation = useAskConversation(slug);
143 return (
144 <Sheet open={open} onOpenChange={setOpen}>
145 <Hint label="Ask g1t about your work">
146 <SheetTrigger asChild>
147 <Button variant="soft" size="bar" aria-label="Ask g1t" className="gap-2 max-md:ring-0 lg:px-3">
148 <MarkGlyph className="h-3.5" />
149 <span className="max-lg:hidden">Ask</span>
150 </Button>
151 </SheetTrigger>
152 </Hint>
153 <SheetContent side="right" className="sm:max-w-lg">
154 <AskPanel slug={slug} open={open} conversation={conversation} />
155 </SheetContent>
156 </Sheet>
157 );
158}
159
160function AskPanel({ slug, open, conversation }: { slug: string; open: boolean; conversation: Conversation }) {
161 const { pathname } = useLocation();
162 const { state, draft, setDraft, ask, stop } = conversation;
163 const [setup, setSetup] = useState<Setup | null>(null);
164 const [setupError, setSetupError] = useState<string | null>(null);
165 const end = useRef<HTMLDivElement>(null);
166 const input = useRef<HTMLTextAreaElement>(null);
167
168 // What Ask answers with, each time it opens: an owner may have changed it.
169 useEffect(() => {
170 if (!open) return;
171 let live = true;
172 fetch(`/${slug}/-/ask`, { headers: { accept: "application/json" } })
173 .then((response) => (response.ok ? (response.json() as Promise<SetupAnswer>) : Promise.reject(new Error(String(response.status)))))
174 .then((answer) => {
175 if (!live) return;
176 if (answer.ok && answer.value) {
177 setSetup({ ...answer.value, owner: answer.owner });
178 setSetupError(null);
179 } else setSetupError(answer.error?.message ?? "Ask could not be loaded.");
180 })
181 .catch(() => live && setSetupError("Ask could not be loaded. Try again in a moment."));
182 return () => {
183 live = false;
184 };
185 }, [open, slug]);
186
187 useEffect(() => {
188 if (open) requestAnimationFrame(() => input.current?.focus());
189 }, [open]);
190
191 // Keep the newest words in view as they come.
192 const last = state.turns.at(-1);
193 useEffect(() => {
194 end.current?.scrollIntoView({ block: "end" });
195 }, [state.turns.length, last?.content.length]);
196
197 const fresh = () => {
198 conversation.fresh();
199 input.current?.focus();
200 };
201
202 const submit = (event: FormEvent) => {
203 event.preventDefault();
204 void ask(draft);
205 };
206
207 const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
208 if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
209 event.preventDefault();
210 void ask(draft);
211 }
212 };
213
214 const ready = setup != null && setup.problem == null;
215 const context: TextContext = useMemo(() => ({ slug, me: null, channels: new Set(), project: null }), [slug]);
216 const where = askPage(pathname);
217
218 return (
219 <>
220 <SheetHeader className="gap-1">
221 <SheetTitle className="flex items-center gap-2">
222 <MarkGlyph className="h-3.5" />
223 Ask g1t
224 </SheetTitle>
225 <SheetDescription className="sr-only">A conversation with g1t about your work in {slug}.</SheetDescription>
226 <div className="flex min-h-6 flex-wrap items-center gap-2 text-xs text-muted">
227 {setup == null && !setupError ? (
228 <Skeleton className="h-4 w-40" />
229 ) : ready ? (
230 <span className="truncate">
231 Answers with {setup.connection_name} · <span className="font-mono">{setup.model}</span>
232 </span>
233 ) : (
234 <span>Not set up yet</span>
235 )}
236 <span className="ml-auto flex items-center gap-1">
237 {setup?.owner && (
238 <Hint label="Choose the provider and model">
239 <Button asChild variant="ghost" size="icon-xs" aria-label="Choose the provider and model">
240 <Link to={`/${slug}/-/integrations/models#ask`}>
241 <Settings2 size={14} />
242 </Link>
243 </Button>
244 </Hint>
245 )}
246 <Button variant="ghost" size="xs" onClick={fresh} disabled={state.turns.length === 0}>
247 <RotateCcw size={13} />
248 New conversation
249 </Button>
250 </span>
251 </div>
252 </SheetHeader>
253
254 <div className="min-h-0 grow overflow-y-auto px-5 py-4" aria-live="polite" aria-busy={state.busy}>
255 {setupError && (
256 <Alert tone="danger">
257 <AlertDescription className="text-danger">{setupError}</AlertDescription>
258 </Alert>
259 )}
260 {setup && setup.problem && <NotSetUp slug={slug} setup={setup} />}
261 {state.turns.length === 0 && ready && <Welcome where={where.repo ? `${where.repo.namespace}/${where.repo.name}` : null} />}
262 <ol className="space-y-5">
263 {state.turns.map((turn) => (
264 <li key={turn.id}>{turn.role === "user" ? <Question turn={turn} /> : <Answer turn={turn} context={context} />}</li>
265 ))}
266 </ol>
267 <div ref={end} />
268 </div>
269
270 <form onSubmit={submit} className="border-t border-line px-4 py-3">
271 <div className="rounded-lg border border-line bg-bg focus-within:border-accent-dim focus-within:ring-2 focus-within:ring-accent/25">
272 <label htmlFor="ask-g1t" className="sr-only">
273 Ask g1t
274 </label>
275 <textarea
276 id="ask-g1t"
277 ref={input}
278 value={draft}
279 onChange={(event) => setDraft(event.target.value)}
280 onKeyDown={onKeyDown}
281 rows={2}
282 maxLength={ASK_MAX_MESSAGE_CHARS}
283 disabled={!ready}
284 placeholder={ready ? "Ask about this page, a repository, or your work" : "Ask is not set up yet"}
285 className="block max-h-48 min-h-14 w-full resize-none bg-transparent px-3 pt-2.5 pb-1 text-sm text-fg placeholder:text-faint focus:outline-none disabled:cursor-not-allowed"
286 />
287 <div className="flex items-center gap-2 px-2 pb-2">
288 <span className="truncate pl-1 text-xs text-faint">
289 {where.issue ? `On issue #${where.issue}` : where.pull ? `On pull request #${where.pull}` : where.repo ? `In ${where.repo.name}` : null}
290 </span>
291 <span className="ml-auto" />
292 {state.busy ? (
293 <Hint label="Stop the answer">
294 <Button type="button" variant="outline" size="icon-sm" onClick={stop} aria-label="Stop">
295 <Square size={12} className="fill-current" />
296 </Button>
297 </Hint>
298 ) : (
299 <Hint label="Send (Enter)">
300 <Button type="submit" variant="accent" size="icon-sm" disabled={!ready || !draft.trim()} aria-label="Send">
301 <ArrowUp size={15} />
302 </Button>
303 </Hint>
304 )}
305 </div>
306 </div>
307 <p className="mt-2 px-1 text-[0.6875rem] text-faint">
308 Answers come from your workspace's own AI provider and can be wrong. Kept in this tab only.
309 </p>
310 </form>
311 </>
312 );
313}
314
315function NotSetUp({ slug, setup }: { slug: string; setup: Setup }) {
316 return (
317 <Alert tone="info" className="mb-4">
318 <AlertTitle>Ask needs one of your workspace's AI providers</AlertTitle>
319 <AlertDescription>
320 <p>{setup.problem}</p>
321 <p className="mt-2">
322 Ask answers with an AI provider your workspace connects, on its own key: g1t hosts no model for it.{" "}
323 {setup.owner ? (
324 <Link to={`/${slug}/-/integrations/models#ask`} className="text-accent hover:underline">
325 Connect a provider and choose it for Ask
326 </Link>
327 ) : (
328 <>Ask an owner of {slug} to connect one and choose it for Ask.</>
329 )}
330 </p>
331 </AlertDescription>
332 </Alert>
333 );
334}
335
336function Welcome({ where }: { where: string | null }) {
337 return (
338 <div className="mb-4 rounded-lg border border-dashed border-line px-4 py-5 text-sm text-muted">
339 <p className="font-medium text-fg">Ask about your work</p>
340 <p className="mt-1">
341 g1t tells the model which page you are on{where ? `, here ${where}` : ""}, and the issue or pull request you are looking at, so you can ask
342 about it directly.
343 </p>
344 </div>
345 );
346}
347
348function Question({ turn }: { turn: Turn }) {
349 return (
350 <div className="ml-auto w-fit max-w-[85%] rounded-lg bg-raised px-3 py-2 text-[0.9375rem] leading-[1.55] wrap-break-word whitespace-pre-wrap text-fg">
351 {turn.content}
352 </div>
353 );
354}
355
356function Answer({ turn, context }: { turn: Turn; context: TextContext }) {
357 const waiting = turn.status === "streaming" && !turn.content;
358 return (
359 <div className="space-y-2">
360 {waiting ? (
361 <div className="flex items-center gap-2 text-sm text-muted" role="status">
362 <MarkGlyph className="h-3 animate-pulse motion-reduce:animate-none" />
363 Thinking…
364 </div>
365 ) : (
366 turn.content && <MessageText body={turn.content} context={context} />
367 )}
368 {turn.status === "error" && (
369 <Alert tone="danger" className="flex items-start gap-2">
370 <CircleAlert size={14} className="mt-0.5 shrink-0" />
371 <span>{turn.error}</span>
372 </Alert>
373 )}
374 <div className="flex flex-wrap items-center gap-1.5 text-xs text-faint">
375 {turn.provider && (
376 <Badge className="px-1.5 py-0 font-normal">
377 {turn.provider} · <span className="font-mono">{turn.model}</span>
378 </Badge>
379 )}
380 {turn.status === "stopped" && <span>Stopped</span>}
381 {turn.status === "done" && cutOff(turn) && <span>Cut off at the length limit</span>}
382 </div>
383 </div>
384 );
385}