Home says what changed on the page (#51): What changed, first under "Since you were last here", streams g1t's plain-English summary of the span into the card (components/home-summary, loaded when Summarize is first pressed), with Ask a follow-up, Summarize again and a link to the direct message it is kept in. The box's first starter, "Summarize what changed since I was last here", brings the card into view and starts it. The questions read as a person would ask them ("Summarize what changed since I was last here (2:12 PM) in plain English.", "Explain pull request #47 in flagon-io/g1t in plain English: what changed, why, and is it live?"): what to read and how comes from g1t's own prompt. Explain shows on every Landed row, not on hover. Checked: the site's typecheck and tests (955), and the real Home route rendered with mocked data and a streaming answer at desktop and phone width, dark and light: Summarize, the starter, Ask a follow-up and Explain.
- G1t-Session:
- ses_01m4ygex20ezftekecszgw2gv5
2 files+155−30/2 viewed
| 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 | + | */ | |
| 9 | + | import { useCallback, useEffect, useRef, useState } from "react"; | |
| 10 | + | import { Link, useLocation } from "react-router"; | |
| 11 | + | ||
| 12 | + | import type { AskSetup, ChatLiveEvent, ChatMessage, Result } from "@g1t/contracts"; | |
| 13 | + | ||
| 14 | + | import { askPage } from "../lib/ask"; | |
| 15 | + | import { answering, applyLive, fromG1t } from "../lib/g1t-chat"; | |
| 16 | + | import { browserTimeZone } from "../lib/time-zone"; | |
| 17 | + | import { G1tAnswer } from "./chat/answer"; | |
| 18 | + | import { useChatLive } from "./chat/live"; | |
| 19 | + | import { Alert, AlertDescription } from "./ui/alert"; | |
| 20 | + | import { Skeleton } from "./ui/skeleton"; | |
| 21 | + | ||
| 22 | + | /** How often the conversation is read again while the answer comes, should the socket miss it. */ | |
| 23 | + | const POLL_MS = 2_500; | |
| 24 | + | /** How long the card waits for an answer before it stops reading. */ | |
| 25 | + | const GIVE_UP_MS = 4 * 60_000; | |
| 26 | + | ||
| 27 | + | async 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 | + | ||
| 36 | + | function 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 | + | ||
| 41 | + | type 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. */ | |
| 47 | + | export 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 | + | } |
| 83 | 83 | ||
| 84 | 84 | /** | |
| 85 | 85 | * Home: the workspace's front page. First a box that asks g1t | |
| 86 | − | * (components/home-ask; What changed summarizes the span on the page, and | |
| 87 | − | * Explain on each change that landed asks too); then where you're needed now, with no time | |
| 88 | − | * window; what happened since you were last here (or over the last 24 | |
| 86 | + | * (components/home-ask: What changed summarizes the span on the page, and | |
| 87 | + | * Explain on each change that landed asks about it); then where you're | |
| 88 | + | * needed now, with no time window; what happened since you were last here (or over the last 24 | |
| 89 | 89 | * hours or 7 days, `?window=`): what people did, from the events | |
| 90 | 90 | * service's digest of the span and chat's, then what landed; what is | |
| 91 | 91 | * running; what the span cost. The last visit is kept by notify, per |
Comments
Sign in to comment.