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.

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