| 1 | import { ArrowUp, CircleAlert, RotateCcw, Settings2, Square } from "lucide-react"; |
| 2 | import { type FormEvent, type KeyboardEvent, useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; |
| 3 | import { Link, useLocation } from "react-router"; |
| 4 | |
| 5 | import { ASK_MAX_MESSAGE_CHARS, type AskEvent, type AskSetup } from "@g1t/contracts"; |
| 6 | |
| 7 | import { EMPTY_ASK, type Turn, askPage, askReducer, cutOff, history, readLines } from "../lib/ask"; |
| 8 | import { MessageText, type TextContext } from "./chat/text"; |
| 9 | import { MarkGlyph } from "./logo"; |
| 10 | import { Alert, AlertDescription, AlertTitle } from "./ui/alert"; |
| 11 | import { Badge } from "./ui/badge"; |
| 12 | import { Button } from "./ui/button"; |
| 13 | import { Hint } from "./ui/hint"; |
| 14 | import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "./ui/sheet"; |
| 15 | import { 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 | |
| 30 | type Setup = AskSetup & { owner: boolean }; |
| 31 | type SetupAnswer = { ok: boolean; value?: AskSetup; error?: { message: string }; owner: boolean }; |
| 32 | |
| 33 | const storageKey = (slug: string) => `g1t:ask:${slug}`; |
| 34 | |
| 35 | function 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 | |
| 44 | function 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 | */ |
| 56 | function 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 | |
| 137 | type Conversation = ReturnType<typeof useAskConversation>; |
| 138 | |
| 139 | /** The top bar's Ask: the lavender pixel 1 beside its words. Opens the conversation beside the page. */ |
| 140 | export 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 | |
| 160 | function 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 | |
| 315 | function 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 | |
| 336 | function 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 | |
| 348 | function 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 | |
| 356 | function 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 | } |