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.

187 linesCodeBlameRaw
1/**
2 * Home's way into Ask (components/ask-composer): the box at the top of the
3 * page, What changed (g1t's plain-English summary of the span, streamed
4 * onto the page by components/home-summary), and Explain on each change
5 * that landed. The box and Explain hand their question to the top bar's Ask
6 * panel (lib/ask.ts askG1t). Every question goes to the viewer's direct
7 * message with @g1t, so the answers stay in Chat and Ask carries on from them.
8 */
9import { ArrowUp, MessageSquare, RotateCcw, Sparkles } from "lucide-react";
10import { type FormEvent, type KeyboardEvent, Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
11import { Link } from "react-router";
12
13import { askG1t, openAsk } from "../lib/ask";
14import { HOME_STARTERS, type LandedChange, type Span, explainQuestion, summarizeQuestion } from "../lib/home";
15import { Suggestion, Suggestions } from "./ai-elements/suggestion";
16import { Card } from "./home";
17import { MarkGlyph } from "./logo";
18import { Button } from "./ui/button";
19import { Hint } from "./ui/hint";
20import { Skeleton } from "./ui/skeleton";
21import { Textarea } from "./ui/textarea";
22
23const loadSummary = () => import("./home-summary");
24const HomeSummary = lazy(loadSummary);
25
26type AskSpan = Pick<Span, "key" | "note" | "words" | "from" | "now">;
27
28/** The window event Home's box sends to have What changed summarize the span. */
29const SUMMARIZE_EVENT = "g1t:home-summarize";
30
31/** What the span is called on a button: "since I was last here", "in the last 24 hours". */
32function spanLabel(span: AskSpan): string {
33 return span.key === "last" && span.note == null ? "since I was last here" : `${span.words.charAt(0).toLowerCase()}${span.words.slice(1)}`;
34}
35
36/** The top of Home: what are we building today, a box that asks g1t, and a few things to ask first. */
37export function HomeAsk({ span }: { span: AskSpan }) {
38 const [draft, setDraft] = useState("");
39 const send = () => {
40 if (!draft.trim()) return;
41 askG1t(draft);
42 setDraft("");
43 };
44 const submit = (event: FormEvent) => {
45 event.preventDefault();
46 send();
47 };
48 const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
49 if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
50 event.preventDefault();
51 send();
52 }
53 };
54 return (
55 <section aria-labelledby="home-ask" className="mx-auto flex w-full max-w-3xl flex-col items-center gap-5 pt-4 pb-2 md:pt-8">
56 <h1 id="home-ask" className="text-center text-[1.75rem] leading-tight font-semibold tracking-tight md:text-[2rem]">
57 What are we building today?
58 </h1>
59 <form onSubmit={submit} className="w-full">
60 <div className="flex items-end gap-2 rounded-2xl border border-line bg-surface p-2 pl-4 shadow-sm transition-shadow focus-within:border-accent-dim focus-within:ring-2 focus-within:ring-accent/25">
61 <MarkGlyph className="mb-2.5 h-4 shrink-0" />
62 <label htmlFor="home-ask-box" className="sr-only">
63 Ask g1t
64 </label>
65 <Textarea
66 id="home-ask-box"
67 value={draft}
68 onChange={(event) => setDraft(event.target.value)}
69 onKeyDown={onKeyDown}
70 rows={1}
71 maxLength={16_000}
72 placeholder="Ask g1t what changed, what's blocked, or what to do next"
73 className="field-sizing-content max-h-40 min-h-10 resize-none border-0 bg-transparent px-1 py-2 text-[0.9375rem] shadow-none focus-visible:ring-0"
74 />
75 <Hint label="Ask (Enter)">
76 <Button type="submit" variant="accent" size="icon-sm" disabled={!draft.trim()} aria-label="Ask" className="mb-1">
77 <ArrowUp size={15} />
78 </Button>
79 </Hint>
80 </div>
81 </form>
82 <Suggestions className="justify-center">
83 <Suggestion
84 suggestion={`Summarize what changed ${spanLabel(span)}`}
85 onPick={() => window.dispatchEvent(new CustomEvent(SUMMARIZE_EVENT))}
86 onPointerEnter={() => void loadSummary()}
87 className="border-accent-dim text-fg"
88 />
89 {HOME_STARTERS.map((starter) => (
90 <Suggestion key={starter.label} suggestion={starter.label} onPick={() => askG1t(starter.question)} />
91 ))}
92 </Suggestions>
93 </section>
94 );
95}
96
97/**
98 * What changed: g1t's summary of the span in plain English, on the page.
99 * Asked only when the viewer presses Summarize (each answer is a call to
100 * the workspace's own AI provider); asked again for another span.
101 */
102export function WhatChangedCard({ slug, span, tz }: { slug: string; span: AskSpan; tz: string | null }) {
103 // Each ask is its own: Summarize again asks anew.
104 const [asked, setAsked] = useState<{ question: string; at: number } | null>(null);
105 const [done, setDone] = useState<{ channelId: string | null } | null>(null);
106 const card = useRef<HTMLDivElement>(null);
107 const label = spanLabel(span);
108
109 const summarize = useCallback(() => {
110 setDone(null);
111 setAsked({ question: summarizeQuestion(span, tz), at: Date.now() });
112 }, [span, tz]);
113
114 // Home's box asks for it: bring the card into view and start.
115 useEffect(() => {
116 const onAsk = () => {
117 card.current?.scrollIntoView({ behavior: "smooth", block: "start" });
118 summarize();
119 };
120 window.addEventListener(SUMMARIZE_EVENT, onAsk);
121 return () => window.removeEventListener(SUMMARIZE_EVENT, onAsk);
122 }, [summarize]);
123
124 const onDone = useCallback((channelId: string | null) => setDone({ channelId }), []);
125
126 return (
127 <div ref={card} className="scroll-mt-4">
128 <Card title="What changed" hint={`In plain English, ${label}`}>
129 <div className="px-5 pb-5">
130 {!asked ? (
131 <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
132 <p className="grow text-sm text-muted">g1t reads the commits, pull requests, deploys and conversations {label} and tells you what changed, why, and what is live.</p>
133 <Button type="button" variant="accent" size="sm" onClick={summarize} onPointerEnter={() => void loadSummary()} className="shrink-0 self-start sm:self-auto">
134 <Sparkles size={14} />
135 Summarize
136 </Button>
137 </div>
138 ) : (
139 <>
140 <Suspense
141 fallback={
142 <div className="space-y-2.5">
143 <Skeleton className="h-3.5 w-11/12" />
144 <Skeleton className="h-3.5 w-3/4" />
145 </div>
146 }
147 >
148 <HomeSummary key={asked.at} slug={slug} question={asked.question} onDone={onDone} />
149 </Suspense>
150 {done && (
151 <div className="mt-4 flex flex-wrap items-center gap-2 border-t border-line pt-3">
152 <Button type="button" variant="soft" size="sm" onClick={openAsk}>
153 <MarkGlyph className="h-3" />
154 Ask a follow-up
155 </Button>
156 <Button type="button" variant="ghost" size="sm" onClick={summarize}>
157 <RotateCcw size={13} />
158 Summarize again
159 </Button>
160 {done.channelId && (
161 <Button asChild variant="ghost" size="sm" className="sm:ml-auto">
162 <Link to={`/${slug}/-/chat/dm/${done.channelId}`}>
163 <MessageSquare size={13} />
164 In your direct message with g1t
165 </Link>
166 </Button>
167 )}
168 </div>
169 )}
170 </>
171 )}
172 </div>
173 </Card>
174 </div>
175 );
176}
177
178/** Explain one change that landed: what it did and why, in plain English. */
179export function ExplainButton({ change }: { change: Pick<LandedChange, "kind" | "title" | "to"> }) {
180 return (
181 <Hint label="Explain this change in plain English">
182 <Button type="button" variant="ghost" size="icon-xs" aria-label={`Explain ${change.title}`} onClick={() => askG1t(explainQuestion(change))} className="shrink-0 text-muted hover:text-accent">
183 <Sparkles size={13} />
184 </Button>
185 </Hint>
186 );
187}