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.

152 linesCodeBlameRaw
1/**
2 * Home's summary, on the page: the question goes to the viewer's direct
3 * message with @g1t, as Ask's does, and g1t's answer streams into the card
4 * over that conversation's live socket. Loaded the first time Summarize is
5 * pressed (components/home-ask.tsx), so Home doesn't carry the answer's
6 * Markdown and code until then. The answer stays in the direct message, so
7 * a follow-up in Ask carries on from it.
8 */
9import { useCallback, useEffect, useRef, useState } from "react";
10import { Link, useLocation } from "react-router";
11
12import type { AskSetup, ChatLiveEvent, ChatMessage, Result } from "@g1t/contracts";
13
14import { askPage } from "../lib/ask";
15import { answering, applyLive, fromG1t } from "../lib/g1t-chat";
16import { browserTimeZone } from "../lib/time-zone";
17import { G1tAnswer } from "./chat/answer";
18import { useChatLive } from "./chat/live";
19import { Alert, AlertDescription } from "./ui/alert";
20import { Skeleton } from "./ui/skeleton";
21
22/** How often the conversation is read again while the answer comes, should the socket miss it. */
23const POLL_MS = 2_500;
24/** How long the card waits for an answer before it stops reading. */
25const GIVE_UP_MS = 4 * 60_000;
26
27async function chatApi<T>(slug: string, body: Record<string, unknown>): Promise<Result<T>> {
28 try {
29 const response = await fetch(`/${slug}/-/chat/api`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
30 return (await response.json()) as Result<T>;
31 } catch {
32 return { ok: false, error: { code: "conflict", message: "g1t could not be reached. Check your connection and try again." } };
33 }
34}
35
36function LiveSocket({ slug, channelId, onEvent, onReconnect }: { slug: string; channelId: string; onEvent: (event: ChatLiveEvent) => void; onReconnect: () => void }) {
37 useChatLive(slug, channelId, onEvent, onReconnect);
38 return null;
39}
40
41type State =
42 | { step: "asking" }
43 | { step: "answering"; channelId: string; questionId: string }
44 | { step: "problem"; message: string; setup: boolean; owner: boolean };
45
46/** Asks g1t `question` once, then shows its answer as it streams. `onDone` runs when the answer is finished. */
47export default function HomeSummary({ slug, question, onDone }: { slug: string; question: string; onDone?: (channelId: string | null) => void }) {
48 const { pathname } = useLocation();
49 const [state, setState] = useState<State>({ step: "asking" });
50 const [messages, setMessages] = useState<ChatMessage[]>([]);
51 const asked = useRef(false);
52
53 useEffect(() => {
54 if (asked.current) return;
55 asked.current = true;
56 void (async () => {
57 const setup = await fetch(`/${slug}/-/ask`, { headers: { accept: "application/json" } })
58 .then((response) => response.json() as Promise<{ ok: boolean; value?: AskSetup; owner: boolean }>)
59 .catch(() => null);
60 if (!setup?.ok || !setup.value) return setState({ step: "problem", message: "Ask could not be reached. Try again in a moment.", setup: false, owner: false });
61 if (setup.value.problem) return setState({ step: "problem", message: setup.value.problem, setup: true, owner: setup.owner });
62 const opened = await chatApi<{ channel: { id: string } }>(slug, { intent: "g1t_dm" });
63 if (!opened.ok) return setState({ step: "problem", message: opened.error.message, setup: false, owner: false });
64 const channelId = opened.value.channel.id;
65 const saved = await chatApi<ChatMessage>(slug, { intent: "post", channel_id: channelId, body: question, page: { ...askPage(pathname), time_zone: browserTimeZone() } });
66 if (!saved.ok) return setState({ step: "problem", message: saved.error.message, setup: false, owner: false });
67 setState({ step: "answering", channelId, questionId: saved.value.id });
68 })();
69 }, [slug, question, pathname]);
70
71 const channelId = state.step === "answering" ? state.channelId : null;
72 const questionId = state.step === "answering" ? state.questionId : null;
73 const answer = questionId ? (messages.find((m) => fromG1t(m) && !m.reset && !m.deleted_at && m.id > questionId) ?? null) : null;
74 const finished = answer != null && !answering(answer.answer);
75
76 const load = useCallback(async () => {
77 if (!channelId) return;
78 try {
79 const response = await fetch(`/${slug}/-/chat/api?channel=${encodeURIComponent(channelId)}&limit=10`, { headers: { accept: "application/json" } });
80 const page = (await response.json()) as Result<{ messages: ChatMessage[] }>;
81 if (page.ok) {
82 // A frame streamed over the socket is newer than a row still being written: keep the longer body.
83 setMessages((now) =>
84 page.value.messages.map((m) => {
85 const known = now.find((k) => k.id === m.id);
86 return known && answering(m.answer) && known.body.length > m.body.length ? known : m;
87 }),
88 );
89 }
90 } catch {
91 // Read again on the next tick.
92 }
93 }, [channelId, slug]);
94
95 const onEvent = useCallback((event: ChatLiveEvent) => {
96 if (channelId) setMessages((now) => applyLive(now, event, channelId));
97 }, [channelId]);
98
99 // Read until the answer is finished: the socket brings it as it streams, this makes sure of it.
100 useEffect(() => {
101 if (!channelId || finished) return;
102 void load();
103 const started = Date.now();
104 const timer = setInterval(() => {
105 if (Date.now() - started > GIVE_UP_MS) clearInterval(timer);
106 else void load();
107 }, POLL_MS);
108 return () => clearInterval(timer);
109 }, [channelId, finished, load]);
110
111 const told = useRef(false);
112 useEffect(() => {
113 if (finished && !told.current) {
114 told.current = true;
115 onDone?.(channelId);
116 }
117 }, [finished, channelId, onDone]);
118
119 if (state.step === "problem") {
120 return (
121 <Alert tone={state.setup ? "info" : "danger"}>
122 <AlertDescription>
123 {state.message}{" "}
124 {state.setup &&
125 (state.owner ? (
126 <Link to={`/${slug}/-/ai#ask`} className="text-accent hover:underline">
127 Connect a provider and choose it for Ask.
128 </Link>
129 ) : (
130 <>Ask an owner of {slug} to connect one.</>
131 ))}
132 </AlertDescription>
133 </Alert>
134 );
135 }
136
137 return (
138 <div aria-live="polite" aria-busy={!finished}>
139 {channelId && <LiveSocket slug={slug} channelId={channelId} onEvent={onEvent} onReconnect={() => void load()} />}
140 {answer ? (
141 <G1tAnswer message={answer} context={{ slug, repo: null }} />
142 ) : (
143 <div className="space-y-2.5" aria-label="g1t is reading what happened">
144 <p className="text-sm text-muted">Reading what happened…</p>
145 <Skeleton className="h-3.5 w-11/12" />
146 <Skeleton className="h-3.5 w-3/4" />
147 <Skeleton className="h-3.5 w-5/6" />
148 </div>
149 )}
150 </div>
151 );
152}