Mission control counts people's own changes as theirs; a shorter sidebar with setup under Settings, and a 56px top bar
The week splits by who wrote each change: agents on their own, agents merged by a person, and people. Landed without you is a share of the agents' changes only. Members and Teams move down beside Usage; Guardrails, Secrets and variables, Runners, Integrations and Webhooks move into Settings. The top bar and the docs' header are 56px, with a smaller mark.
9 files+197−1020/9 viewed
| 26 | 26 | flex-direction: column; | |
| 27 | 27 | height: 100%; | |
| 28 | 28 | } | |
| 29 | − | /* The same bar as the app's: 4rem, and a line under it. */ | |
| 29 | + | /* The same bar as the app's: 3.5rem, and a line under it. */ | |
| 30 | 30 | .g1t-header-top { | |
| 31 | 31 | box-sizing: border-box; | |
| 32 | − | height: 4rem; | |
| 32 | + | height: 3.5rem; | |
| 33 | 33 | flex-shrink: 0; | |
| 34 | 34 | padding: 0 var(--sl-nav-pad-x); | |
| 35 | 35 | border-bottom: 1px solid var(--g1t-line); |
| 39 | 39 | .g1t-title-lockup { | |
| 40 | 40 | display: inline-flex; | |
| 41 | 41 | align-items: baseline; | |
| 42 | − | font-size: 1.5rem; | |
| 42 | + | font-size: 1.25rem; | |
| 43 | 43 | line-height: 1; | |
| 44 | 44 | } | |
| 45 | 45 | svg { |
| 37 | 37 | --sl-text-body: 0.9375rem; | |
| 38 | 38 | --sl-line-height: 1.75; | |
| 39 | 39 | --sl-content-width: 46rem; | |
| 40 | − | /* The header's row, as tall as the app's top bar (4rem), and the tabs | |
| 40 | + | /* The header's row, as tall as the app's top bar (3.5rem), and the tabs | |
| 41 | 41 | beneath it. */ | |
| 42 | − | --sl-nav-height: 6.75rem; | |
| 42 | + | --sl-nav-height: 6.25rem; | |
| 43 | 43 | --sl-sidebar-width: 17rem; | |
| 44 | 44 | ||
| 45 | 45 | color-scheme: dark; | |
| 63 | 63 | } | |
| 64 | 64 | /* On phones the menu button sits in the header's top row, not its middle. */ | |
| 65 | 65 | .sl-menu-button { | |
| 66 | − | top: calc((4rem - var(--sl-menu-button-size)) / 2) !important; | |
| 66 | + | top: calc((3.5rem - var(--sl-menu-button-size)) / 2) !important; | |
| 67 | 67 | } | |
| 68 | 68 | site-search button[data-open-modal] { | |
| 69 | 69 | border-radius: 0.5rem; |
| 395 | 395 | className="scroll-mt-28 rounded-xl border border-line" | |
| 396 | 396 | > | |
| 397 | 397 | <header | |
| 398 | − | className={`sticky top-16 z-20 flex items-center gap-2.5 border-line bg-surface/95 px-3 py-2 backdrop-blur ${ | |
| 398 | + | className={`sticky top-14 z-20 flex items-center gap-2.5 border-line bg-surface/95 px-3 py-2 backdrop-blur ${ | |
| 399 | 399 | collapsed ? "rounded-xl" : "rounded-t-xl border-b" | |
| 400 | 400 | }`} | |
| 401 | 401 | > | |
| 707 | 707 | const allCollapsed = files.every((file) => collapsed.has(file.path)); | |
| 708 | 708 | return ( | |
| 709 | 709 | <div> | |
| 710 | − | <div className="sticky top-16 z-30 -mx-1 mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 bg-bg/90 px-1 py-2 backdrop-blur"> | |
| 710 | + | <div className="sticky top-14 z-30 -mx-1 mb-3 flex flex-wrap items-center gap-x-4 gap-y-2 bg-bg/90 px-1 py-2 backdrop-blur"> | |
| 711 | 711 | <span className="text-sm text-muted"> | |
| 712 | 712 | <span className="font-medium text-fg">{files.length}</span> {files.length === 1 ? "file" : "files"} | |
| 713 | 713 | </span> |
| 425 | 425 | ||
| 426 | 426 | /** Seven days of landed changes, each split into what agents landed alone and what a person merged. */ | |
| 427 | 427 | function WeekChart({ week }: { week: Week }) { | |
| 428 | − | const max = Math.max(1, ...week.days.map((d) => d.agents + d.people)); | |
| 428 | + | const max = Math.max(1, ...week.days.map((d) => d.agents + d.assisted + d.people)); | |
| 429 | 429 | const height = 112; | |
| 430 | 430 | const delta = change(week.total, week.previous); | |
| 431 | 431 | return ( | |
| 450 | 450 | </div> | |
| 451 | 451 | {week.total === 0 ? ( | |
| 452 | 452 | <p className="mt-4 rounded-lg border border-dashed border-line px-4 py-6 text-center text-sm leading-6 text-muted"> | |
| 453 | − | Nothing landed in the last 7 days. Each change that does shows here, by day, split by whether a person had to merge it. | |
| 453 | + | Nothing landed in the last 7 days. Each change that does shows here, by day: agents' changes that landed on their own, agents' that a person merged, and people's own. | |
| 454 | 454 | </p> | |
| 455 | 455 | ) : ( | |
| 456 | 456 | <div className="relative mt-5" style={{ height: height + 20 }}> | |
| 457 | 457 | <div className="absolute inset-x-0 border-t border-line" style={{ top: height }} /> | |
| 458 | 458 | <div className="absolute inset-x-0 top-0 flex items-end justify-between gap-1" style={{ height }}> | |
| 459 | 459 | {week.days.map((day, index) => { | |
| 460 | − | const total = day.agents + day.people; | |
| 461 | − | const agentsH = Math.round((day.agents / max) * (height - 4)); | |
| 462 | − | const peopleH = Math.round((day.people / max) * (height - 4)); | |
| 460 | + | const total = day.agents + day.assisted + day.people; | |
| 461 | + | const bar = (n: number) => Math.max(3, Math.round((n / max) * (height - 4))); | |
| 462 | + | // Top to bottom: agents alone, agents with a person, people. | |
| 463 | + | const segments = [ | |
| 464 | + | { n: day.agents, tone: "bg-merged" }, | |
| 465 | + | { n: day.assisted, tone: "bg-warn" }, | |
| 466 | + | { n: day.people, tone: "bg-info" }, | |
| 467 | + | ].filter((segment) => segment.n > 0); | |
| 463 | 468 | const today = index === week.days.length - 1; | |
| 464 | 469 | return ( | |
| 465 | 470 | <div key={day.key} className="group relative flex h-full flex-1 flex-col items-center justify-end"> | |
| 474 | 479 | </span> | |
| 475 | 480 | )} | |
| 476 | 481 | <div className="flex w-full max-w-6 flex-col items-stretch gap-[2px]"> | |
| 477 | − | {day.people > 0 && <span className="block rounded-t bg-warn" style={{ height: Math.max(3, peopleH) }} />} | |
| 478 | − | {day.agents > 0 && ( | |
| 479 | − | <span className={cn("block bg-merged", day.people > 0 ? "" : "rounded-t")} style={{ height: Math.max(3, agentsH) }} /> | |
| 480 | − | )} | |
| 482 | + | {segments.map((segment, at) => ( | |
| 483 | + | <span key={segment.tone} className={cn("block", segment.tone, at === 0 && "rounded-t")} style={{ height: bar(segment.n) }} /> | |
| 484 | + | ))} | |
| 481 | 485 | {total === 0 && <span className="block h-[2px] rounded-full bg-line-strong" />} | |
| 482 | 486 | </div> | |
| 483 | 487 | <div className="pointer-events-none absolute bottom-full left-1/2 z-10 mb-1 hidden -translate-x-1/2 rounded-md border border-line-strong bg-raised px-2.5 py-1.5 text-xs whitespace-nowrap shadow-lg shadow-black/40 group-hover:block"> | |
| 484 | 488 | <p className="font-medium text-fg">{day.label}</p> | |
| 485 | 489 | <p className="mt-0.5 flex items-center gap-1.5 text-muted"> | |
| 486 | − | <span className="size-1.5 rounded-full bg-merged" /> {day.agents} by agents | |
| 490 | + | <span className="size-1.5 rounded-full bg-merged" /> {day.agents} by agents on their own | |
| 491 | + | </p> | |
| 492 | + | <p className="flex items-center gap-1.5 text-muted"> | |
| 493 | + | <span className="size-1.5 rounded-full bg-warn" /> {day.assisted} by agents, merged by a person | |
| 487 | 494 | </p> | |
| 488 | 495 | <p className="flex items-center gap-1.5 text-muted"> | |
| 489 | − | <span className="size-1.5 rounded-full bg-warn" /> {day.people} needed a person | |
| 496 | + | <span className="size-1.5 rounded-full bg-info" /> {day.people} by people | |
| 490 | 497 | </p> | |
| 491 | 498 | </div> | |
| 492 | 499 | </div> | |
| 510 | 517 | )} | |
| 511 | 518 | <div className={cn("mt-3 flex-wrap gap-x-4 gap-y-1 text-xs text-muted", week.total === 0 ? "hidden" : "flex")}> | |
| 512 | 519 | <span className="inline-flex items-center gap-1.5"> | |
| 513 | − | <span className="size-2 rounded-sm bg-merged" /> Landed by agents | |
| 520 | + | <span className="size-2 rounded-sm bg-merged" /> Agents, on their own | |
| 521 | + | </span> | |
| 522 | + | <span className="inline-flex items-center gap-1.5"> | |
| 523 | + | <span className="size-2 rounded-sm bg-warn" /> Agents, merged by a person | |
| 514 | 524 | </span> | |
| 515 | 525 | <span className="inline-flex items-center gap-1.5"> | |
| 516 | − | <span className="size-2 rounded-sm bg-warn" /> Needed a person | |
| 526 | + | <span className="size-2 rounded-sm bg-info" /> People | |
| 517 | 527 | </span> | |
| 518 | 528 | </div> | |
| 519 | 529 | <table className="sr-only"> | |
| 521 | 531 | <thead> | |
| 522 | 532 | <tr> | |
| 523 | 533 | <th>Day</th> | |
| 524 | − | <th>Landed by agents</th> | |
| 525 | − | <th>Needed a person</th> | |
| 534 | + | <th>Agents, on their own</th> | |
| 535 | + | <th>Agents, merged by a person</th> | |
| 536 | + | <th>People</th> | |
| 526 | 537 | </tr> | |
| 527 | 538 | </thead> | |
| 528 | 539 | <tbody> | |
| 530 | 541 | <tr key={day.key}> | |
| 531 | 542 | <td>{day.key}</td> | |
| 532 | 543 | <td>{day.agents}</td> | |
| 544 | + | <td>{day.assisted}</td> | |
| 533 | 545 | <td>{day.people}</td> | |
| 534 | 546 | </tr> | |
| 535 | 547 | ))} | |
| 781 | 793 | ||
| 782 | 794 | const rows = tab === "needs" ? sortRows(needs, sort) : tab === "waiting" ? sortRows(waiting, sort) : sortRows(landed, sort); | |
| 783 | 795 | const shown = all ? rows : rows.slice(0, ROWS); | |
| 784 | − | const share = week.total > 0 ? week.byAgents / week.total : null; | |
| 796 | + | // Of the agents' own changes only: people's work is not theirs to land. | |
| 797 | + | const share = week.agentChanges > 0 ? week.byAgents / week.agentChanges : null; | |
| 785 | 798 | const delta = change(week.total, week.previous); | |
| 786 | 799 | const feed = everyActivity ? groups : groups.slice(0, 8); | |
| 787 | 800 | ||
| 903 | 916 | label="Landed without you" | |
| 904 | 917 | dot="bg-merged" | |
| 905 | 918 | value={share == null ? "—" : `${Math.round(share * 100)}%`} | |
| 906 | − | hint={share == null ? "nothing landed yet" : `${week.byAgents} of ${week.total}`} | |
| 907 | − | title="Merged by g1t, by auto-merge or the merge queue, with no person pressing merge." | |
| 919 | + | hint={share == null ? "no agent changes yet" : `${week.byAgents} of ${week.agentChanges} agent changes`} | |
| 920 | + | title="Of the changes agents wrote, those g1t merged by auto-merge or the merge queue, with no person pressing merge. People's own changes are not counted." | |
| 908 | 921 | /> | |
| 909 | 922 | <div className="col-span-2 lg:col-span-1"> | |
| 910 | 923 | <Stat |
| 439 | 439 | } | |
| 440 | 440 | ||
| 441 | 441 | /** | |
| 442 | − | * A workspace's settings pages, which the sidebar drills into: what it | |
| 443 | − | * pays, its repositories, tokens and record. Who belongs (Members, Teams) | |
| 444 | − | * and what it builds and runs with (secrets, integrations, webhooks, | |
| 445 | − | * guardrails) sit in the main list, for every member to see. | |
| 442 | + | * A workspace's settings pages, which the sidebar drills into: how it is | |
| 443 | + | * set up and connected (guardrails, secrets, runners, integrations, | |
| 444 | + | * webhooks), what it pays, its repositories, tokens and record. The main | |
| 445 | + | * list keeps the places work happens and who belongs; every member can | |
| 446 | + | * still open these. | |
| 446 | 447 | */ | |
| 447 | − | const SETTINGS_PAGE = /^\/([^/]+)\/-\/(settings|repositories|tokens|billing|audit)(\/|$)/; | |
| 448 | + | const SETTINGS_PAGE = | |
| 449 | + | /^\/([^/]+)\/-\/(settings|repositories|tokens|guardrails|secrets|runners|integrations|webhooks|billing|audit)(\/|$)/; | |
| 448 | 450 | /** A project's settings pages, which the project's menu drills into. */ | |
| 449 | 451 | const REPO_SETTINGS_PAGE = /^\/([^/]+)\/([^/-][^/]*)\/settings(\/|$)/; | |
| 450 | 452 | ||
| 633 | 635 | ||
| 634 | 636 | {ws && ( | |
| 635 | 637 | <> | |
| 636 | − | <Rule /> | |
| 637 | − | <div className="space-y-px"> | |
| 638 | − | {/* Who belongs, for every member to see; owners invite and manage there. */} | |
| 639 | − | <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />}> | |
| 640 | − | Members | |
| 641 | − | </SidebarLink> | |
| 642 | − | {roadmapIn("Workspace") | |
| 643 | − | .filter((item) => item.key === "teams") | |
| 644 | − | .map((item) => ( | |
| 645 | − | <SidebarSoonLink key={item.key} to={`/${ws.slug}/-/soon/${item.key}`} icon={WORKSPACE_ICONS[item.key]} about={item.summary}> | |
| 646 | − | {item.title} | |
| 647 | − | </SidebarSoonLink> | |
| 648 | − | ))} | |
| 649 | − | </div> | |
| 650 | 638 | <Rule /> | |
| 651 | 639 | <div className="space-y-px"> | |
| 652 | 640 | <SidebarLink to={`/${ws.slug}/-/agents`} icon={<Bot size={15} />}> | |
| 660 | 648 | </SidebarLink> | |
| 661 | 649 | <SidebarLink to={`/${ws.slug}/-/security`} icon={<ShieldCheck size={15} />}> | |
| 662 | 650 | Security | |
| 663 | − | </SidebarLink> | |
| 664 | − | <SidebarLink to={`/${ws.slug}/-/guardrails`} icon={<Gauge size={15} />}> | |
| 665 | − | Guardrails | |
| 666 | − | </SidebarLink> | |
| 667 | − | <SidebarLink to={`/${ws.slug}/-/secrets`} icon={<Lock size={15} />}> | |
| 668 | − | Secrets and variables | |
| 669 | − | </SidebarLink> | |
| 670 | − | <SidebarLink to={`/${ws.slug}/-/runners`} icon={<ServerCog size={15} />}> | |
| 671 | − | Runners | |
| 672 | 651 | </SidebarLink> | |
| 673 | 652 | <SidebarLink to={`/${ws.slug}/-/packages`} icon={<Package size={15} />}> | |
| 674 | 653 | Packages | |
| 675 | 654 | </SidebarLink> | |
| 676 | − | <SidebarLink to={`/${ws.slug}/-/integrations`} icon={<Plug size={15} />}> | |
| 677 | − | Integrations | |
| 678 | − | </SidebarLink> | |
| 679 | − | <SidebarLink to={`/${ws.slug}/-/webhooks`} icon={<Webhook size={15} />}> | |
| 680 | − | Webhooks | |
| 681 | − | </SidebarLink> | |
| 682 | 655 | {roadmapIn("Workspace").filter((item) => item.key !== "teams").map((item) => ( | |
| 683 | 656 | <SidebarSoonLink | |
| 684 | 657 | key={item.key} | |
| 693 | 666 | ||
| 694 | 667 | <Rule /> | |
| 695 | 668 | <div className="space-y-px"> | |
| 669 | + | {/* Who belongs, for every member to see; owners invite and manage there. */} | |
| 670 | + | <SidebarLink to={`/${ws.slug}/-/people`} icon={<Users size={15} />}> | |
| 671 | + | Members | |
| 672 | + | </SidebarLink> | |
| 673 | + | {roadmapIn("Workspace") | |
| 674 | + | .filter((item) => item.key === "teams") | |
| 675 | + | .map((item) => ( | |
| 676 | + | <SidebarSoonLink key={item.key} to={`/${ws.slug}/-/soon/${item.key}`} icon={WORKSPACE_ICONS[item.key]} about={item.summary}> | |
| 677 | + | {item.title} | |
| 678 | + | </SidebarSoonLink> | |
| 679 | + | ))} | |
| 696 | 680 | <SidebarLink to={`/${ws.slug}/-/usage`} icon={<BarChart3 size={15} />}> | |
| 697 | 681 | Usage | |
| 698 | 682 | </SidebarLink> | |
| 699 | 683 | <SidebarLink to="/support" icon={<LifeBuoy size={15} />}> | |
| 700 | 684 | Support | |
| 701 | 685 | </SidebarLink> | |
| 702 | − | {/* Who belongs, what it pays and its record: a list of their own. */} | |
| 686 | + | {/* How it is set up and connected, what it pays and its record: a list of their own. */} | |
| 703 | 687 | <SidebarLink | |
| 704 | 688 | to={ws.role === "owner" ? `/${ws.slug}/-/settings` : `/${ws.slug}/-/repositories`} | |
| 705 | 689 | icon={<Settings size={15} />} | |
| 732 | 716 | Access tokens | |
| 733 | 717 | </SidebarLink> | |
| 734 | 718 | </div> | |
| 719 | + | <SidebarGroup title="Agents and runs" className="mt-3"> | |
| 720 | + | <SidebarLink to={`/${slug}/-/guardrails`} icon={<Gauge size={15} />}> | |
| 721 | + | Guardrails | |
| 722 | + | </SidebarLink> | |
| 723 | + | <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}> | |
| 724 | + | Secrets and variables | |
| 725 | + | </SidebarLink> | |
| 726 | + | <SidebarLink to={`/${slug}/-/runners`} icon={<ServerCog size={15} />}> | |
| 727 | + | Runners | |
| 728 | + | </SidebarLink> | |
| 729 | + | </SidebarGroup> | |
| 730 | + | <SidebarGroup title="Connections" className="mt-3"> | |
| 731 | + | <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}> | |
| 732 | + | Integrations | |
| 733 | + | </SidebarLink> | |
| 734 | + | <SidebarLink to={`/${slug}/-/webhooks`} icon={<Webhook size={15} />}> | |
| 735 | + | Webhooks | |
| 736 | + | </SidebarLink> | |
| 737 | + | </SidebarGroup> | |
| 735 | 738 | <Rule /> | |
| 736 | 739 | <div className="space-y-px"> | |
| 737 | 740 | <SidebarLink to={`/${slug}/-/billing`} icon={<CreditCard size={15} />}> | |
| 1141 | 1144 | return ( | |
| 1142 | 1145 | <div className="flex h-full flex-col"> | |
| 1143 | 1146 | {/* The same height and rule as the top bar, so the two read as one line. */} | |
| 1144 | − | <div className="flex h-16 shrink-0 items-center gap-1 border-b border-line pr-2 pl-2.5"> | |
| 1147 | + | <div className="flex h-14 shrink-0 items-center gap-1 border-b border-line pr-2 pl-2.5"> | |
| 1145 | 1148 | {user ? ( | |
| 1146 | 1149 | <> | |
| 1147 | 1150 | {/* The 1 alone beside the workspace: a square hover the height of the | |
| 1148 | 1151 | switcher, the mark as tall as the workspace avatar. */} | |
| 1149 | 1152 | <Link to="/" aria-label="g1t home" className="flex size-9 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-raised"> | |
| 1150 | − | <Mark tight className="h-5 w-auto" /> | |
| 1153 | + | <Mark tight className="h-4 w-auto" /> | |
| 1151 | 1154 | </Link> | |
| 1152 | 1155 | <span className="shrink-0 text-line-strong" aria-hidden="true"> | |
| 1153 | 1156 | / | |
| 1520 | 1523 | )} | |
| 1521 | 1524 | ||
| 1522 | 1525 | <div className="flex min-h-screen min-w-0 flex-col lg:pl-64"> | |
| 1523 | − | <header className="sticky top-0 z-30 flex h-16 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:px-6"> | |
| 1526 | + | <header className="sticky top-0 z-30 flex h-14 items-center gap-3 border-b border-line bg-bg/85 px-4 backdrop-blur sm:px-6"> | |
| 1524 | 1527 | <button | |
| 1525 | 1528 | type="button" | |
| 1526 | 1529 | aria-label="Open menu" |
| 160 | 160 | assert.ok(!isTestFile("src/contest.ts")); | |
| 161 | 161 | }); | |
| 162 | 162 | ||
| 163 | − | const merged = (daysAgo: number, mergedBy: string | null, number = 1): Merged => ({ | |
| 163 | + | const merged = (daysAgo: number, mergedBy: string | null, number = 1, authoredByAgent = true): Merged => ({ | |
| 164 | 164 | repo, | |
| 165 | 165 | number, | |
| 166 | 166 | title: `Change ${number}`, | |
| 167 | 167 | agent: "g1t", | |
| 168 | + | authoredByAgent, | |
| 168 | 169 | mergedBy, | |
| 169 | 170 | mergedAt: new Date(NOW - daysAgo * DAY).toISOString(), | |
| 170 | 171 | files: [], | |
| 177 | 178 | assert.ok(!landedByAgents({ mergedBy: "syntaqx" })); | |
| 178 | 179 | }); | |
| 179 | 180 | ||
| 180 | − | test("the week is seven days, each split by who landed it, with the week before", () => { | |
| 181 | + | test("the week is seven days, each split by who did the work, with the week before", () => { | |
| 181 | 182 | const week = weekOf( | |
| 182 | − | [merged(0, "g1t"), merged(0, "syntaqx"), merged(1, "g1t"), merged(6, "g1t"), merged(8, "g1t"), merged(10, "alex"), merged(20, "g1t")], | |
| 183 | + | [ | |
| 184 | + | merged(0, "g1t"), | |
| 185 | + | merged(0, "syntaqx"), | |
| 186 | + | // A person's own change, merged by them and auto-merged by g1t: both theirs. | |
| 187 | + | merged(0, "syntaqx", 2, false), | |
| 188 | + | merged(1, "g1t", 3, false), | |
| 189 | + | merged(1, "g1t"), | |
| 190 | + | merged(6, "g1t"), | |
| 191 | + | merged(8, "g1t"), | |
| 192 | + | merged(10, "alex"), | |
| 193 | + | merged(20, "g1t"), | |
| 194 | + | ], | |
| 183 | 195 | NOW, | |
| 184 | 196 | "UTC", | |
| 185 | 197 | ); | |
| 187 | 199 | assert.equal(week.days[6].key, "2026-10-05"); | |
| 188 | 200 | assert.equal(week.days[6].label, "Mon"); | |
| 189 | 201 | assert.deepEqual( | |
| 190 | − | week.days.map((d) => [d.agents, d.people]), | |
| 191 | − | [[1, 0], [0, 0], [0, 0], [0, 0], [0, 0], [1, 0], [1, 1]], | |
| 202 | + | week.days.map((d) => [d.agents, d.assisted, d.people]), | |
| 203 | + | [[1, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [1, 0, 1], [1, 1, 1]], | |
| 192 | 204 | ); | |
| 193 | − | assert.equal(week.total, 4); | |
| 205 | + | assert.equal(week.total, 6); | |
| 194 | 206 | assert.equal(week.byAgents, 3); | |
| 207 | + | assert.equal(week.agentChanges, 4); | |
| 208 | + | assert.equal(week.people, 2); | |
| 195 | 209 | assert.equal(week.previous, 2); | |
| 196 | 210 | // Not knowing the week before is not the same as nothing in it. | |
| 197 | 211 | assert.equal(weekOf([merged(0, "g1t")], NOW, "UTC", false).previous, null); | |
| 203 | 217 | assert.equal(dayKey(late, "America/Los_Angeles"), "2026-10-04"); | |
| 204 | 218 | assert.equal(dayKey(late, "UTC"), "2026-10-05"); | |
| 205 | 219 | assert.equal(dayKey(late, "Not/AZone"), "2026-10-05"); | |
| 206 | − | const week = weekOf([{ mergedAt: new Date(late).toISOString(), mergedBy: "g1t" }], NOW, "America/Los_Angeles"); | |
| 220 | + | const week = weekOf([{ mergedAt: new Date(late).toISOString(), mergedBy: "g1t", authoredByAgent: true }], NOW, "America/Los_Angeles"); | |
| 207 | 221 | assert.equal(week.days.find((d) => d.key === "2026-10-04")?.agents, 1); | |
| 208 | 222 | }); | |
| 209 | 223 | ||
| 315 | 329 | }); | |
| 316 | 330 | ||
| 317 | 331 | test("the summary says the week honestly", () => { | |
| 318 | − | assert.equal(summaryLine({ total: 47, byAgents: 39, live: 2, needs: 8 }), "Agents landed 39 of 47 changes this week without you."); | |
| 319 | − | assert.equal(summaryLine({ total: 3, byAgents: 3, live: 0, needs: 0 }), "Agents landed all 3 changes this week without you."); | |
| 320 | − | assert.equal(summaryLine({ total: 2, byAgents: 0, live: 0, needs: 0 }), "2 changes landed this week, each merged by a person."); | |
| 321 | − | assert.equal(summaryLine({ total: 0, byAgents: 0, live: 1, needs: 0 }), "1 agent is at work. Nothing has landed this week yet."); | |
| 322 | − | assert.match(summaryLine({ total: 0, byAgents: 0, live: 0, needs: 0 }), /Assign an issue/); | |
| 332 | + | const none = { total: 0, byAgents: 0, agentChanges: 0, people: 0, live: 0, needs: 0 }; | |
| 333 | + | assert.equal( | |
| 334 | + | summaryLine({ ...none, total: 47, byAgents: 39, agentChanges: 47 }), | |
| 335 | + | "Agents landed 39 of their 47 changes this week without you.", | |
| 336 | + | ); | |
| 337 | + | assert.equal( | |
| 338 | + | summaryLine({ ...none, total: 3, byAgents: 3, agentChanges: 3 }), | |
| 339 | + | "Agents landed all 3 changes of theirs this week without you.", | |
| 340 | + | ); | |
| 341 | + | assert.equal( | |
| 342 | + | summaryLine({ ...none, total: 2, agentChanges: 2 }), | |
| 343 | + | "Agents made 2 changes this week, each merged by a person.", | |
| 344 | + | ); | |
| 345 | + | // People's own work is counted as theirs, never as agents' that needed help. | |
| 346 | + | assert.equal( | |
| 347 | + | summaryLine({ ...none, total: 7, byAgents: 4, agentChanges: 5, people: 2 }), | |
| 348 | + | "Agents landed 4 of their 5 changes this week without you, and people landed 2 changes of their own.", | |
| 349 | + | ); | |
| 350 | + | assert.equal(summaryLine({ ...none, total: 3, people: 3 }), "People landed 3 changes this week; none were agents'."); | |
| 351 | + | assert.equal(summaryLine({ ...none, live: 1 }), "1 agent is at work. Nothing has landed this week yet."); | |
| 352 | + | assert.match(summaryLine(none), /Assign an issue/); | |
| 323 | 353 | }); |
| 429 | 429 | number: number; | |
| 430 | 430 | title: string; | |
| 431 | 431 | agent: string; | |
| 432 | + | /** Whether an agent wrote it (its author is g1t or another agent), not a person. */ | |
| 433 | + | authoredByAgent: boolean; | |
| 432 | 434 | mergedBy: string | null; | |
| 433 | 435 | mergedAt: string; | |
| 434 | 436 | files: ChangedFile[]; | |
| 451 | 453 | } | |
| 452 | 454 | } | |
| 453 | 455 | ||
| 454 | − | export type WeekDay = { key: string; label: string; agents: number; people: number }; | |
| 456 | + | /** | |
| 457 | + | * One day's changes by who did the work: `agents`, written by an agent and | |
| 458 | + | * landed without a person; `assisted`, written by an agent and merged by a | |
| 459 | + | * person; `people`, written by a person, however it landed. | |
| 460 | + | */ | |
| 461 | + | export type WeekDay = { key: string; label: string; agents: number; assisted: number; people: number }; | |
| 455 | 462 | ||
| 456 | 463 | export type Week = { | |
| 457 | 464 | days: WeekDay[]; | |
| 458 | − | /** Changes landed in the last seven days, and how many without a person. */ | |
| 465 | + | /** Changes landed in the last seven days. */ | |
| 459 | 466 | total: number; | |
| 467 | + | /** Of those, agents' changes that landed without a person. */ | |
| 460 | 468 | byAgents: number; | |
| 469 | + | /** Agents' changes, with a person or without. */ | |
| 470 | + | agentChanges: number; | |
| 471 | + | /** People's own changes. */ | |
| 472 | + | people: number; | |
| 461 | 473 | /** The seven days before, or null when the lists read do not reach back that far. */ | |
| 462 | 474 | previous: number | null; | |
| 463 | 475 | }; | |
| 464 | 476 | ||
| 465 | 477 | /** | |
| 466 | − | * The last seven days in the viewer's zone, oldest first, each split into | |
| 467 | − | * what agents landed alone and what a person merged, and the week before | |
| 468 | − | * as one number. `complete` says whether what was read reaches back two | |
| 469 | − | * weeks; when it does not, the week before is not guessed. | |
| 478 | + | * The last seven days in the viewer's zone, oldest first, each split by who | |
| 479 | + | * did the work (see `WeekDay`), and the week before as one number. | |
| 480 | + | * `complete` says whether what was read reaches back two weeks; when it | |
| 481 | + | * does not, the week before is not guessed. | |
| 470 | 482 | */ | |
| 471 | − | export function weekOf(changes: Pick<Merged, "mergedAt" | "mergedBy">[], now: number, timeZone: string | null, complete = true): Week { | |
| 483 | + | export function weekOf( | |
| 484 | + | changes: Pick<Merged, "mergedAt" | "mergedBy" | "authoredByAgent">[], | |
| 485 | + | now: number, | |
| 486 | + | timeZone: string | null, | |
| 487 | + | complete = true, | |
| 488 | + | ): Week { | |
| 472 | 489 | const days: WeekDay[] = []; | |
| 473 | 490 | const index = new Map<string, number>(); | |
| 474 | 491 | for (let back = 6; back >= 0; back -= 1) { | |
| 482 | 499 | } catch { | |
| 483 | 500 | label = new Intl.DateTimeFormat("en-US", { weekday: "short", timeZone: "UTC" }).format(at); | |
| 484 | 501 | } | |
| 485 | − | days.push({ key, label, agents: 0, people: 0 }); | |
| 502 | + | days.push({ key, label, agents: 0, assisted: 0, people: 0 }); | |
| 486 | 503 | } | |
| 487 | 504 | const oldest = days[0]?.key ?? ""; | |
| 488 | 505 | const twoWeeks = dayKey(now - 13 * DAY, timeZone); | |
| 493 | 510 | const key = dayKey(at, timeZone); | |
| 494 | 511 | const slot = index.get(key); | |
| 495 | 512 | if (slot != null) { | |
| 496 | − | if (landedByAgents(change)) days[slot].agents += 1; | |
| 497 | − | else days[slot].people += 1; | |
| 513 | + | if (!change.authoredByAgent) days[slot].people += 1; | |
| 514 | + | else if (landedByAgents(change)) days[slot].agents += 1; | |
| 515 | + | else days[slot].assisted += 1; | |
| 498 | 516 | } else if (key < oldest && key >= twoWeeks) { | |
| 499 | 517 | previous += 1; | |
| 500 | 518 | } | |
| 501 | 519 | } | |
| 502 | − | const byAgents = days.reduce((sum, day) => sum + day.agents, 0); | |
| 503 | − | const total = byAgents + days.reduce((sum, day) => sum + day.people, 0); | |
| 504 | − | return { days, total, byAgents, previous: complete ? previous : null }; | |
| 520 | + | const sum = (pick: (day: WeekDay) => number) => days.reduce((total, day) => total + pick(day), 0); | |
| 521 | + | const byAgents = sum((day) => day.agents); | |
| 522 | + | const agentChanges = byAgents + sum((day) => day.assisted); | |
| 523 | + | const people = sum((day) => day.people); | |
| 524 | + | return { days, total: agentChanges + people, byAgents, agentChanges, people, previous: complete ? previous : null }; | |
| 505 | 525 | } | |
| 506 | 526 | ||
| 507 | 527 | /** The change from one number to another, as a share; null from nothing. */ | |
| 553 | 573 | title: change.title, | |
| 554 | 574 | by: who(change.mergedBy ?? "g1t"), | |
| 555 | 575 | agent: change.agent, | |
| 556 | − | byAgents: landedByAgents(change), | |
| 576 | + | byAgents: change.authoredByAgent && landedByAgents(change), | |
| 557 | 577 | at: Date.parse(change.mergedAt), | |
| 558 | 578 | to: `/${change.repo.namespace}/${change.repo.name}/pull/${change.number}`, | |
| 559 | 579 | facts: pullFacts({ checkStatus: "passed", files: change.files }).filter((fact) => fact.label !== "Required checks"), | |
| 582 | 602 | ||
| 583 | 603 | // --- The summary ------------------------------------------------------------ | |
| 584 | 604 | ||
| 585 | − | /** The sentence under the greeting: the week, honestly, in one line. */ | |
| 586 | − | export function summaryLine(input: { total: number; byAgents: number; live: number; needs: number }): string { | |
| 587 | − | const { total, byAgents, live, needs } = input; | |
| 605 | + | /** | |
| 606 | + | * The sentence under the greeting: the week, honestly, in one line. What | |
| 607 | + | * agents landed alone is counted against the agents' own changes, never | |
| 608 | + | * against what people wrote. | |
| 609 | + | */ | |
| 610 | + | export function summaryLine(input: { | |
| 611 | + | total: number; | |
| 612 | + | byAgents: number; | |
| 613 | + | agentChanges: number; | |
| 614 | + | people: number; | |
| 615 | + | live: number; | |
| 616 | + | needs: number; | |
| 617 | + | }): string { | |
| 618 | + | const { total, byAgents, agentChanges, people, live, needs } = input; | |
| 588 | 619 | if (total > 0) { | |
| 589 | − | const landed = | |
| 590 | − | byAgents === total | |
| 591 | − | ? `Agents landed all ${plural(total, "change")} this week without you.` | |
| 592 | − | : byAgents === 0 | |
| 593 | − | ? `${plural(total, "change")} landed this week, each merged by a person.` | |
| 594 | − | : `Agents landed ${byAgents} of ${plural(total, "change")} this week without you.`; | |
| 595 | − | return landed; | |
| 620 | + | const theirs = people > 0 ? `, and people landed ${plural(people, "change")} of their own` : ""; | |
| 621 | + | if (agentChanges === 0) return `People landed ${plural(people, "change")} this week; none were agents'.`; | |
| 622 | + | if (byAgents === agentChanges) return `Agents landed all ${plural(agentChanges, "change")} of theirs this week without you${theirs}.`; | |
| 623 | + | if (byAgents === 0) return `Agents made ${plural(agentChanges, "change")} this week, each merged by a person${theirs}.`; | |
| 624 | + | return `Agents landed ${byAgents} of their ${plural(agentChanges, "change")} this week without you${theirs}.`; | |
| 596 | 625 | } | |
| 597 | 626 | if (live > 0) return `${plural(live, "agent is", "agents are")} at work. Nothing has landed this week yet.`; | |
| 598 | 627 | if (needs > 0) return "Nothing has landed this week. What is waiting on you is below."; |
| 499 | 499 | const merged: Merged[] = (perRepo ?? []).flatMap(({ repo, closed }) => | |
| 500 | 500 | (closed ?? []).flatMap((pull) => | |
| 501 | 501 | pull.status === "merged" && pull.mergedAt | |
| 502 | − | ? [{ repo: pathOf(repo), number: pull.number, title: pull.title, agent: pull.agent, mergedBy: pull.mergedBy, mergedAt: pull.mergedAt, files: pull.files }] | |
| 502 | + | ? [ | |
| 503 | + | { | |
| 504 | + | repo: pathOf(repo), | |
| 505 | + | number: pull.number, | |
| 506 | + | title: pull.title, | |
| 507 | + | agent: pull.agent, | |
| 508 | + | // Who wrote it, not who merged it: a person's change that g1t | |
| 509 | + | // auto-merged is still theirs. | |
| 510 | + | authoredByAgent: pull.author.kind === "agent" || isAgent(pull.author.username), | |
| 511 | + | mergedBy: pull.mergedBy, | |
| 512 | + | mergedAt: pull.mergedAt, | |
| 513 | + | files: pull.files, | |
| 514 | + | }, | |
| 515 | + | ] | |
| 503 | 516 | : [], | |
| 504 | 517 | ), | |
| 505 | 518 | ); | |
| 582 | 595 | name: profile?.name?.trim() || username, | |
| 583 | 596 | greeting: greetingFor(hourIn(now, tz)), | |
| 584 | 597 | date: dateLine(now, tz), | |
| 585 | − | summary: summaryLine({ total: week.total, byAgents: week.byAgents, live: liveRuns.length, needs: needRows.length }), | |
| 598 | + | summary: summaryLine({ | |
| 599 | + | total: week.total, | |
| 600 | + | byAgents: week.byAgents, | |
| 601 | + | agentChanges: week.agentChanges, | |
| 602 | + | people: week.people, | |
| 603 | + | live: liveRuns.length, | |
| 604 | + | needs: needRows.length, | |
| 605 | + | }), | |
| 586 | 606 | workspace: slug, | |
| 587 | 607 | repos: repoList.filter((repo) => inWorkspace(repo)).map(pathOf), | |
| 588 | 608 | canRunAgents: models_ == null || models_.hosted || models_.own != null, |