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.

g1t.sh

Commit

Home opens with an Ask box (#51): "What are we building today?" over a box that asks g1t, with starters (what changed since I was last here, what needs me first, what shipped today, who's working on what), in place of "Welcome back". Summarize, beside the span switch on "Since you were last here", asks g1t what changed in that span in plain English, from its start in the viewer's zone; Explain on each Landed row asks what that one change did, why, and whether it is live. Each hands its question to the top bar's Ask (askG1t, a g1t:ask window event), which opens and sends it, or leaves it in the box when Ask is not set up or an answer is still coming. Checked: the site's typecheck and tests (956), and the real Home route and Ask panel rendered with mocked data at desktop and phone width, dark and light, clicking the box, a starter, Summarize and Explain.

G1t-Session:
ses_01m4ygex20ezftekecszgw2gv5
syntaqxcommitted Parent43a29c4Browse files
8 files+266−370/8 viewed
+15−1
99 import { Suspense, lazy, useEffect, useState } from "react";
1010 import { useLocation } from "react-router";
1111
12+import { ASK_EVENT } from "../lib/ask";
1213 import { MarkGlyph } from "./logo";
1314 import { Button } from "./ui/button";
1415 import { Hint } from "./ui/hint";
2122 export function AskButton({ slug }: { slug: string }) {
2223 const [open, setOpen] = useState(false);
2324 const [wanted, setWanted] = useState(false);
25+ // A question a page asked (lib/ask.ts askG1t), until the panel sends it.
26+ const [queued, setQueued] = useState<{ text: string; at: number } | null>(null);
2427 const location = useLocation();
2528 // Any completed navigation, including answer citations and the current DM,
2629 // reveals its destination. Revalidation and links opened in another tab do not.
2730 useEffect(() => setOpen(false), [location.key]);
31+ useEffect(() => {
32+ const onAsk = (event: Event) => {
33+ const text = (event as CustomEvent<{ text?: string }>).detail?.text?.trim();
34+ if (!text) return;
35+ setQueued({ text, at: Date.now() });
36+ setWanted(true);
37+ setOpen(true);
38+ };
39+ window.addEventListener(ASK_EVENT, onAsk);
40+ return () => window.removeEventListener(ASK_EVENT, onAsk);
41+ }, []);
2842 const change = (next: boolean) => {
2943 if (next) setWanted(true);
3044 setOpen(next);
4155 </Hint>
4256 {wanted && (
4357 <Suspense fallback={<Loading />}>
44− <AskSheet slug={slug} open={open} />
58+ <AskSheet slug={slug} open={open} queued={queued} onQueuedTaken={() => setQueued(null)} />
4559 </Suspense>
4660 )}
4761 </Sheet>
+29−3
223223
224224 type G1tConversation = ReturnType<typeof useG1tConversation>;
225225
226+/** A question another page asked (lib/ask.ts askG1t), waiting for the panel to send it. */
227+type Queued = { text: string; at: number };
228+
226229 /**
227230 * The Ask panel's sheet (components/ask-button.tsx loads it the first time
228231 * the top bar's Ask opens, so pages don't carry it until then): the
229232 * conversation, kept while the panel is closed.
230233 */
231−export function AskSheet({ slug, open }: { slug: string; open: boolean }) {
234+export function AskSheet({ slug, open, queued = null, onQueuedTaken }: { slug: string; open: boolean; queued?: Queued | null; onQueuedTaken?: () => void }) {
232235 const conversation = useG1tConversation(slug, open);
233236 const busy = anyAnswering(conversation.messages);
234237 return (
242245 void conversation.act("g1t_stop");
243246 }}
244247 >
245− <AskPanel slug={slug} open={open} conversation={conversation} />
248+ <AskPanel slug={slug} open={open} conversation={conversation} queued={queued} onQueuedTaken={onQueuedTaken} />
246249 </SheetContent>
247250 );
248251 }
253256 return null;
254257 }
255258
256−function AskPanel({ slug, open, conversation }: { slug: string; open: boolean; conversation: G1tConversation }) {
259+function AskPanel({
260+ slug,
261+ open,
262+ conversation,
263+ queued,
264+ onQueuedTaken,
265+}: {
266+ slug: string;
267+ open: boolean;
268+ conversation: G1tConversation;
269+ queued: Queued | null;
270+ onQueuedTaken?: () => void;
271+}) {
257272 const { pathname } = useLocation();
258273 const { channelId, messages, loaded, problem, ask, act, clear, load, onEvent } = conversation;
259274 const [clearing, setClearing] = useState(false);
335350 send(draft);
336351 }
337352 };
353+ // A question asked from a page (Home's box, Summarize, Explain): sent once
354+ // Ask is ready and the conversation has loaded; left in the box when Ask is
355+ // not set up or an answer is still coming, so nothing is lost.
356+ useEffect(() => {
357+ if (!queued || !open || !loaded || (setup == null && !setupError)) return;
358+ if (ready && !busy) void ask(queued.text, pathname);
359+ else setDraft(queued.text);
360+ onQueuedTaken?.();
361+ // eslint-disable-next-line react-hooks/exhaustive-deps -- once per queued question
362+ }, [queued?.at, open, loaded, setup, setupError]);
363+
338364 const fresh = () => {
339365 void act("g1t_reset");
340366 input.current?.focus();
+104−0
1+/**
2+ * Home's way into Ask (components/ask-composer): the box at the top of the
3+ * page, Summarize on what happened in the span, and Explain on each change
4+ * that landed. Each hands its question to the top bar's Ask panel
5+ * (lib/ask.ts askG1t), which asks g1t in the viewer's direct message with
6+ * it, so the answer streams there and stays in Chat.
7+ */
8+import { ArrowUp, Sparkles } from "lucide-react";
9+import { type FormEvent, type KeyboardEvent, useState } from "react";
10+
11+import { askG1t } from "../lib/ask";
12+import { type LandedChange, type Span, explainQuestion, homeStarters, summarizeQuestion } from "../lib/home";
13+import { Suggestion, Suggestions } from "./ai-elements/suggestion";
14+import { MarkGlyph } from "./logo";
15+import { Button } from "./ui/button";
16+import { Hint } from "./ui/hint";
17+import { Textarea } from "./ui/textarea";
18+
19+type AskSpan = Pick<Span, "key" | "note" | "words" | "from">;
20+
21+/** The top of Home: what are we building today, a box that asks g1t, and a few things to ask first. */
22+export function HomeAsk({ span, tz }: { span: AskSpan; tz: string | null }) {
23+ const [draft, setDraft] = useState("");
24+ const send = () => {
25+ if (!draft.trim()) return;
26+ askG1t(draft);
27+ setDraft("");
28+ };
29+ const submit = (event: FormEvent) => {
30+ event.preventDefault();
31+ send();
32+ };
33+ const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
34+ if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
35+ event.preventDefault();
36+ send();
37+ }
38+ };
39+ return (
40+ <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">
41+ <h1 id="home-ask" className="text-center text-[1.75rem] leading-tight font-semibold tracking-tight md:text-[2rem]">
42+ What are we building today?
43+ </h1>
44+ <form onSubmit={submit} className="w-full">
45+ <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">
46+ <MarkGlyph className="mb-2.5 h-4 shrink-0" />
47+ <label htmlFor="home-ask-box" className="sr-only">
48+ Ask g1t
49+ </label>
50+ <Textarea
51+ id="home-ask-box"
52+ value={draft}
53+ onChange={(event) => setDraft(event.target.value)}
54+ onKeyDown={onKeyDown}
55+ rows={1}
56+ maxLength={16_000}
57+ placeholder="Ask g1t what changed, what's blocked, or what to do next"
58+ 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"
59+ />
60+ <Hint label="Ask (Enter)">
61+ <Button type="submit" variant="accent" size="icon-sm" disabled={!draft.trim()} aria-label="Ask" className="mb-1">
62+ <ArrowUp size={15} />
63+ </Button>
64+ </Hint>
65+ </div>
66+ </form>
67+ <Suggestions className="justify-center">
68+ {homeStarters(span, tz).map((starter) => (
69+ <Suggestion key={starter.label} suggestion={starter.label} onPick={() => askG1t(starter.question)} />
70+ ))}
71+ </Suggestions>
72+ </section>
73+ );
74+}
75+
76+/** Summarize what happened in the span, in plain English. */
77+export function SummarizeButton({ span, tz }: { span: AskSpan; tz: string | null }) {
78+ return (
79+ <Hint label="g1t reads everything that happened in this span and tells you what changed">
80+ <Button type="button" variant="soft" size="sm" onClick={() => askG1t(summarizeQuestion(span, tz))}>
81+ <Sparkles size={14} />
82+ Summarize
83+ </Button>
84+ </Hint>
85+ );
86+}
87+
88+/** Explain one change that landed: what it did and why, in plain English. */
89+export function ExplainButton({ change }: { change: Pick<LandedChange, "kind" | "title" | "to"> }) {
90+ return (
91+ <Hint label="Explain this change">
92+ <Button
93+ type="button"
94+ variant="ghost"
95+ size="icon-xs"
96+ aria-label={`Explain ${change.title}`}
97+ onClick={() => askG1t(explainQuestion(change))}
98+ className="shrink-0 text-muted hover:text-fg sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100"
99+ >
100+ <Sparkles size={13} />
101+ </Button>
102+ </Hint>
103+ );
104+}
+3−1
5353 import { cn } from "../lib/cn";
5454 import { money } from "../lib/money";
5555 import { CommitText } from "./commit-message";
56+import { ExplainButton } from "./home-ask";
5657 import { ButtonLink } from "./ui";
5758 import { AgentMarker, Avatar } from "./ui/avatar";
5859 import { Badge, type BadgeTone } from "./ui/badge";
545546
546547 function LandedItem({ change, now, tz, timeOnly }: { change: LandedChange; now: number; tz: string | null; timeOnly?: boolean }) {
547548 return (
548− <li className={cn(rowClass, "items-start")}>
549+ <li className={cn(rowClass, "group items-start")}>
549550 <span className="mt-0.5 shrink-0">{LANDED_ICON[change.kind]}</span>
550551 <div className="min-w-0 grow">
551552 <div className="flex min-w-0 items-center gap-1.5">
566567 <Shipped change={change} />
567568 </div>
568569 </div>
570+ <ExplainButton change={change} />
569571 <When at={change.at} now={now} tz={tz} timeOnly={timeOnly} />
570572 </li>
571573 );
+14−0
2626 return page;
2727 }
2828
29+/** The window event a page sends to have the top bar's Ask open and ask a question (components/ask-button.tsx). */
30+export const ASK_EVENT = "g1t:ask";
31+
32+/**
33+ * Opens the top bar's Ask panel and asks g1t this, as if it were typed
34+ * there: Home's box, its Summarize and Explain buttons. Where Ask is not
35+ * set up, the panel opens with the question in its box.
36+ */
37+export function askG1t(text: string) {
38+ const question = text.trim();
39+ if (!question) return;
40+ window.dispatchEvent(new CustomEvent<{ text: string }>(ASK_EVENT, { detail: { text: question } }));
41+}
42+
2943 /** What the composer says about where the question is asked from. */
3044 export function askWhere(page: AskPage): string | null {
3145 if (page.issue) return `On issue #${page.issue}`;
+33−8
1212 agentLabel,
1313 attention,
1414 awayOf,
15− landedByDay,
16− pushRows,
17− sessionTrailers,
18− settledIn,
19− shippedAsks,
20− shippedLabel,
21− withSessions,
22− withShipped,
2315 chatRow,
2416 codeRow,
2517 dayIn,
2618 deploysIn,
2719 digestOf,
2820 duration,
21+ explainQuestion,
22+ homeStarters,
2923 landed,
24+ landedByDay,
3025 landedDeploys,
3126 landedFromActivity,
3227 landedIn,
3328 notificationRow,
3429 parseWindow,
30+ pushRows,
3531 rankAttention,
3632 running,
3733 runningDeploys,
3834 runningWorkflows,
35+ sessionTrailers,
36+ settledIn,
37+ shippedAsks,
38+ shippedLabel,
3939 sinceWords,
4040 spanFor,
4141 spanMonths,
4242 spanSentence,
4343 spendIn,
4444 startHere,
45+ summarizeQuestion,
4546 waitingSentence,
47+ withSessions,
48+ withShipped,
4649 } from "./home.ts";
4750
4851 const HOUR = 3_600_000;
780783 });
781784 assert.deepEqual({ ...away, settled: away.settled.length }, { settled: 2, shipped: 1, waiting: 1, deploying: 0, failed: 0, needs: 3 });
782785 });
786+
787+test("Summarize asks about the span the page shows, from its start in the viewer's zone", () => {
788+ const from = Date.UTC(2026, 9, 13, 18, 0);
789+ const last = summarizeQuestion({ key: "last", note: null, words: "Since yesterday evening", from }, "UTC");
790+ assert.equal(last, "Summarize what changed since I was last here (Oct 13, 6:00 PM) in plain English: what shipped, what's still deploying or failed, what's in flight, and what needs me.");
791+ assert.match(summarizeQuestion({ key: "24h", note: null, words: "In the last 24 hours", from }, "America/Chicago"), /in the last 24 hours \(since Oct 13, 1:00 PM\)/);
792+ // A first visit shows the last 24 hours, so it is not asked as "since I was last here".
793+ assert.doesNotMatch(summarizeQuestion({ key: "last", note: "first", words: "In the last 24 hours", from }, "UTC"), /last here/);
794+});
795+
796+test("Explain asks about the one change, by what it is", () => {
797+ assert.equal(explainQuestion({ kind: "merge", title: "Faster pushes", to: "/acme/web/pull/12" }), 'Explain "Faster pushes" (/acme/web/pull/12) in plain English: what changed, why, and is it live?');
798+ assert.match(explainQuestion({ kind: "deploy", title: "web", to: "/acme/web/deployments/1" }), /went out in this deployment/);
799+ assert.match(explainQuestion({ kind: "release", title: "v1.2.0", to: "/acme/web/releases/v1.2.0" }), /What's new in v1\.2\.0/);
800+});
801+
802+test("Home's starters lead with what changed in the span", () => {
803+ const starters = homeStarters({ key: "7d", note: null, words: "In the last 7 days", from: 0 }, "UTC");
804+ assert.equal(starters.length, 4);
805+ assert.equal(starters[0]!.label, "What changed in the last 7 days?");
806+ assert.equal(homeStarters({ key: "last", note: null, words: "Since Tuesday", from: 0 }, "UTC")[0]!.label, "What changed since I was last here?");
807+});
+37−0
13521352 if (dayIn(at, timeZone) === dayIn(now, timeZone)) return formatIn(at, timeZone, { hour: "numeric", minute: "2-digit" });
13531353 return now - at < 6 * DAY ? formatIn(at, timeZone, { weekday: "short", hour: "numeric" }) : shortDate(at, timeZone);
13541354 }
1355+
1356+// --- Asking g1t from Home ------------------------------------------------------------
1357+
1358+type AskedSpan = Pick<Span, "key" | "note" | "words" | "from">;
1359+
1360+/** The span as part of a question, with its start in the viewer's zone (g1t is told the zone): "since I was last here (Oct 14, 2:10 PM)". */
1361+function spanAsked(span: AskedSpan, timeZone: string | null): string {
1362+ const since = formatIn(span.from, timeZone, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
1363+ if (span.key === "last" && span.note == null) return `since I was last here (${since})`;
1364+ return `${span.words.charAt(0).toLowerCase()}${span.words.slice(1)} (since ${since})`;
1365+}
1366+
1367+/**
1368+ * Home's Summarize: what changed in the span, in plain English, asked of
1369+ * g1t with the span's start, so the answer covers what the page shows.
1370+ */
1371+export function summarizeQuestion(span: AskedSpan, timeZone: string | null): string {
1372+ return `Summarize what changed ${spanAsked(span, timeZone)} in plain English: what shipped, what's still deploying or failed, what's in flight, and what needs me.`;
1373+}
1374+
1375+/** A Landed row's Explain: what one change did and why, in plain English. */
1376+export function explainQuestion(change: Pick<LandedChange, "kind" | "title" | "to">): string {
1377+ if (change.kind === "deploy") return `What went out in this deployment (${change.to}), in plain English?`;
1378+ if (change.kind === "release" || change.kind === "package") return `What's new in ${change.title} (${change.to}), in plain English?`;
1379+ return `Explain "${change.title}" (${change.to}) in plain English: what changed, why, and is it live?`;
1380+}
1381+
1382+/** What Home's Ask box offers to ask first: a label, and the question it asks. */
1383+export function homeStarters(span: AskedSpan, timeZone: string | null): { label: string; question: string }[] {
1384+ const changed = span.key === "last" && span.note == null ? "since I was last here" : `${span.words.charAt(0).toLowerCase()}${span.words.slice(1)}`;
1385+ return [
1386+ { label: `What changed ${changed}?`, question: summarizeQuestion(span, timeZone) },
1387+ { label: "What needs me first?", question: "What needs me first? Most urgent first, one line each on what to do." },
1388+ { label: "What shipped today?", question: "What shipped today? What's live, what's still deploying, and what failed." },
1389+ { label: "Who's working on what?", question: "Who's working on what right now, and is anything stuck?" },
1390+ ];
1391+}
+31−24
1919 StartHere,
2020 WindowSwitch,
2121 } from "../../components/home";
22+import { HomeAsk, SummarizeButton } from "../../components/home-ask";
2223 import { Skeleton } from "../../components/ui/skeleton";
2324 import { sidebarOrNull } from "../../lib/chat.server";
2425 import {
8182 const NOTIFICATIONS_READ = 50;
8283
8384 /**
84− * Home: the workspace's front page. Where you're needed now, with no time
85+ * Home: the workspace's front page. First a box that asks g1t
86+ * (components/home-ask: Summarize on the span and Explain on each change
87+ * that landed ask it too); then where you're needed now, with no time
8588 * window; what happened since you were last here (or over the last 24
8689 * hours or 7 days, `?window=`): what people did, from the events
8790 * service's digest of the span and chat's, then what landed; what is
242245 return (
243246 <div className="mx-auto w-full max-w-260 space-y-5 px-4 py-5 md:px-10 md:py-8">
244247 <OnboardingOnPage slug={slug} view={loaderData.onboarding} asked={loaderData.setupAsked} />
245− <header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
246− <div className="min-w-0">
247− <h1 className="text-[1.75rem] leading-tight font-semibold tracking-tight">{span.key === "last" && span.note == null ? "Welcome back" : "Home"}</h1>
248− <p className="mt-1.5 max-w-2xl text-sm leading-relaxed text-muted">
249− <Suspense fallback={<Skeleton className="inline-block h-3.5 w-72 align-middle" />}>
250− <Await resolve={head}>
251− {([digest, happened, att]) => (
252− <>
253− <span className="text-fg-soft">{waitingSentence(att.rows.length)}</span> {spanSentence(span, digest, happened?.landed?.length ?? null)}
254− </>
255− )}
256− </Await>
257− </Suspense>
258− </p>
259− </div>
260− <WindowSwitch span={span} />
248+ {/* 0. Ask g1t anything: the answer opens in the top bar's Ask panel. */}
249+ <header className="space-y-3">
250+ <HomeAsk span={span} tz={tz} />
251+ <p className="mx-auto max-w-2xl text-center text-sm leading-relaxed text-muted">
252+ <Suspense fallback={<Skeleton className="inline-block h-3.5 w-72 align-middle" />}>
253+ <Await resolve={head}>
254+ {([digest, happened, att]) => (
255+ <>
256+ <span className="text-fg-soft">{waitingSentence(att.rows.length)}</span> {spanSentence(span, digest, happened?.landed?.length ?? null)}
257+ </>
258+ )}
259+ </Await>
260+ </Suspense>
261+ </p>
261262 </header>
262263
263264 {/* 1. Where you're needed now: no window, first and biggest. Start here beside it on wide screens, under it on phones. */}
288289
289290 {/* 2. What happened in the span. */}
290291 <section aria-labelledby="since" className="space-y-4">
291− <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 pt-2">
292− <h2 id="since" className="text-lg font-semibold tracking-tight text-fg">
293− {span.key === "last" && span.note == null ? "Since you were last here" : span.words}
294− </h2>
295− <p className="text-sm text-muted">
296− {span.key === "last" && span.note == null ? `${span.words} · ${span.length}` : spanNote(span.note)}
297− </p>
292+ <div className="flex flex-wrap items-center gap-x-3 gap-y-2 pt-2">
293+ <div className="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1">
294+ <h2 id="since" className="text-lg font-semibold tracking-tight text-fg">
295+ {span.key === "last" && span.note == null ? "Since you were last here" : span.words}
296+ </h2>
297+ <p className="text-sm text-muted">
298+ {span.key === "last" && span.note == null ? `${span.words} · ${span.length}` : spanNote(span.note)}
299+ </p>
300+ </div>
301+ <div className="flex flex-wrap items-center gap-2 sm:ml-auto">
302+ <SummarizeButton span={span} tz={tz} />
303+ <WindowSwitch span={span} />
304+ </div>
298305 </div>
299306 <Suspense
300307 fallback={

Comments

Sign in to comment.