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.

171 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({
37 slug,
38 channelId,
39 onEvent,
40 onReconnect,
41}: {
42 slug: string;
43 channelId: string;
44 onEvent: (event: ChatLiveEvent) => void;
45 onReconnect: () => void;
46}) {
47 useChatLive(slug, channelId, onEvent, onReconnect);
48 return null;
49}
50
51type State =
52 | { step: "asking" }
53 | { step: "answering"; channelId: string; questionId: string }
54 | { step: "problem"; message: string; setup: boolean; owner: boolean };
55
56/** Asks g1t `question` once, then shows its answer as it streams. `onDone` runs when the answer is finished. */
57export default function HomeSummary({ slug, question, onDone }: { slug: string; question: string; onDone?: (channelId: string | null) => void }) {
58 const { pathname } = useLocation();
59 const [state, setState] = useState<State>({ step: "asking" });
60 const [messages, setMessages] = useState<ChatMessage[]>([]);
61 const asked = useRef(false);
62
63 useEffect(() => {
64 if (asked.current) return;
65 asked.current = true;
66 void (async () => {
67 const setup = await fetch(`/${slug}/-/ask`, { headers: { accept: "application/json" } })
68 .then((response) => response.json() as Promise<{ ok: boolean; value?: AskSetup; owner: boolean }>)
69 .catch(() => null);
70 if (!setup?.ok || !setup.value)
71 return setState({ step: "problem", message: "Ask could not be reached. Try again in a moment.", setup: false, owner: false });
72 if (setup.value.problem) return setState({ step: "problem", message: setup.value.problem, setup: true, owner: setup.owner });
73 const opened = await chatApi<{ channel: { id: string } }>(slug, { intent: "g1t_dm" });
74 if (!opened.ok) return setState({ step: "problem", message: opened.error.message, setup: false, owner: false });
75 const channelId = opened.value.channel.id;
76 const saved = await chatApi<ChatMessage>(slug, {
77 intent: "post",
78 channel_id: channelId,
79 body: question,
80 page: { ...askPage(pathname), time_zone: browserTimeZone() },
81 });
82 if (!saved.ok) return setState({ step: "problem", message: saved.error.message, setup: false, owner: false });
83 setState({ step: "answering", channelId, questionId: saved.value.id });
84 })();
85 }, [slug, question, pathname]);
86
87 const channelId = state.step === "answering" ? state.channelId : null;
88 const questionId = state.step === "answering" ? state.questionId : null;
89 const answer = questionId ? (messages.find((m) => fromG1t(m) && !m.reset && !m.deleted_at && m.id > questionId) ?? null) : null;
90 const finished = answer != null && !answering(answer.answer);
91
92 const load = useCallback(async () => {
93 if (!channelId) return;
94 try {
95 const response = await fetch(`/${slug}/-/chat/api?channel=${encodeURIComponent(channelId)}&limit=10`, { headers: { accept: "application/json" } });
96 const page = (await response.json()) as Result<{ messages: ChatMessage[] }>;
97 if (page.ok) {
98 // A frame streamed over the socket is newer than a row still being written: keep the longer body.
99 setMessages((now) =>
100 page.value.messages.map((m) => {
101 const known = now.find((k) => k.id === m.id);
102 return known && answering(m.answer) && known.body.length > m.body.length ? known : m;
103 }),
104 );
105 }
106 } catch {
107 // Read again on the next tick.
108 }
109 }, [channelId, slug]);
110
111 const onEvent = useCallback(
112 (event: ChatLiveEvent) => {
113 if (channelId) setMessages((now) => applyLive(now, event, channelId));
114 },
115 [channelId],
116 );
117
118 // Read until the answer is finished: the socket brings it as it streams, this makes sure of it.
119 useEffect(() => {
120 if (!channelId || finished) return;
121 void load();
122 const started = Date.now();
123 const timer = setInterval(() => {
124 if (Date.now() - started > GIVE_UP_MS) clearInterval(timer);
125 else void load();
126 }, POLL_MS);
127 return () => clearInterval(timer);
128 }, [channelId, finished, load]);
129
130 const told = useRef(false);
131 useEffect(() => {
132 if (finished && !told.current) {
133 told.current = true;
134 onDone?.(channelId);
135 }
136 }, [finished, channelId, onDone]);
137
138 if (state.step === "problem") {
139 return (
140 <Alert tone={state.setup ? "info" : "danger"}>
141 <AlertDescription>
142 {state.message}{" "}
143 {state.setup &&
144 (state.owner ? (
145 <Link to={`/${slug}/-/ai#ask`} className="text-accent hover:underline">
146 Connect a provider and choose it for Ask.
147 </Link>
148 ) : (
149 <>Ask an owner of {slug} to connect one.</>
150 ))}
151 </AlertDescription>
152 </Alert>
153 );
154 }
155
156 return (
157 <div aria-live="polite" aria-busy={!finished}>
158 {channelId && <LiveSocket slug={slug} channelId={channelId} onEvent={onEvent} onReconnect={() => void load()} />}
159 {answer ? (
160 <G1tAnswer message={answer} context={{ slug, repo: null }} />
161 ) : (
162 <div className="space-y-2.5" aria-label="g1t is reading what happened">
163 <p className="text-sm text-muted">Reading what happened…</p>
164 <Skeleton className="h-3.5 w-11/12" />
165 <Skeleton className="h-3.5 w-3/4" />
166 <Skeleton className="h-3.5 w-5/6" />
167 </div>
168 )}
169 </div>
170 );
171}