The home page leads with Code: the tour opens on the pull request, and the forge comes before Chat
The tour now opens on Otto's pull request #431 running its checks, being approved and merged by the queue; then #web, where it was asked for and where it shipped; then Otto's desk, the record of what he did; then the docs page. Pills, stills and the caption follow. The desk shows Shipped once the pull request has merged. The four modes start with Code, and the Code and forge sections come before Chat.
4 files+198−2490/4 viewed
| 163 | 163 | /** The four modes of a workspace. */ | |
| 164 | 164 | const MODES: { icon: ReactNode; name: string; about: string; soon?: boolean; to: string }[] = [ | |
| 165 | 165 | { | |
| 166 | + | icon: <Code2 size={18} />, | |
| 167 | + | name: "Code", | |
| 168 | + | about: "Repositories, issues, pull requests, checks, a merge queue and deployments. For the people who build.", | |
| 169 | + | to: `${DOCS}/concepts/overview/`, | |
| 170 | + | }, | |
| 171 | + | { | |
| 166 | 172 | icon: <MessagesSquare size={18} />, | |
| 167 | 173 | name: "Chat", | |
| 168 | 174 | about: "Channels, direct messages and threads, live. People and agents are members alike.", | |
| ⋯ | |||
| 181 | 187 | soon: true, | |
| 182 | 188 | to: `${DOCS}/guides/docs/`, | |
| 183 | 189 | }, | |
| 184 | − | { | |
| 185 | − | icon: <Code2 size={18} />, | |
| 186 | − | name: "Code", | |
| 187 | − | about: "Repositories, issues, pull requests, checks, a merge queue and deployments. For the people who build.", | |
| 188 | − | to: `${DOCS}/concepts/overview/`, | |
| 189 | − | }, | |
| 190 | 190 | ]; | |
| 191 | 191 | ||
| 192 | 192 | /** Who in a company talks to the agents, and what they ask. */ | |
| ⋯ | |||
| 433 | 433 | </div> | |
| 434 | 434 | </section> | |
| 435 | 435 | ||
| 436 | − | {/* Chat first. */} | |
| 437 | − | <section className="mx-auto grid max-w-6xl items-start gap-12 px-4 pt-24 lg:grid-cols-[1.1fr_1fr] lg:gap-16"> | |
| 436 | + | {/* Code. */} | |
| 437 | + | <section className="mx-auto max-w-6xl px-4 pt-24"> | |
| 438 | + | <div className="max-w-3xl"> | |
| 439 | + | <Eyebrow>Code</Eyebrow> | |
| 440 | + | <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-5xl"> | |
| 441 | + | A forge built for many agents at once. | |
| 442 | + | </h2> | |
| 443 | + | <p className="mt-5 max-w-2xl text-lg leading-8 text-muted"> | |
| 444 | + | Underneath the conversation is plain git. Every change an agent makes is a pull request in its own fork, | |
| 445 | + | checked by g1t, reviewed, and landed through a queue that keeps main passing. | |
| 446 | + | </p> | |
| 447 | + | </div> | |
| 448 | + | ||
| 449 | + | <div className="mt-6 divide-y divide-line"> | |
| 450 | + | <Pillar | |
| 451 | + | eyebrow="Outcomes" | |
| 452 | + | title="Hand off an outcome, not just a task" | |
| 453 | + | art={<PlanArt className="w-full" />} | |
| 454 | + | points={[ | |
| 455 | + | "A brief planned into issues with dependencies", | |
| 456 | + | "Your workflows' required checks on every change", | |
| 457 | + | "Work starts as each dependency lands", | |
| 458 | + | "The plan as a live graph, with its cost", | |
| 459 | + | ]} | |
| 460 | + | more={["Hand off an outcome", `${DOCS}/guides/outcomes/`]} | |
| 461 | + | > | |
| 462 | + | Write what should be true. A planner turns it into issues, each saying what done looks like, and the order | |
| 463 | + | they depend on. Agents take each issue as it unblocks, and you watch the whole outcome converge. | |
| 464 | + | </Pillar> | |
| 465 | + | ||
| 466 | + | <Pillar | |
| 467 | + | flip | |
| 468 | + | eyebrow="Ship safely" | |
| 469 | + | title="Main only moves to what passed" | |
| 470 | + | art={<QueueArt className="w-full" />} | |
| 471 | + | points={[ | |
| 472 | + | "Checks run by g1t in a clean sandbox", | |
| 473 | + | "Workflows from .g1t/workflows", | |
| 474 | + | "A merge queue that tests changes together", | |
| 475 | + | "Conflicts found on every push, before a merge", | |
| 476 | + | "Catch up with main in seconds", | |
| 477 | + | "Reviews by people and by agents", | |
| 478 | + | ]} | |
| 479 | + | more={["The merge queue", `${DOCS}/guides/merge-queue/`]} | |
| 480 | + | > | |
| 481 | + | Checks are run by g1t, never by the agent being checked. The queue tests each change together with what | |
| 482 | + | lands ahead of it; one that breaks goes back to its author with what failed, and main never sees it. | |
| 483 | + | </Pillar> | |
| 484 | + | ||
| 485 | + | <Pillar | |
| 486 | + | eyebrow="Context" | |
| 487 | + | title="Every line knows why it is there" | |
| 488 | + | art={<WhyArt className="w-full" />} | |
| 489 | + | points={[ | |
| 490 | + | "Sessions recorded onto pull requests", | |
| 491 | + | "Why-blame on any line", | |
| 492 | + | "A context hub agents search before they start", | |
| 493 | + | "Search across code, issues and people", | |
| 494 | + | ]} | |
| 495 | + | more={["Sessions and why-blame", `${DOCS}/guides/why-blame/`]} | |
| 496 | + | > | |
| 497 | + | Pick any line. g1t shows the commit that changed it, the pull request and issue it came from, and the | |
| 498 | + | agent's own session: what it read, ran and decided. The reasoning stays with the code, for people and | |
| 499 | + | for the next agent. | |
| 500 | + | </Pillar> | |
| 501 | + | ||
| 502 | + | <Pillar | |
| 503 | + | flip | |
| 504 | + | eyebrow="Run it" | |
| 505 | + | title="Every change, live on the edge" | |
| 506 | + | art={<DeployArt className="w-full" />} | |
| 507 | + | points={[ | |
| 508 | + | "A live preview for every pull request", | |
| 509 | + | "Production on every merge to main", | |
| 510 | + | "Custom domains, with certificates", | |
| 511 | + | "Projects that depend on each other", | |
| 512 | + | ]} | |
| 513 | + | more={["Deployments", `${DOCS}/guides/deployments/`]} | |
| 514 | + | > | |
| 515 | + | Turn on deployments and every pull request gets its own address on g1t.page; merging ships production. | |
| 516 | + | Reviewers, and the agents reviewing for you, click through a change instead of reading a diff. An app nobody | |
| 517 | + | visits runs nothing and costs nothing. | |
| 518 | + | </Pillar> | |
| 519 | + | </div> | |
| 520 | + | </section> | |
| 521 | + | ||
| 522 | + | {/* The forge, for people. */} | |
| 523 | + | <section className="mx-auto max-w-6xl px-4 py-24"> | |
| 524 | + | <div className="grid gap-10 lg:grid-cols-[1fr_1.2fr] lg:gap-16"> | |
| 525 | + | <div> | |
| 526 | + | <Eyebrow>Collaborate</Eyebrow> | |
| 527 | + | <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance"> | |
| 528 | + | A complete forge for the people who build | |
| 529 | + | </h2> | |
| 530 | + | <p className="mt-4 max-w-md leading-7 text-muted"> | |
| 531 | + | Issues, branches, pull requests and reviews, the way your team already works, with agents as members | |
| 532 | + | alongside you. Work by hand, hand work off, or both on the same issue. | |
| 533 | + | </p> | |
| 534 | + | <More to={`${DOCS}/concepts/overview/`}>How g1t works</More> | |
| 535 | + | </div> | |
| 536 | + | <div className="rounded-3xl bg-surface p-7 ring-1 ring-line"> | |
| 537 | + | <p className="text-sm font-medium">Included in every workspace</p> | |
| 538 | + | <Tags items={FORGE} /> | |
| 539 | + | </div> | |
| 540 | + | </div> | |
| 541 | + | </section> | |
| 542 | + | ||
| 543 | + | {/* Chat, where work is asked for. */} | |
| 544 | + | <section className="mx-auto grid max-w-6xl items-start gap-12 px-4 lg:grid-cols-[1.1fr_1fr] lg:gap-16"> | |
| 438 | 545 | <div> | |
| 439 | 546 | <Eyebrow>Chat</Eyebrow> | |
| 440 | 547 | <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-4xl"> | |
| ⋯ | |||
| 737 | 844 | </div> | |
| 738 | 845 | </section> | |
| 739 | 846 | ||
| 740 | − | {/* Code. */} | |
| 741 | − | <section className="mx-auto max-w-6xl px-4 pt-24"> | |
| 742 | − | <div className="max-w-3xl"> | |
| 743 | − | <Eyebrow>Code</Eyebrow> | |
| 744 | − | <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance sm:text-5xl"> | |
| 745 | − | A forge built for many agents at once. | |
| 746 | − | </h2> | |
| 747 | − | <p className="mt-5 max-w-2xl text-lg leading-8 text-muted"> | |
| 748 | − | Underneath the conversation is plain git. Every change an agent makes is a pull request in its own fork, | |
| 749 | − | checked by g1t, reviewed, and landed through a queue that keeps main passing. | |
| 750 | − | </p> | |
| 751 | − | </div> | |
| 752 | − | ||
| 753 | − | <div className="mt-6 divide-y divide-line"> | |
| 754 | − | <Pillar | |
| 755 | − | eyebrow="Outcomes" | |
| 756 | − | title="Hand off an outcome, not just a task" | |
| 757 | − | art={<PlanArt className="w-full" />} | |
| 758 | − | points={[ | |
| 759 | − | "A brief planned into issues with dependencies", | |
| 760 | − | "Your workflows' required checks on every change", | |
| 761 | − | "Work starts as each dependency lands", | |
| 762 | − | "The plan as a live graph, with its cost", | |
| 763 | − | ]} | |
| 764 | − | more={["Hand off an outcome", `${DOCS}/guides/outcomes/`]} | |
| 765 | − | > | |
| 766 | − | Write what should be true. A planner turns it into issues, each saying what done looks like, and the order | |
| 767 | − | they depend on. Agents take each issue as it unblocks, and you watch the whole outcome converge. | |
| 768 | − | </Pillar> | |
| 769 | − | ||
| 770 | − | <Pillar | |
| 771 | − | flip | |
| 772 | − | eyebrow="Ship safely" | |
| 773 | − | title="Main only moves to what passed" | |
| 774 | − | art={<QueueArt className="w-full" />} | |
| 775 | − | points={[ | |
| 776 | − | "Checks run by g1t in a clean sandbox", | |
| 777 | − | "Workflows from .g1t/workflows", | |
| 778 | − | "A merge queue that tests changes together", | |
| 779 | − | "Conflicts found on every push, before a merge", | |
| 780 | − | "Catch up with main in seconds", | |
| 781 | − | "Reviews by people and by agents", | |
| 782 | − | ]} | |
| 783 | − | more={["The merge queue", `${DOCS}/guides/merge-queue/`]} | |
| 784 | − | > | |
| 785 | − | Checks are run by g1t, never by the agent being checked. The queue tests each change together with what | |
| 786 | − | lands ahead of it; one that breaks goes back to its author with what failed, and main never sees it. | |
| 787 | − | </Pillar> | |
| 788 | − | ||
| 789 | − | <Pillar | |
| 790 | − | eyebrow="Context" | |
| 791 | − | title="Every line knows why it is there" | |
| 792 | − | art={<WhyArt className="w-full" />} | |
| 793 | − | points={[ | |
| 794 | − | "Sessions recorded onto pull requests", | |
| 795 | − | "Why-blame on any line", | |
| 796 | − | "A context hub agents search before they start", | |
| 797 | − | "Search across code, issues and people", | |
| 798 | − | ]} | |
| 799 | − | more={["Sessions and why-blame", `${DOCS}/guides/why-blame/`]} | |
| 800 | − | > | |
| 801 | − | Pick any line. g1t shows the commit that changed it, the pull request and issue it came from, and the | |
| 802 | − | agent's own session: what it read, ran and decided. The reasoning stays with the code, for people and | |
| 803 | − | for the next agent. | |
| 804 | − | </Pillar> | |
| 805 | − | ||
| 806 | − | <Pillar | |
| 807 | − | flip | |
| 808 | − | eyebrow="Run it" | |
| 809 | − | title="Every change, live on the edge" | |
| 810 | − | art={<DeployArt className="w-full" />} | |
| 811 | − | points={[ | |
| 812 | − | "A live preview for every pull request", | |
| 813 | − | "Production on every merge to main", | |
| 814 | − | "Custom domains, with certificates", | |
| 815 | − | "Projects that depend on each other", | |
| 816 | − | ]} | |
| 817 | − | more={["Deployments", `${DOCS}/guides/deployments/`]} | |
| 818 | − | > | |
| 819 | − | Turn on deployments and every pull request gets its own address on g1t.page; merging ships production. | |
| 820 | − | Reviewers, and the agents reviewing for you, click through a change instead of reading a diff. An app nobody | |
| 821 | − | visits runs nothing and costs nothing. | |
| 822 | − | </Pillar> | |
| 823 | − | </div> | |
| 824 | − | </section> | |
| 825 | − | ||
| 826 | − | {/* The forge, for people. */} | |
| 847 | + | {/* Secure by default */} | |
| 827 | 848 | <section className="mx-auto max-w-6xl px-4 py-24"> | |
| 828 | − | <div className="grid gap-10 lg:grid-cols-[1fr_1.2fr] lg:gap-16"> | |
| 829 | − | <div> | |
| 830 | − | <Eyebrow>Collaborate</Eyebrow> | |
| 831 | − | <h2 className="mt-3 text-3xl font-semibold tracking-tight text-balance"> | |
| 832 | − | A complete forge for the people who build | |
| 833 | − | </h2> | |
| 834 | − | <p className="mt-4 max-w-md leading-7 text-muted"> | |
| 835 | − | Issues, branches, pull requests and reviews, the way your team already works, with agents as members | |
| 836 | − | alongside you. Work by hand, hand work off, or both on the same issue. | |
| 837 | − | </p> | |
| 838 | − | <More to={`${DOCS}/concepts/overview/`}>How g1t works</More> | |
| 839 | − | </div> | |
| 840 | − | <div className="rounded-3xl bg-surface p-7 ring-1 ring-line"> | |
| 841 | − | <p className="text-sm font-medium">Included in every workspace</p> | |
| 842 | − | <Tags items={FORGE} /> | |
| 843 | − | </div> | |
| 844 | − | </div> | |
| 845 | − | </section> | |
| 846 | − | ||
| 847 | − | {/* Secure by default */} | |
| 848 | − | <section className="mx-auto max-w-6xl px-4 pb-24"> | |
| 849 | 849 | <Eyebrow>Secure by default</Eyebrow> | |
| 850 | 850 | <h2 className="mt-3 max-w-2xl text-3xl font-semibold tracking-tight text-balance"> | |
| 851 | 851 | Safe to hand the work to, and healthy without anyone watching | |
| 158 | 158 | ||
| 159 | 159 | /** The pill a frame belongs to, from what it shows (the clock's time is not React state). */ | |
| 160 | 160 | function pillOf(frame: Frame): number { | |
| 161 | − | if (frame.scene === "docs") return 3; | |
| 162 | − | if (frame.scene === "code" || frame.merged) return 2; | |
| 163 | − | if (frame.scene === "agents") return 1; | |
| 164 | − | return 0; | |
| 161 | + | return Math.max(0, PILLS.findIndex((pill) => pill.scene === frame.scene)); | |
| 165 | 162 | } | |
| 166 | 163 | ||
| 167 | 164 | /** What the frame's top bar says about the step: shipped today, or a preview. */ | |
| ⋯ | |||
| 628 | 625 | </p> | |
| 629 | 626 | <p className="text-xs text-muted">@otto · Software Engineer, Engineering</p> | |
| 630 | 627 | </div> | |
| 631 | − | <span className="ml-auto flex items-center gap-1.5 rounded-full bg-warn/10 px-2.5 py-1 text-[11px] text-warn"> | |
| 632 | − | <span className="tour-pulse size-1.5 rounded-full bg-warn" /> | |
| 633 | − | Working | |
| 634 | − | </span> | |
| 628 | + | {frame.merged ? ( | |
| 629 | + | <span className="ml-auto flex items-center gap-1.5 rounded-full bg-merged/10 px-2.5 py-1 text-[11px] text-merged"> | |
| 630 | + | <GitMerge size={11} /> | |
| 631 | + | Shipped #431 | |
| 632 | + | </span> | |
| 633 | + | ) : ( | |
| 634 | + | <span className="ml-auto flex items-center gap-1.5 rounded-full bg-warn/10 px-2.5 py-1 text-[11px] text-warn"> | |
| 635 | + | <span className="tour-pulse size-1.5 rounded-full bg-warn" /> | |
| 636 | + | Working | |
| 637 | + | </span> | |
| 638 | + | )} | |
| 635 | 639 | </div> | |
| 636 | 640 | <div className="flex gap-5 border-b border-line px-5 text-[12px]"> | |
| 637 | 641 | {["Desk", "Profile", "Spend", "Activity"].map((tab) => ( | |
| ⋯ | |||
| 1019 | 1023 | observer.observe(element); | |
| 1020 | 1024 | return () => observer.disconnect(); | |
| 1021 | 1025 | }, []); | |
| 1022 | − | const scenes: Scene[] = ["chat", "agents", "code", "docs"]; | |
| 1026 | + | const scenes: Scene[] = ["code", "chat", "agents", "docs"]; | |
| 1023 | 1027 | const mains: Record<Scene, ReactNode> = { | |
| 1024 | 1028 | chat: <ChatMain frame={frame} />, | |
| 1025 | 1029 | agents: <AgentsMain frame={frame} />, | |
| ⋯ | |||
| 1075 | 1079 | ||
| 1076 | 1080 | function Phone({ frame }: { frame: Frame }) { | |
| 1077 | 1081 | const note = honesty(frame); | |
| 1078 | − | const scenes: Scene[] = ["chat", "agents", "code", "docs"]; | |
| 1082 | + | const scenes: Scene[] = ["code", "chat", "agents", "docs"]; | |
| 1079 | 1083 | return ( | |
| 1080 | 1084 | <div className="relative h-[34rem] w-full overflow-hidden rounded-2xl bg-surface text-left shadow-2xl shadow-black/50 ring-1 ring-line"> | |
| 1081 | 1085 | <div className="flex h-12 items-center gap-2 border-b border-line px-3"> | |
| ⋯ | |||
| 1194 | 1198 | <p className="flex items-center gap-2 text-sm font-semibold text-fg"> | |
| 1195 | 1199 | Otto <AgentTag /> | |
| 1196 | 1200 | </p> | |
| 1197 | − | <p className="text-[11px] text-muted">Working · ${cost.toFixed(2)} of $5</p> | |
| 1201 | + | <p className="text-[11px] text-muted">{frame.merged ? "Shipped #431" : "Working"} · ${cost.toFixed(2)} of $5</p> | |
| 1198 | 1202 | </div> | |
| 1199 | 1203 | </div> | |
| 1200 | 1204 | <div className="rounded-xl bg-bg ring-1 ring-line"> | |
| ⋯ | |||
| 1382 | 1386 | })} | |
| 1383 | 1387 | </div> | |
| 1384 | 1388 | <p className="mx-auto mt-3 max-w-xl text-center text-xs leading-5 text-faint text-balance"> | |
| 1385 | − | A preview of where g1t is going. Chat, agents you DM, pull requests, checks and the merge queue work today. | |
| 1389 | + | A preview of where g1t is going. Pull requests, checks, the merge queue, chat and agents you DM work today. | |
| 1386 | 1390 | Handoffs and consults between agents, live desks and Docs are coming soon. | |
| 1387 | 1391 | </p> | |
| 1388 | 1392 | </div> | |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { ASK, BEAT, CHECKS, LOOP_MS, PILLS, STEPS, STILLS, frameAt, pillAt, pillProgress, sameFrame } from "./tour.ts"; | |
| 4 | + | import { BEAT, CHECKS, LOOP_MS, PILLS, STEPS, STILLS, frameAt, pillAt, pillProgress, sameFrame } from "./tour.ts"; | |
| 5 | 5 | ||
| 6 | − | test("the first frame is the empty channel, the same on the server and the client", () => { | |
| 6 | + | test("the first frame is the pull request before its checks, the same on the server and the client", () => { | |
| 7 | 7 | const frame = frameAt(0); | |
| 8 | − | assert.equal(frame.scene, "chat"); | |
| 9 | − | assert.equal(frame.typed, 0); | |
| 10 | − | assert.equal(frame.sent, false); | |
| 8 | + | assert.equal(frame.scene, "code"); | |
| 9 | + | assert.equal(frame.checks, 0); | |
| 10 | + | assert.equal(frame.merged, false); | |
| 11 | 11 | assert.equal(frame.cursorShown, false); | |
| 12 | 12 | assert.ok(sameFrame(frame, frameAt(0))); | |
| 13 | 13 | }); | |
| 14 | 14 | ||
| 15 | − | test("the message is typed in full before it is sent, then the composer empties", () => { | |
| 16 | − | assert.equal(frameAt(BEAT.typed).typed, ASK.length); | |
| 17 | − | assert.equal(frameAt(BEAT.typed - 1).typed, ASK.length - 1); | |
| 18 | − | const sent = frameAt(BEAT.sent); | |
| 19 | − | assert.equal(sent.sent, true); | |
| 20 | − | assert.equal(sent.typed, 0); | |
| 21 | − | }); | |
| 22 | − | ||
| 23 | − | test("typing only ever moves forward", () => { | |
| 24 | − | let last = 0; | |
| 25 | − | for (let t = 0; t <= BEAT.typed; t += 17) { | |
| 26 | − | const typed = frameAt(t).typed; | |
| 27 | − | assert.ok(typed >= last); | |
| 28 | − | last = typed; | |
| 29 | − | } | |
| 30 | − | }); | |
| 31 | − | ||
| 32 | − | test("the scenes come in the story's order", () => { | |
| 15 | + | test("the scenes come in the story's order: Code first", () => { | |
| 33 | 16 | const order: string[] = []; | |
| 34 | 17 | for (let t = 0; t < LOOP_MS; t += 100) { | |
| 35 | 18 | const scene = frameAt(t).scene; | |
| 36 | 19 | if (order[order.length - 1] !== scene) order.push(scene); | |
| 37 | 20 | } | |
| 38 | − | assert.deepEqual(order, ["chat", "agents", "code", "chat", "docs"]); | |
| 21 | + | assert.deepEqual(order, ["code", "chat", "agents", "docs"]); | |
| 39 | 22 | }); | |
| 40 | 23 | ||
| 41 | − | test("steps and checks finish before the cursor leaves them", () => { | |
| 42 | − | assert.equal(frameAt(BEAT.pullClick).steps, STEPS.length); | |
| 24 | + | test("checks pass before review, and the queue merges only after approval", () => { | |
| 43 | 25 | assert.equal(frameAt(BEAT.approved).checks, CHECKS.length); | |
| 44 | − | assert.equal(frameAt(BEAT.pullClick).cursor, "task-pull"); | |
| 26 | + | assert.equal(frameAt(BEAT.merged - 1).merged, false); | |
| 27 | + | assert.equal(frameAt(BEAT.merged).approved, true); | |
| 28 | + | }); | |
| 29 | + | ||
| 30 | + | test("the cursor leaves the code only once it has merged", () => { | |
| 31 | + | assert.equal(frameAt(BEAT.merged - 1).cursorShown, false); | |
| 32 | + | assert.equal(frameAt(BEAT.railChatClick).cursor, "rail-chat"); | |
| 33 | + | assert.equal(frameAt(BEAT.railChatClick).click, true); | |
| 34 | + | }); | |
| 35 | + | ||
| 36 | + | test("Otto's work is done before the story opens", () => { | |
| 37 | + | for (const t of [0, BEAT.chat, BEAT.agents]) assert.equal(frameAt(t).steps, STEPS.length); | |
| 45 | 38 | }); | |
| 46 | 39 | ||
| 47 | 40 | test("the loop wraps", () => { | |
| ⋯ | |||
| 50 | 43 | }); | |
| 51 | 44 | ||
| 52 | 45 | test("pills start where their scene does, and progress runs 0 to 1", () => { | |
| 46 | + | assert.equal(PILLS[0].scene, "code"); | |
| 53 | 47 | PILLS.forEach((pill, index) => { | |
| 54 | 48 | assert.equal(frameAt(pill.start + 1).scene, pill.scene); | |
| 55 | 49 | assert.equal(pillAt(pill.start + 1), index); | |
| 56 | 50 | }); | |
| 57 | − | assert.equal(pillAt(BEAT.chatAgain + 10), 2); | |
| 58 | 51 | assert.ok(pillProgress(PILLS[1].start) < 0.01); | |
| 59 | 52 | assert.ok(pillProgress(PILLS[2].start - 1) > 0.99); | |
| 60 | 53 | }); | |
| 61 | 54 | ||
| 62 | 55 | test("each still shows its step's point", () => { | |
| 63 | − | assert.equal(frameAt(STILLS.chat).handoff, true); | |
| 64 | − | assert.equal(frameAt(STILLS.agents).steps, STEPS.length); | |
| 65 | 56 | assert.equal(frameAt(STILLS.code).approved, true); | |
| 57 | + | assert.equal(frameAt(STILLS.chat).izzy, true); | |
| 58 | + | assert.equal(frameAt(STILLS.agents).scene, "agents"); | |
| 66 | 59 | assert.equal(frameAt(STILLS.docs).docUpdated, true); | |
| 67 | 60 | }); | |
| 68 | 61 | ||
| 69 | − | test("Izzy tells #support after g1t says it shipped, and the consult shows while Otto works", () => { | |
| 62 | + | test("Izzy tells #support after g1t says it shipped", () => { | |
| 70 | 63 | assert.equal(frameAt(BEAT.izzy - 1).izzy, false); | |
| 71 | 64 | assert.equal(frameAt(BEAT.izzy).shipped, true); | |
| 72 | − | assert.equal(frameAt(BEAT.agents).consult, false); | |
| 73 | − | assert.equal(frameAt(BEAT.pullClick).consult, true); | |
| 74 | 65 | }); | |
| 4 | 4 | * paused, resumed, jumped to a step, or shown still, and the server and | |
| 5 | 5 | * the first client render agree (t = 0). | |
| 6 | 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. | |
| 7 | + | * The story opens on the code: Otto's pull request #431 runs its checks, | |
| 8 | + | * is approved and is merged by the queue. Then where it came from: the | |
| 9 | + | * ask in #web, handed to @otto by g1t, now merged, and Izzy telling | |
| 10 | + | * #support. Then Otto's desk, the record of what he did and spent, and | |
| 11 | + | * last Inky's docs page picking up the change. | |
| 12 | 12 | */ | |
| 13 | 13 | ||
| 14 | 14 | export type Scene = "chat" | "agents" | "code" | "docs"; | |
| ⋯ | |||
| 73 | 73 | ]; | |
| 74 | 74 | ||
| 75 | 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 | 76 | ||
| 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 | 77 | /** The beats of the story, in ms from the start of the loop. */ | |
| 106 | 78 | 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, | |
| 79 | + | code: 0, | |
| 80 | + | check0: 900, | |
| 120 | 81 | 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, | |
| 82 | + | diff: 1300, | |
| 83 | + | approved: 3900, | |
| 84 | + | merged: 5000, | |
| 85 | + | railChatClick: 6300, | |
| 86 | + | chat: 6450, | |
| 87 | + | cardMerged: 7100, | |
| 88 | + | shipped: 8400, | |
| 89 | + | izzy: 9600, | |
| 90 | + | railAgentsClick: 11000, | |
| 91 | + | agents: 11150, | |
| 92 | + | railDocsClick: 15000, | |
| 93 | + | docs: 15150, | |
| 94 | + | docUpdated: 16300, | |
| 95 | + | end: 21000, | |
| 133 | 96 | } as const; | |
| 134 | 97 | ||
| 135 | 98 | /** The whole loop, in ms. */ | |
| ⋯ | |||
| 137 | 100 | ||
| 138 | 101 | /** The step pills under the frame: where each starts, for jumping to it. */ | |
| 139 | 102 | export const PILLS: { scene: Scene; label: string; start: number; soon: boolean }[] = [ | |
| 140 | − | { scene: "chat", label: "Chat", start: 0, soon: false }, | |
| 103 | + | { scene: "code", label: "Code", start: 0, soon: false }, | |
| 104 | + | { scene: "chat", label: "Chat", start: BEAT.chat, soon: false }, | |
| 141 | 105 | { scene: "agents", label: "Agents", start: BEAT.agents, soon: true }, | |
| 142 | − | { scene: "code", label: "Code", start: BEAT.code, soon: false }, | |
| 143 | 106 | { scene: "docs", label: "Docs", start: BEAT.docs, soon: true }, | |
| 144 | 107 | ]; | |
| 145 | 108 | ||
| 146 | − | /** Which pill a time falls under. Back in chat after the merge is still Code's story. */ | |
| 109 | + | /** Which pill a time falls under. */ | |
| 147 | 110 | export function pillAt(t: number): number { | |
| 148 | 111 | let index = 0; | |
| 149 | 112 | PILLS.forEach((pill, i) => { | |
| ⋯ | |||
| 169 | 132 | function cursorAt(t: number): { cursor: CursorTarget; click: boolean } { | |
| 170 | 133 | const press = (at: number) => t >= at && t < at + 180; | |
| 171 | 134 | 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 | 135 | [BEAT.merged + 300, "rail-chat"], | |
| 177 | − | [BEAT.izzy + 500, "rail-docs"], | |
| 136 | + | [BEAT.izzy + 500, "rail-agents"], | |
| 137 | + | [BEAT.agents + 2600, "rail-docs"], | |
| 178 | 138 | [BEAT.docs + 500, "idle"], | |
| 179 | 139 | ]; | |
| 180 | 140 | let cursor: CursorTarget = "idle"; | |
| 181 | 141 | 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); | |
| 142 | + | const click = press(BEAT.railChatClick) || press(BEAT.railAgentsClick) || press(BEAT.railDocsClick); | |
| 189 | 143 | return { cursor, click }; | |
| 190 | 144 | } | |
| 191 | 145 | ||
| 192 | − | /** The frame at time t (ms into the loop). */ | |
| 146 | + | /** | |
| 147 | + | * The frame at time t (ms into the loop). The ask, g1t's handoff and | |
| 148 | + | * Otto's work all happened before the pull request the story opens on, so | |
| 149 | + | * they are shown done throughout. | |
| 150 | + | */ | |
| 193 | 151 | export function frameAt(time: number): Frame { | |
| 194 | 152 | 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; | |
| 153 | + | const scene: Scene = t >= BEAT.docs ? "docs" : t >= BEAT.agents ? "agents" : t >= BEAT.chat ? "chat" : "code"; | |
| 200 | 154 | const { cursor, click } = cursorAt(t); | |
| 201 | 155 | return { | |
| 202 | 156 | 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), | |
| 157 | + | typed: 0, | |
| 158 | + | sent: true, | |
| 159 | + | g1tTyping: false, | |
| 160 | + | handoff: true, | |
| 161 | + | steps: STEPS.length, | |
| 208 | 162 | checks: count(t, BEAT.check0, BEAT.checkGap, CHECKS.length), | |
| 209 | 163 | diff: t >= BEAT.diff, | |
| 210 | 164 | approved: t >= BEAT.approved, | |
| ⋯ | |||
| 212 | 166 | cardMerged: t >= BEAT.cardMerged, | |
| 213 | 167 | shipped: t >= BEAT.shipped, | |
| 214 | 168 | izzy: t >= BEAT.izzy, | |
| 215 | − | consult: t >= BEAT.step0 + 2 * BEAT.stepGap + 500, | |
| 169 | + | consult: true, | |
| 216 | 170 | docUpdated: t >= BEAT.docUpdated, | |
| 217 | 171 | cursor, | |
| 218 | 172 | click, | |
| 219 | − | cursorShown: t >= 200 && t < BEAT.end - 600, | |
| 173 | + | cursorShown: t >= BEAT.merged && t < BEAT.end - 600, | |
| 220 | 174 | }; | |
| 221 | 175 | } | |
| 222 | 176 | ||
| 223 | 177 | /** A still frame for each pill, for reduced motion: the moment the step makes its point. */ | |
| 224 | 178 | export const STILLS: Record<Scene, number> = { | |
| 225 | − | chat: BEAT.handoff + 400, | |
| 226 | − | agents: BEAT.step0 + 4 * BEAT.stepGap + 200, | |
| 227 | 179 | code: BEAT.approved + 200, | |
| 180 | + | chat: BEAT.izzy + 400, | |
| 181 | + | agents: BEAT.agents + 400, | |
| 228 | 182 | docs: BEAT.docUpdated + 400, | |
| 229 | 183 | }; | |
| 230 | 184 | ||