Skip to content
235 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)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: a person asks g1t in #web to fix the CSV export; g1t hands it
8 * to @otto and the review to @margo; Otto's desk works it, consulting
9 * Margo on the way; the pull request goes green and
10 * merges; g1t says it shipped and Izzy tells #support; Inky updates the
11 * docs page.
12 */
13
14export type Scene = "chat" | "agents" | "code" | "docs";
15
16/** Where the soft cursor points: an element marked `data-tour="<target>"`. */
17export type CursorTarget =
18 | "composer"
19 | "send"
20 | "rail-agents"
21 | "rail-code"
22 | "rail-chat"
23 | "rail-docs"
24 | "task-pull"
25 | "idle";
26
27export type Frame = {
28 scene: Scene;
29 /** Characters of the person's message typed into the composer. */
30 typed: number;
31 /** The person's message has been sent. */
32 sent: boolean;
33 /** g1t shows as typing. */
34 g1tTyping: boolean;
35 /** g1t's handoff reply, with the task card. */
36 handoff: boolean;
37 /** Builder's steps done, 0 to STEPS.length. */
38 steps: number;
39 /** Checks passed on the pull request, 0 to CHECKS.length. */
40 checks: number;
41 /** The diff is shown on the pull request. */
42 diff: boolean;
43 /** @reviewer approved. */
44 approved: boolean;
45 /** The merge queue merged it. */
46 merged: boolean;
47 /** The task card in #web says merged and deploying. */
48 cardMerged: boolean;
49 /** g1t posted that it shipped. */
50 shipped: boolean;
51 /** Izzy says she told #support. */
52 izzy: boolean;
53 /** Otto consulted Margo, shown as a collapsed line on the task. */
54 consult: boolean;
55 /** The docs page shows the changed line. */
56 docUpdated: boolean;
57 cursor: CursorTarget;
58 /** The cursor is pressing. */
59 click: boolean;
60 /** The cursor is shown at all. */
61 cursorShown: boolean;
62};
63
64/** What the person types into #web. */
65export const ASK = "@g1t the CSV export times out for big accounts. Can we get it fixed before Thursday?";
66
67export const STEPS: { text: string; cost: number }[] = [
68 { text: "Read the export job and the two reports in #web", cost: 0.04 },
69 { text: "Found it: every row loads before the first byte is sent", cost: 0.11 },
70 { text: "Streamed rows in batches of 1,000", cost: 0.19 },
71 { text: "Tested a 200,000-row account: 3.1 s, was 61 s", cost: 0.27 },
72 { text: "Opened pull request #431", cost: 0.31 },
73];
74
75export const CHECKS = ["build", "test", "lint", "preview"];
76
77/** When the person starts typing, and how long each character takes, in ms. */
78const TYPE_START = 1500;
79
80/**
81 * A human typing pace: about 45 ms a character, a little longer after a
82 * space, and a short hesitation after punctuation. Seeded, so every loop
83 * and every render types the same way.
84 */
85function typingTimes(text: string): number[] {
86 let seed = 7;
87 const random = () => {
88 seed = (seed * 16807) % 2147483647;
89 return (seed - 1) / 2147483646;
90 };
91 const times: number[] = [];
92 let at = TYPE_START;
93 for (const char of text) {
94 at += 32 + random() * 34;
95 if (char === " ") at += random() * 30;
96 times.push(Math.round(at));
97 if (char === "." || char === "?" || char === ",") at += 220 + random() * 160;
98 }
99 return times;
100}
101
102const TYPED_AT = typingTimes(ASK);
103const TYPED_END = TYPED_AT[TYPED_AT.length - 1];
104
105/** The beats of the story, in ms from the start of the loop. */
106export const BEAT = {
107 composerClick: 1100,
108 typed: TYPED_END,
109 sendClick: TYPED_END + 700,
110 sent: TYPED_END + 800,
111 g1tTyping: TYPED_END + 1300,
112 handoff: TYPED_END + 2500,
113 railAgentsClick: TYPED_END + 4300,
114 agents: TYPED_END + 4450,
115 step0: TYPED_END + 5400,
116 stepGap: 1150,
117 pullClick: TYPED_END + 11900,
118 code: TYPED_END + 12050,
119 check0: TYPED_END + 12900,
120 checkGap: 650,
121 diff: TYPED_END + 13300,
122 approved: TYPED_END + 15900,
123 merged: TYPED_END + 17000,
124 railChatClick: TYPED_END + 18300,
125 chatAgain: TYPED_END + 18450,
126 cardMerged: TYPED_END + 19100,
127 shipped: TYPED_END + 20400,
128 izzy: TYPED_END + 21600,
129 railDocsClick: TYPED_END + 23000,
130 docs: TYPED_END + 23150,
131 docUpdated: TYPED_END + 24300,
132 end: TYPED_END + 29000,
133} as const;
134
135/** The whole loop, in ms. */
136export const LOOP_MS = BEAT.end;
137
138/** The step pills under the frame: where each starts, for jumping to it. */
139export const PILLS: { scene: Scene; label: string; start: number; soon: boolean }[] = [
140 { scene: "chat", label: "Chat", start: 0, soon: false },
141 { scene: "agents", label: "Agents", start: BEAT.agents, soon: true },
142 { scene: "code", label: "Code", start: BEAT.code, soon: false },
143 { scene: "docs", label: "Docs", start: BEAT.docs, soon: true },
144];
145
146/** Which pill a time falls under. Back in chat after the merge is still Code's story. */
147export function pillAt(t: number): number {
148 let index = 0;
149 PILLS.forEach((pill, i) => {
150 if (t >= pill.start) index = i;
151 });
152 return index;
153}
154
155/** How far through its pill a time is, 0 to 1. */
156export function pillProgress(t: number): number {
157 const index = pillAt(t);
158 const start = PILLS[index].start;
159 const end = PILLS[index + 1]?.start ?? LOOP_MS;
160 return Math.min(1, Math.max(0, (t - start) / (end - start)));
161}
162
163function count(t: number, first: number, gap: number, max: number): number {
164 if (t < first) return 0;
165 return Math.min(max, Math.floor((t - first) / gap) + 1);
166}
167
168/** Where the cursor is headed, and whether it is pressing, at time t. */
169function cursorAt(t: number): { cursor: CursorTarget; click: boolean } {
170 const press = (at: number) => t >= at && t < at + 180;
171 const moves: [number, CursorTarget][] = [
172 [300, "composer"],
173 [TYPED_END + 150, "send"],
174 [BEAT.handoff + 700, "rail-agents"],
175 [BEAT.step0 + 4 * BEAT.stepGap + 500, "task-pull"],
176 [BEAT.merged + 300, "rail-chat"],
177 [BEAT.izzy + 500, "rail-docs"],
178 [BEAT.docs + 500, "idle"],
179 ];
180 let cursor: CursorTarget = "idle";
181 for (const [at, target] of moves) if (t >= at) cursor = target;
182 const click =
183 press(BEAT.composerClick) ||
184 press(BEAT.sendClick) ||
185 press(BEAT.railAgentsClick) ||
186 press(BEAT.pullClick) ||
187 press(BEAT.railChatClick) ||
188 press(BEAT.railDocsClick);
189 return { cursor, click };
190}
191
192/** The frame at time t (ms into the loop). */
193export function frameAt(time: number): Frame {
194 const t = ((time % LOOP_MS) + LOOP_MS) % LOOP_MS;
195 const scene: Scene =
196 t >= BEAT.docs ? "docs" : t >= BEAT.chatAgain ? "chat" : t >= BEAT.code ? "code" : t >= BEAT.agents ? "agents" : "chat";
197 let typed = 0;
198 while (typed < TYPED_AT.length && TYPED_AT[typed] <= t) typed++;
199 const sent = t >= BEAT.sent;
200 const { cursor, click } = cursorAt(t);
201 return {
202 scene,
203 typed: sent ? 0 : typed,
204 sent,
205 g1tTyping: t >= BEAT.g1tTyping && t < BEAT.handoff,
206 handoff: t >= BEAT.handoff,
207 steps: count(t, BEAT.step0, BEAT.stepGap, STEPS.length),
208 checks: count(t, BEAT.check0, BEAT.checkGap, CHECKS.length),
209 diff: t >= BEAT.diff,
210 approved: t >= BEAT.approved,
211 merged: t >= BEAT.merged,
212 cardMerged: t >= BEAT.cardMerged,
213 shipped: t >= BEAT.shipped,
214 izzy: t >= BEAT.izzy,
215 consult: t >= BEAT.step0 + 2 * BEAT.stepGap + 500,
216 docUpdated: t >= BEAT.docUpdated,
217 cursor,
218 click,
219 cursorShown: t >= 200 && t < BEAT.end - 600,
220 };
221}
222
223/** A still frame for each pill, for reduced motion: the moment the step makes its point. */
224export const STILLS: Record<Scene, number> = {
225 chat: BEAT.handoff + 400,
226 agents: BEAT.step0 + 4 * BEAT.stepGap + 200,
227 code: BEAT.approved + 200,
228 docs: BEAT.docUpdated + 400,
229};
230
231/** Whether two frames would draw the same. */
232export function sameFrame(a: Frame, b: Frame): boolean {
233 for (const key of Object.keys(a) as (keyof Frame)[]) if (a[key] !== b[key]) return false;
234 return true;
235}

This file's history is long; its oldest lines are credited to the oldest commit read.