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.

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