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.

208 linesCodeBlameRaw
1/**
2 * The landing page's product tour: one story told as a timeline. Every
3 * frame is a pure function of the time into the loop, so the tour can be
4 * paused, resumed, jumped to a step, or shown still, and the server and
5 * the first client render agree (t = 0).
6 *
7 * The story is Priya's morning. She opens Home: the notifications that
8 * settled overnight leave Where you're needed, Start here picks what is
9 * left, and What changed tells her in plain English what landed, that it
10 * is live, and that her pull request #431 is in review. She opens #431: it
11 * runs its checks, is approved by Dana and is merged by the queue, and
12 * production deploys. Then how it was made: the session Priya's coding
13 * agent recorded onto the pull request through g1t's MCP server. Last, the
14 * people: #web, where Dana reported the bug and hears it shipped.
15 */
16
17export type Scene = "home" | "code" | "session" | "chat";
18
19/** Where the soft cursor points: an element marked `data-tour="<target>"`. */
20export type CursorTarget = "home-summarize" | "home-pr" | "pr-session" | "rail-code" | "rail-chat" | "idle";
21
22export type Frame = {
23 scene: Scene;
24 /** Checks passed on the pull request, 0 to CHECKS.length. */
25 checks: number;
26 /** The diff is shown on the pull request. */
27 diff: boolean;
28 /** Dana approved. */
29 approved: boolean;
30 /** The merge queue merged it. */
31 merged: boolean;
32 /** Entries of the recorded session shown, 0 to SESSION.length. */
33 entries: number;
34 /** Notifications on Home that have settled themselves, 0 to SETTLED.length. */
35 settled: number;
36 /** Settled rows that have left Where you're needed, 0 to SETTLED.length. */
37 cleared: number;
38 /** Home has scrolled to What changed, as Summarize brings it into view. */
39 summarizing: boolean;
40 /** Words of What changed's summary shown, 0 to SUMMARY_WORDS.length. */
41 words: number;
42 /** The summary is finished: Ask a follow-up and Summarize again show. */
43 summarized: boolean;
44 /** Priya posted in #web that it shipped. */
45 shipped: boolean;
46 /** Dana says support has been told, in #web. */
47 told: boolean;
48 cursor: CursorTarget;
49 /** The cursor is pressing. */
50 click: boolean;
51 /** The cursor is shown at all. */
52 cursorShown: boolean;
53};
54
55/** One entry of the session the coding agent recorded onto the pull request. */
56export type SessionEntry = { kind: "prompt" | "message" | "tool"; text: string; tool?: string; commit?: string };
57
58/** The session Priya's coding agent recorded onto #431 as it worked. */
59export const SESSION: SessionEntry[] = [
60 { kind: "prompt", text: "Big accounts time out on CSV export (Dana's report in #web). Find out why and fix it." },
61 { kind: "tool", tool: "read", text: "services/export/csv.py" },
62 { kind: "message", text: "Every row loads before the first byte is sent, so accounts over 100k rows hit the 60 s limit." },
63 { kind: "tool", tool: "bash", text: "pytest tests/test_export.py -k big_account → 3.1 s, was 61 s" },
64 { kind: "message", text: "Rows now stream in batches of 1,000. Opened #431.", commit: "3f9c2a1" },
65];
66
67export const CHECKS = ["build", "test", "lint", "preview"];
68
69/**
70 * The notifications waiting on Priya that settle themselves while she
71 * looks, each with the reason the events service writes when it settles
72 * (services/events/src/inbox.rs).
73 */
74export const SETTLED: { title: string; detail: string; why: string }[] = [
75 { title: "Deploy failed on main", detail: "acme/web main · Production on an older build", why: "Production of web deployed at 3f9c2a1" },
76 { title: "Preview failed on webhook-retries", detail: "acme/web #433 · Run 410", why: "Alex merged #433 after run 411 passed" },
77];
78
79/** What Changed's answer, as g1t streams it onto Home. */
80export const SUMMARY =
81 "Two changes landed in acme/web since you were last here, and both are live. Jordan pinned the build image's cache key, which fixed last night's failed deploy, and Alex merged webhook retries (#433). Your CSV export fix (#431) is up: rows now stream in batches of 1,000, so the largest accounts no longer time out. Dana is reviewing it.";
82
83export const SUMMARY_WORDS = SUMMARY.split(" ");
84
85/** The beats of the story, in ms from the start of the loop. */
86export const BEAT = {
87 home: 0,
88 settle0: 900,
89 settleGap: 650,
90 /** How long a settled row stays, saying why, before it leaves the list. */
91 clearAfter: 1100,
92 summarizeClick: 3600,
93 summarizing: 3750,
94 word0: 4400,
95 wordGap: 48,
96 /** #431 in What changed's answer, pressed once the answer is in. */
97 prClick: 8500,
98 code: 8650,
99 check0: 9550,
100 checkGap: 650,
101 diff: 9950,
102 approved: 12550,
103 merged: 13650,
104 sessionClick: 14950,
105 session: 15100,
106 entry0: 15350,
107 entryGap: 450,
108 railChatClick: 18000,
109 chat: 18150,
110 shipped: 19000,
111 told: 20200,
112 end: 23300,
113} as const;
114
115/** When the summary has finished streaming. */
116export const SUMMARIZED = BEAT.word0 + SUMMARY_WORDS.length * BEAT.wordGap + 200;
117
118/** The whole loop, in ms. */
119export const LOOP_MS = BEAT.end;
120
121/** The step pills under the frame: where each starts, for jumping to it. */
122export const PILLS: { scene: Scene; label: string; start: number }[] = [
123 { scene: "home", label: "Home", start: BEAT.home },
124 { scene: "code", label: "Pull request", start: BEAT.code },
125 { scene: "session", label: "Session", start: BEAT.session },
126 { scene: "chat", label: "Chat", start: BEAT.chat },
127];
128
129/** Which pill a time falls under. */
130export function pillAt(t: number): number {
131 let index = 0;
132 PILLS.forEach((pill, i) => {
133 if (t >= pill.start) index = i;
134 });
135 return index;
136}
137
138/** How far through its pill a time is, 0 to 1. */
139export function pillProgress(t: number): number {
140 const index = pillAt(t);
141 const start = PILLS[index].start;
142 const end = PILLS[index + 1]?.start ?? LOOP_MS;
143 return Math.min(1, Math.max(0, (t - start) / (end - start)));
144}
145
146function count(t: number, first: number, gap: number, max: number): number {
147 if (t < first) return 0;
148 return Math.min(max, Math.floor((t - first) / gap) + 1);
149}
150
151/** Where the cursor is headed, and whether it is pressing, at time t. */
152function cursorAt(t: number): { cursor: CursorTarget; click: boolean } {
153 const press = (at: number) => t >= at && t < at + 180;
154 const moves: [number, CursorTarget][] = [
155 [BEAT.summarizeClick - 900, "home-summarize"],
156 [BEAT.summarizing + 300, "idle"],
157 [SUMMARIZED + 300, "home-pr"],
158 [BEAT.code + 400, "idle"],
159 [BEAT.merged + 300, "pr-session"],
160 [BEAT.railChatClick - 900, "rail-chat"],
161 [BEAT.chat + 500, "idle"],
162 ];
163 let cursor: CursorTarget = "idle";
164 for (const [at, target] of moves) if (t >= at) cursor = target;
165 const click = press(BEAT.summarizeClick) || press(BEAT.prClick) || press(BEAT.sessionClick) || press(BEAT.railChatClick);
166 return { cursor, click };
167}
168
169/** The frame at time t (ms into the loop). */
170export function frameAt(time: number): Frame {
171 const t = ((time % LOOP_MS) + LOOP_MS) % LOOP_MS;
172 const scene: Scene = t >= BEAT.chat ? "chat" : t >= BEAT.session ? "session" : t >= BEAT.code ? "code" : "home";
173 const { cursor, click } = cursorAt(t);
174 return {
175 scene,
176 checks: count(t, BEAT.check0, BEAT.checkGap, CHECKS.length),
177 diff: t >= BEAT.diff,
178 approved: t >= BEAT.approved,
179 merged: t >= BEAT.merged,
180 // The session was recorded before the story opens; the tour plays it back once its tab is opened.
181 entries: t < BEAT.session ? 0 : count(t, BEAT.entry0, BEAT.entryGap, SESSION.length),
182 settled: count(t, BEAT.settle0, BEAT.settleGap, SETTLED.length),
183 cleared: count(t, BEAT.settle0 + BEAT.clearAfter, BEAT.settleGap, SETTLED.length),
184 summarizing: t >= BEAT.summarizing,
185 words: count(t, BEAT.word0, BEAT.wordGap, SUMMARY_WORDS.length),
186 summarized: t >= SUMMARIZED,
187 shipped: t >= BEAT.shipped,
188 told: t >= BEAT.told,
189 cursor,
190 click,
191 // Out of the way while the pull request's checks run and it merges.
192 cursorShown: (t >= BEAT.summarizeClick - 1500 && t < BEAT.code + 600) || (t >= BEAT.merged && t < BEAT.end - 600),
193 };
194}
195
196/** A still frame for each pill, for reduced motion: the moment the step makes its point. */
197export const STILLS: Record<Scene, number> = {
198 home: SUMMARIZED + 400,
199 code: BEAT.approved + 200,
200 session: BEAT.railChatClick - 400,
201 chat: BEAT.told + 400,
202};
203
204/** Whether two frames would draw the same. */
205export function sameFrame(a: Frame, b: Frame): boolean {
206 for (const key of Object.keys(a) as (keyof Frame)[]) if (a[key] !== b[key]) return false;
207 return true;
208}