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 | ||
| 14 | export type Scene = "chat" | "agents" | "code" | "docs"; | |
| 15 | ||
| 16 | /** Where the soft cursor points: an element marked `data-tour="<target>"`. */ | |
| 17 | export type CursorTarget = | |
| 18 | | "composer" | |
| 19 | | "send" | |
| 20 | | "rail-agents" | |
| 21 | | "rail-code" | |
| 22 | | "rail-chat" | |
| 23 | | "rail-docs" | |
| 24 | | "task-pull" | |
| 25 | | "idle"; | |
| 26 | ||
| 27 | export 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. */ | |
| 65 | export const ASK = "@g1t the CSV export times out for big accounts. Can we get it fixed before Thursday?"; | |
| 66 | ||
| 67 | export 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 | ||
| 75 | export const CHECKS = ["build", "test", "lint", "preview"]; | |
| 76 | ||
| 77 | /** When the person starts typing, and how long each character takes, in ms. */ | |
| 78 | const 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 | */ | |
| 85 | function 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 | ||
| 102 | const TYPED_AT = typingTimes(ASK); | |
| 103 | const TYPED_END = TYPED_AT[TYPED_AT.length - 1]; | |
| 104 | ||
| 105 | /** The beats of the story, in ms from the start of the loop. */ | |
| 106 | export 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. */ | |
| 136 | export const LOOP_MS = BEAT.end; | |
| 137 | ||
| 138 | /** The step pills under the frame: where each starts, for jumping to it. */ | |
| 139 | export 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. */ | |
| 147 | export 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. */ | |
| 156 | export 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 | ||
| 163 | function 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. */ | |
| 169 | function 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). */ | |
| 193 | export 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. */ | |
| 224 | export 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. */ | |
| 232 | export 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.