Merge pull request #168 from flagon-io/home-ask-wide
Home leads with Ask, then your move beside what's live, then the week day by day
10 files+650−4290/10 viewed
| 1 | 1 | --- | |
| 2 | 2 | title: Home | |
| 3 | − | description: Your workspace's front page. The past week in four figures, the daily signal (what your work says today, with the evidence), where you're needed now, what happened since you were last here in plain English, what is running, and the one item to start with. How every number is worked out. | |
| 3 | + | description: Your workspace's front page. A box that asks g1t, your move (everything waiting on you, and the one item to start with), the agents, runs and builds going now, this week's work day by day with what is worth a look, and what happened since you were last here in plain English. How every number is worked out. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 6 | 6 | **Home** is the first page of your workspace, at `/<workspace>/-/home`. | |
| 7 | − | Across the top is [the past week](#the-past-week) in four figures, each | |
| 8 | − | against the week before. Under it, the main column is read top to | |
| 9 | − | bottom: | |
| 7 | + | It reads top to bottom, from what you can do now to what already happened: | |
| 10 | 8 | ||
| 11 | − | 1. **[The daily signal](#the-daily-signal)**: what the workspace's work | |
| 12 | − | says today. Its headline is the finding that matters most, and the | |
| 13 | − | others follow, each a sentence with its evidence and where to see it. | |
| 14 | − | Under them is a box that [asks g1t](#ask-from-home) about any of it. | |
| 15 | − | 2. **Where you're needed**: everything waiting on you, however long ago | |
| 9 | + | 1. **What are we building today?**: a box that [asks g1t](#ask-from-home) | |
| 10 | + | anything about your work, with questions to start with. | |
| 11 | + | 2. **[Your move](#your-move)**: everything waiting on you, however long ago | |
| 16 | 12 | it started, with the action beside each item. Its first row is | |
| 17 | − | [Start here](#start-here), the one item to do first, and why. | |
| 18 | − | 3. **Since you were last here**: one card for the span. It shows what people | |
| 13 | + | [Start here](#start-here), the one item to do first, and why. Beside it, | |
| 14 | + | **[Live](#live)**: the agents at work, workflow runs and builds going | |
| 15 | + | now, each agent session with what it has cost so far. | |
| 16 | + | 3. **[This week](#this-week)**: the week in a sentence and four figures, | |
| 17 | + | the last two weeks day by day (what agents and people landed), and what | |
| 18 | + | is [worth a look](#worth-a-look), each finding with its evidence and | |
| 19 | + | pinned to the day it happened where it happened on one. | |
| 20 | + | 4. **Since you were last here**: one card for the span. It shows what people | |
| 19 | 21 | did, what changed in plain English when you ask for it, and what landed, | |
| 20 | − | each change with whether it is live yet. | |
| 21 | − | ||
| 22 | − | The side column stays in view as you scroll: | |
| 23 | − | ||
| 24 | − | - **[Running now](#running-now)**: workflow runs and builds still going. | |
| 25 | − | - **[Spent](#spent)**: what the span cost, and the month so far. | |
| 26 | − | - **[Settled for you](#settled-for-you)**: what finished by itself. | |
| 22 | + | each change with whether it is live yet. Beside it, kept in view as you | |
| 23 | + | scroll: **[Spent](#spent)**, what the span cost and the month so far, | |
| 24 | + | and **[Settled for you](#settled-for-you)**, what finished by itself. | |
| 27 | 25 | ||
| 28 | − | On a phone it is one column: the past week, the daily signal, Where | |
| 29 | − | you're needed, Running now, the span, then Spent and Settled for you. | |
| 26 | + | On a phone it is one column, in the same order. | |
| 30 | 27 | ||
| 31 | 28 | For a workspace's owners, Home also opens | |
| 32 | 29 | [Getting started](/guides/workspaces/#getting-started), the setup guide, | |
| ⋯ | |||
| 39 | 36 | so, and the rest of the page still shows. Only what you ask g1t is written | |
| 40 | 37 | by a model: [What changed](#what-changed) and the answers in Ask. | |
| 41 | 38 | ||
| 42 | − | ## The past week | |
| 39 | + | ## This week | |
| 43 | 40 | ||
| 44 | − | Four figures for the last seven UTC days. Each shows how it moved since the | |
| 45 | − | seven days before (green when that is good news, red when it is not) and a | |
| 46 | − | line over the last ten weeks. Point at the line, or focus it and use the | |
| 47 | − | arrow keys, to read any week. Each figure opens the page behind it. | |
| 41 | + | A sentence for what landed in the last seven UTC days and how much of it | |
| 42 | + | was agents', then four figures. Each shows how it moved since the seven | |
| 43 | + | days before (green when that is good news, red when it is not); point at | |
| 44 | + | one for what it counts, and it opens the page behind it. | |
| 48 | 45 | ||
| 49 | − | | Figure | What it counts | | |
| 50 | − | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 51 | − | | **Agent-authored changes** | Pull requests that merged in the week and were an [agent's](#whose-work-is-an-agents), and the commits agent sessions pushed outside any pull request (to the default branch, most of all), each once. A workspace that pushes straight to main lands most of its work the second way. | | |
| 52 | − | | **Spend per agent change** | The week's agent [usage](/guides/usage-and-billing/#the-usage-page) at price, plus what the [sessions](/guides/sessions/) started in it cost (estimated at list price from their token usage), divided by the agent changes that landed in it: an average, not what any one cost. A dash when usage can't be read or no agent change landed. | | |
| 53 | − | | **Stalled changes** | Pull requests open at the end of each week (now, for this one), opened three days or more before, with nothing in the three days before: no push, comment, review, timeline event, check or session. Drafts count. | | |
| 54 | − | | **Agent rework rate** | Of the agent pull requests that merged in the week (direct commits get no review, so they're not counted here), the share a person asked for changes on before they merged. | | |
| 46 | + | Under them are the last fourteen UTC days as columns: what agents landed | |
| 47 | + | (their merged pull requests and the commits their sessions pushed | |
| 48 | + | directly) in the accent, what people merged above it, last week's days | |
| 49 | + | faded. Point at a day, or focus it, to read it in full: what landed, by | |
| 50 | + | whom, and the sessions started. A finding that happened on one day, such | |
| 51 | + | as a milestone that closed, is pinned over it with the number it has in | |
| 52 | + | [Worth a look](#worth-a-look). | |
| 53 | + | ||
| 54 | + | | Figure | What it counts | | |
| 55 | + | | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 56 | + | | **Landed by agents** | Pull requests that merged in the week and were an [agent's](#whose-work-is-an-agents), and the commits agent sessions pushed outside any pull request (to the default branch, most of all), each once. A workspace that pushes straight to main lands most of its work the second way. | | |
| 57 | + | | **Cost per agent change** | The week's agent [usage](/guides/usage-and-billing/#the-usage-page) at price, plus what the [sessions](/guides/sessions/) started in it cost (estimated at list price from their token usage), divided by the agent changes that landed in it: an average, not what any one cost. A dash when usage can't be read or no agent change landed. | | |
| 58 | + | | **Gone quiet** | Pull requests open at the end of each week (now, for this one), opened three days or more before, with nothing in the three days before: no push, comment, review, timeline event, check or session. Drafts count. | | |
| 59 | + | | **Sent back** | Of the agent pull requests that merged in the week (direct commits get no review, so they're not counted here), the share a person asked for changes on before they merged. | | |
| 55 | 60 | ||
| 56 | 61 | ### Whose work is an agent's | |
| 57 | 62 | ||
| ⋯ | |||
| 61 | 66 | for it, else its author, so an agent's work counts toward the person it | |
| 62 | 67 | was for. | |
| 63 | 68 | ||
| 64 | − | ## The daily signal | |
| 69 | + | ### Worth a look | |
| 65 | 70 | ||
| 66 | − | First the day, then one sentence for the finding that matters most today, | |
| 67 | − | then the others. Each finding names its evidence in a line of figures, | |
| 68 | − | links to where you can see it, and has **Ask g1t**, which asks | |
| 69 | − | [Ask](/guides/ask/) to look into it. A finding with a trend has its line | |
| 70 | − | beside it. When nothing stands out, the headline says what the week did. | |
| 71 | + | Under the days, what the work says is worth a look, most important first. | |
| 72 | + | Each finding is a sentence with its evidence after it, a link to where you | |
| 73 | + | can see it, and **Ask g1t**, which asks [Ask](/guides/ask/) to look into | |
| 74 | + | it. A finding with a trend has its line beside it. When nothing stands | |
| 75 | + | out against the workspace's last weeks, it says so. None of it is about | |
| 76 | + | what needs you: that is [Your move](#your-move). | |
| 71 | 77 | ||
| 72 | − | | Finding | When it shows | | |
| 73 | − | | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 74 | − | | **Work items waiting on one unreviewed change** | An open pull request no one has reviewed is for a work item that other open work items are blocked by. The one holding up the most leads. | | |
| 75 | − | | **Agent drafts waiting for review** | This week's first reviews waited at least four times as long as agents took to open the pull requests (from their session starting), and at least four hours. Without that gap: first reviews took half as long again as the weeks before. | | |
| 76 | − | | **A milestone shipped** | A milestone closed in the last seven days, against its due date, with its weeks in build, its merged pull requests and its projects. | | |
| 77 | − | | **Where agent spend is going** | More than a third of the quarter's agent spend by pull request went into one milestone (else one work item, else one project). Spend is g1t's usage on each pull request and its share of its sessions' cost. | | |
| 78 | − | | **Stalled changes rising** | At least three more stalled changes than last week, and a quarter more. | | |
| 79 | − | | **Agent rework moving** | People asked for changes on agents' merged work at least 8 points more, or less, often than over the four weeks before (with five agent merges or more). | | |
| 80 | − | | **A practice catching on** | Two or more people started using a harness (Claude Code, Codex, Cursor and so on) this month, at least two weeks after the first person did. | | |
| 81 | − | | **Who works where** | In a workspace where three people or more merged lately, someone started merging to a project one person has carried (three quarters of its last 90 days' merges); or merged to a project for the first time this week after work elsewhere. | | |
| 78 | + | | Finding | When it shows | | |
| 79 | + | | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 80 | + | | **One unreviewed change holding up work** | An open pull request no one has reviewed is for a work item that other open work items are blocked by. The one holding up the most leads. | | |
| 81 | + | | **Agent drafts waiting for review** | This week's first reviews waited at least four times as long as agents took to open the pull requests (from their session starting), and at least four hours. Without that gap: first reviews took half as long again as the weeks before. | | |
| 82 | + | | **A milestone closed** | A milestone closed in the last seven days, against its due date, with its weeks in build, its merged pull requests and its projects. | | |
| 83 | + | | **Where agent spend is going** | More than a third of the quarter's agent spend by pull request went into one milestone (else one work item, else one project). Spend is g1t's usage on each pull request and its share of its sessions' cost. | | |
| 84 | + | | **More changes gone quiet** | At least three more stalled changes than last week, and a quarter more. | | |
| 85 | + | | **Agent rework moving** | People asked for changes on agents' merged work at least 8 points more, or less, often than over the four weeks before (with five agent merges or more). | | |
| 86 | + | | **A practice catching on** | Two or more people started using a harness (Claude Code, Codex, Cursor and so on) this month, at least two weeks after the first person did. | | |
| 87 | + | | **Who works where** | In a workspace where three people or more merged lately, someone started merging to a project one person has carried (three quarters of its last 90 days' merges); or merged to a project for the first time this week after work elsewhere. | | |
| 82 | 88 | ||
| 83 | 89 | Findings are ordered by how much they hold up: blocked work first, then | |
| 84 | − | waits, then what shipped and where spend went. Up to five show, and the | |
| 90 | + | waits, then what closed and where spend went. Up to four show, and the | |
| 85 | 91 | rest are one click away. What the work says is read again at most every | |
| 86 | 92 | five minutes for each person, so a figure can be up to five minutes | |
| 87 | 93 | behind. The same reads, at more length, are on the | |
| ⋯ | |||
| 89 | 95 | ||
| 90 | 96 | ## Ask from Home | |
| 91 | 97 | ||
| 92 | − | The box under the daily signal asks g1t anything about your work. Type a question and | |
| 98 | + | The box at the top of Home asks g1t anything about your work. Type a question and | |
| 93 | 99 | press **Enter** (**Shift+Enter** starts a new line): the top bar's | |
| 94 | 100 | [Ask](/guides/ask/) panel opens and sends it, and the answer streams in | |
| 95 | 101 | there. Under the box are questions to start with: | |
| ⋯ | |||
| 109 | 115 | still answering an earlier question, the panel opens with your question | |
| 110 | 116 | in its box rather than sending it, so nothing you typed is lost. | |
| 111 | 117 | ||
| 112 | − | ## Where you're needed | |
| 118 | + | ## Your move | |
| 113 | 119 | ||
| 114 | 120 | This list has no time window: something that started waiting a week ago | |
| 115 | 121 | is here until you deal with it. Each row says what it is, what is at | |
| ⋯ | |||
| 145 | 151 | 6. Mentions and direct messages. | |
| 146 | 152 | ||
| 147 | 153 | The list shows the first eight rows; **Show more** opens the rest. The | |
| 148 | − | sentence under the page's heading starts with how many things need you. | |
| 154 | + | line under its heading says how many things need you. | |
| 149 | 155 | ||
| 150 | 156 | ## Start here | |
| 151 | 157 | ||
| 152 | − | **Start here** is the first row of Where you're needed, set apart at the | |
| 158 | + | **Start here** is the first row of Your move, set apart at the | |
| 153 | 159 | top: the most pressing tier, and within it the row that has waited | |
| 154 | 160 | longest. g1t picks it with that rule, so the same rows always give the | |
| 155 | 161 | same pick. It says why, such as "Everything it needs is met; it lands when | |
| ⋯ | |||
| 192 | 198 | - With no visit on record, Home shows the last 24 hours and says so. After | |
| 193 | 199 | more than 14 days away, it shows the last 14 days. | |
| 194 | 200 | ||
| 195 | − | The sentence under the heading sums it up, for example: | |
| 201 | + | The sentence under the card's heading sums it up, for example: | |
| 196 | 202 | ||
| 197 | − | > 8 things need you. Since Tuesday evening, 3 people pushed 41 commits and | |
| 198 | − | > merged 5 changes, and 12 changes landed. | |
| 203 | + | > Since Tuesday evening, 3 people pushed 41 commits and merged 5 | |
| 204 | + | > changes, and 12 changes landed. | |
| 199 | 205 | ||
| 200 | 206 | It says what people did first (the commits they pushed and the changes | |
| 201 | 207 | they merged; if neither, the messages they sent), then everything that | |
| ⋯ | |||
| 327 | 333 | requests, pushes and releases, deployments) didn't answer, the card says | |
| 328 | 334 | so under the list. | |
| 329 | 335 | ||
| 330 | − | ## Running now | |
| 336 | + | ## Live | |
| 331 | 337 | ||
| 332 | − | At the top of the side column, what is going right now, longest-going | |
| 333 | − | first, each with its state and how long it has gone: | |
| 338 | + | Beside Your move, what is going right now. First the agents at work: each | |
| 339 | + | live [session](/guides/sessions/) of a harness (Claude Code, Codex and so | |
| 340 | + | on) or a hosted agent, with what it is on, who runs it, how long it has | |
| 341 | + | gone, its subagents, and what it has cost so far, estimated at list price | |
| 342 | + | from its token usage. Then workflow runs and builds, longest-going first, | |
| 343 | + | each with its state and how long it has gone: | |
| 334 | 344 | ||
| 335 | 345 | | Kind | What it is | | |
| 336 | 346 | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | |
| ⋯ | |||
| 339 | 349 | ||
| 340 | 350 | ## Spent | |
| 341 | 351 | ||
| 342 | − | In the side column: what the workspace spent over the span, from its | |
| 352 | + | Beside the span: what the workspace spent over it, from its | |
| 343 | 353 | [statement](/guides/usage-and-billing/), with each line at its price: what | |
| 344 | 354 | was charged plus what your plan's included usage, a trial, a pool or a | |
| 345 | 355 | discount paid of it. The lines are the statement's kinds of charge, such | |
| ⋯ | |||
| 356 | 366 | ||
| 357 | 367 | ## Settled for you | |
| 358 | 368 | ||
| 359 | − | At the bottom of the side column: your notifications in this workspace | |
| 369 | + | Under Spent: your notifications in this workspace | |
| 360 | 370 | that [settled themselves](/guides/notifications/#threads-that-settle-themselves) | |
| 361 | 371 | in the span, newest first, each with why, such as **CI passed in run 330** | |
| 362 | 372 | or **Merged by @ana**: a later run passed, the pull request merged, the | |
| ⋯ | |||
| 368 | 378 | Home keeps up with the workspace without a reload. What lands, starts | |
| 369 | 379 | running, finishes or comes to need you while you are on the page joins its | |
| 370 | 380 | list, sliding in at its place and lit for a moment; counts and the | |
| 371 | − | sentence under the heading update with it. Runs that finish leave Running | |
| 372 | − | now. | |
| 381 | + | lines update with it. Sessions that end and runs that finish leave Live. | |
| 373 | 382 | ||
| 374 | 383 | | Home updates when | From | | |
| 375 | 384 | | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | | |
| ⋯ | |||
| 394 | 403 | A member whose membership does not include Code sees Home without anything | |
| 395 | 404 | from Code: | |
| 396 | 405 | ||
| 397 | − | - The past week and the daily signal are not shown. The page opens with | |
| 398 | − | **What are we building today?** and the box that asks g1t. | |
| 406 | + | - This week is not shown. | |
| 399 | 407 | - People shows what was said and the artifacts edited; nothing from | |
| 400 | 408 | repositories. | |
| 401 | − | - Where you're needed has no rows from Code, and no notifications about a | |
| 409 | + | - Your move has no rows from Code, and no notifications about a | |
| 402 | 410 | repository. | |
| 403 | − | - Landed and Running now are not shown. | |
| 411 | + | - Landed is not shown, and Live has no sessions, runs or builds. | |
| 404 | 412 | - Spent is the same for every member. | |
| 405 | 413 | ||
| 406 | 414 | ## When something doesn't answer | |
| ⋯ | |||
| 408 | 416 | Each section reads its own part of g1t and shows as soon as it has it. | |
| 409 | 417 | When one does not answer: | |
| 410 | 418 | ||
| 411 | − | - Where you're needed and Running now name the parts that didn't answer, | |
| 419 | + | - Your move and Live name the parts that didn't answer, | |
| 412 | 420 | so an empty list never reads as nothing waiting. | |
| 413 | 421 | - People counts what could be read, and names the part that is missing | |
| 414 | 422 | (Code or Chat). If nothing could be read, the card says so, and the | |
| 415 | − | sentence under the heading says what people did couldn't be read. | |
| 423 | + | sentence under the card's heading says what people did couldn't be read. | |
| 416 | 424 | - Landed and Spent each say their part didn't answer. When | |
| 417 | 425 | deployments or sessions can't be read, Landed shows its rows without | |
| 418 | 426 | their live labels or agent markers. | |
| 419 | 427 | - Settled for you says when your notifications can't be read. | |
| 420 | − | - The daily signal says when the work couldn't be read, and the past week | |
| 421 | − | is left out. When some projects didn't answer, it says its findings | |
| 422 | − | leave them out. | |
| 428 | + | - This week says when the work couldn't be read. When some projects | |
| 429 | + | didn't answer, it says the week leaves them out. | |
| 423 | 430 | - What changed says when Ask can't be reached or isn't set up, or when | |
| 424 | 431 | the question could not be sent. | |
| 425 | 432 | - If your last visit can't be read, the span is the last 24 hours and the | |
| 99 | 99 | A thread that leaves you nothing to do moves to Done by itself, read, the | |
| 100 | 100 | moment what it told you of is over: a later run passes, the pull request | |
| 101 | 101 | merges, the review is no longer asked of you. It leaves your unread count, | |
| 102 | − | the bell, [Home](/guides/home/#where-youre-needed)'s **Where you're | |
| 103 | − | needed** and `g1t status`, and | |
| 102 | + | the bell, [Home](/guides/home/#your-move)'s **Your | |
| 103 | + | move** and `g1t status`, and | |
| 104 | 104 | stays under **Done** with its history. New activity brings it back, as with | |
| 105 | 105 | any thread. | |
| 106 | 106 |
| 675 | 675 | ||
| 676 | 676 | The workspace's **Overview**, `g1t.sh/<workspace>/-/workspace`, opens with | |
| 677 | 677 | what its work says, for members who can see Code. It is worked out the | |
| 678 | − | same way as [Home's daily signal](/guides/home/#the-daily-signal), and | |
| 678 | + | same way as [Home's week](/guides/home/#this-week), and | |
| 679 | 679 | each person sees only the projects they can read. | |
| 680 | 680 | ||
| 681 | − | 1. **[The past week](/guides/home/#the-past-week)**: the same four | |
| 681 | + | 1. **[The past week](/guides/home/#this-week)**: the same four | |
| 682 | 682 | figures as Home. | |
| 683 | 683 | 2. **Spend, threaded to the work**: the quarter's work in four columns: | |
| 684 | 684 | each milestone, its work items, each work item's change set in each |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | 36 | /** | |
| 37 | − | * Home's box that asks g1t, with a few things to ask first: under the | |
| 38 | − | * daily signal, `compact`, it says to ask about any of it; on its own, it | |
| 39 | − | * leads the page with what are we building today. | |
| 37 | + | * Home's box that asks g1t, with a few things to ask first. It leads the | |
| 38 | + | * page, above everything it can be asked about, with what are we building | |
| 39 | + | * today. | |
| 40 | 40 | */ | |
| 41 | − | export function HomeAsk({ span, compact = false }: { span: AskSpan; compact?: boolean }) { | |
| 41 | + | export function HomeAsk({ span }: { span: AskSpan }) { | |
| 42 | 42 | const [draft, setDraft] = useState(""); | |
| 43 | 43 | const send = () => { | |
| 44 | 44 | if (!draft.trim()) return; | |
| ⋯ | |||
| 56 | 56 | } | |
| 57 | 57 | }; | |
| 58 | 58 | return ( | |
| 59 | − | <section | |
| 60 | − | aria-labelledby="home-ask" | |
| 61 | − | className={compact ? "flex w-full flex-col gap-3" : "mx-auto flex w-full max-w-3xl flex-col items-center gap-5 pt-4 pb-2 md:pt-8"} | |
| 62 | − | > | |
| 63 | − | {compact ? ( | |
| 64 | − | <h2 id="home-ask" className="text-sm font-semibold text-fg"> | |
| 65 | − | Ask about any of this | |
| 66 | − | </h2> | |
| 67 | − | ) : ( | |
| 68 | − | <h1 id="home-ask" className="text-center text-[1.75rem] leading-tight font-semibold tracking-tight md:text-[2rem]"> | |
| 69 | − | What are we building today? | |
| 70 | − | </h1> | |
| 71 | − | )} | |
| 59 | + | <section aria-labelledby="home-ask" className="mx-auto flex w-full max-w-5xl flex-col items-center gap-5 pt-4 pb-3 md:pt-8 md:pb-5"> | |
| 60 | + | <h1 id="home-ask" className="text-center text-[1.75rem] leading-tight font-semibold tracking-tight md:text-[2rem]"> | |
| 61 | + | What are we building today? | |
| 62 | + | </h1> | |
| 72 | 63 | <form onSubmit={submit} className="w-full"> | |
| 73 | 64 | <div className="flex items-end gap-2 rounded-2xl border border-line bg-surface p-2 pl-4 shadow-sm transition-shadow focus-within:border-accent-dim focus-within:ring-2 focus-within:ring-accent/25"> | |
| 74 | 65 | <MarkGlyph className="mb-2.5 h-4 shrink-0 max-sm:hidden" /> | |
| ⋯ | |||
| 92 | 83 | </Hint> | |
| 93 | 84 | </div> | |
| 94 | 85 | </form> | |
| 95 | − | <Suggestions className={compact ? "" : "justify-center"}> | |
| 86 | + | <Suggestions className="justify-center"> | |
| 96 | 87 | <Suggestion | |
| 97 | 88 | suggestion={`Summarize what changed ${spanLabel(span)}`} | |
| 98 | 89 | onPick={() => window.dispatchEvent(new CustomEvent(SUMMARIZE_EVENT))} | |
| 1 | + | /** | |
| 2 | + | * Home's week (lib/signals.ts): the week in a sentence and four figures, | |
| 3 | + | * the last two weeks day by day (what agents and people landed), and what | |
| 4 | + | * is worth a look, each pinned to the day it happened where it happened | |
| 5 | + | * on one. The page leads with what needs you; this is the work's own | |
| 6 | + | * account, so it never says whether anything needs anyone. | |
| 7 | + | */ | |
| 8 | + | import { ChevronRight, MessageSquare, TriangleAlert } from "lucide-react"; | |
| 9 | + | import { useState } from "react"; | |
| 10 | + | import { Link } from "react-router"; | |
| 11 | + | ||
| 12 | + | import { askG1t } from "../lib/ask"; | |
| 13 | + | import { cn } from "../lib/cn"; | |
| 14 | + | import type { DailySignal, DayBar, PulseTile, Signal } from "../lib/signals"; | |
| 15 | + | import { TrendLine } from "./signals"; | |
| 16 | + | import { Hint } from "./ui/hint"; | |
| 17 | + | import { Skeleton } from "./ui/skeleton"; | |
| 18 | + | ||
| 19 | + | const DELTA_TONE: Record<"good" | "bad" | "flat", string> = { good: "text-success", bad: "text-danger", flat: "text-faint" }; | |
| 20 | + | ||
| 21 | + | /** One figure: what it counts, this week's value, and the change on last week. */ | |
| 22 | + | function Figure({ tile }: { tile: PulseTile }) { | |
| 23 | + | return ( | |
| 24 | + | <Hint label={tile.hint}> | |
| 25 | + | <Link to={tile.to} className="group min-w-0 rounded-lg px-3 py-2 transition-colors hover:bg-raised"> | |
| 26 | + | <p className="truncate text-xs text-muted group-hover:text-fg">{tile.label}</p> | |
| 27 | + | <p className="mt-0.5 flex items-baseline gap-2"> | |
| 28 | + | <span className="text-xl font-semibold tracking-tight text-fg tabular-nums">{tile.value}</span> | |
| 29 | + | {tile.delta && ( | |
| 30 | + | <span className={cn("text-xs tabular-nums", DELTA_TONE[tile.delta.tone])}> | |
| 31 | + | {tile.delta.text} | |
| 32 | + | <span className="text-faint"> on last week</span> | |
| 33 | + | </span> | |
| 34 | + | )} | |
| 35 | + | </p> | |
| 36 | + | </Link> | |
| 37 | + | </Hint> | |
| 38 | + | ); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | function dayName(day: string, style: "short" | "long"): string { | |
| 42 | + | const date = new Date(`${day}T00:00:00Z`); | |
| 43 | + | return style === "short" | |
| 44 | + | ? date.toLocaleDateString("en-US", { weekday: "narrow", timeZone: "UTC" }) | |
| 45 | + | : date.toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric", timeZone: "UTC" }); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | function dayWords(bar: DayBar): string { | |
| 49 | + | const landed = bar.agent + bar.people; | |
| 50 | + | if (landed === 0 && bar.sessions === 0) return `${dayName(bar.day, "long")}: nothing landed`; | |
| 51 | + | const parts = [`${landed} landed`, `${bar.agent} by agents`, `${bar.people} by people`]; | |
| 52 | + | if (bar.sessions > 0) parts.push(`${bar.sessions} ${bar.sessions === 1 ? "session" : "sessions"} started`); | |
| 53 | + | return `${dayName(bar.day, "long")}: ${parts.join(", ")}`; | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /** | |
| 57 | + | * Two weeks of days as columns: agents' changes in the accent, people's | |
| 58 | + | * above them, last week's faded. A finding that happened on a day is | |
| 59 | + | * pinned over it with its number in the list below. | |
| 60 | + | */ | |
| 61 | + | function Days({ days, pins }: { days: DayBar[]; pins: Map<string, number[]> }) { | |
| 62 | + | const [active, setActive] = useState<number | null>(null); | |
| 63 | + | const top = Math.max(1, ...days.map((bar) => bar.agent + bar.people)); | |
| 64 | + | const thisWeek = days.length - 7; | |
| 65 | + | const shown = active ?? days.length - 1; | |
| 66 | + | return ( | |
| 67 | + | <figure className="px-5"> | |
| 68 | + | <div | |
| 69 | + | className="grid h-36 items-end gap-1.5 sm:gap-2" | |
| 70 | + | style={{ gridTemplateColumns: `repeat(${days.length}, minmax(0, 1fr))` }} | |
| 71 | + | onPointerLeave={() => setActive(null)} | |
| 72 | + | > | |
| 73 | + | {days.map((bar, i) => { | |
| 74 | + | const landed = bar.agent + bar.people; | |
| 75 | + | const pinned = pins.get(bar.day); | |
| 76 | + | return ( | |
| 77 | + | <button | |
| 78 | + | key={bar.day} | |
| 79 | + | type="button" | |
| 80 | + | aria-label={dayWords(bar)} | |
| 81 | + | onPointerEnter={() => setActive(i)} | |
| 82 | + | onFocus={() => setActive(i)} | |
| 83 | + | onBlur={() => setActive(null)} | |
| 84 | + | className={cn( | |
| 85 | + | "group relative flex h-full flex-col items-stretch justify-end rounded-md outline-none focus-visible:ring-2 focus-visible:ring-accent/40", | |
| 86 | + | i < thisWeek && "opacity-45", | |
| 87 | + | active === i && "opacity-100", | |
| 88 | + | )} | |
| 89 | + | > | |
| 90 | + | {pinned && ( | |
| 91 | + | <span className="absolute inset-x-0 -top-1 flex justify-center gap-0.5"> | |
| 92 | + | {pinned.map((n) => ( | |
| 93 | + | <span key={n} className="flex size-4 items-center justify-center rounded-full bg-warn text-[0.625rem] font-semibold text-bg"> | |
| 94 | + | {n} | |
| 95 | + | </span> | |
| 96 | + | ))} | |
| 97 | + | </span> | |
| 98 | + | )} | |
| 99 | + | <span | |
| 100 | + | className="mx-auto flex w-full max-w-11 flex-col justify-end overflow-hidden rounded-[5px]" | |
| 101 | + | style={{ height: `${Math.max(landed > 0 ? 6 : 2, (landed / top) * 80)}%` }} | |
| 102 | + | > | |
| 103 | + | {landed === 0 ? ( | |
| 104 | + | <span className="h-full bg-line" /> | |
| 105 | + | ) : ( | |
| 106 | + | <> | |
| 107 | + | <span className="bg-line-strong transition-colors group-hover:bg-fg-soft/50" style={{ flexGrow: bar.people }} /> | |
| 108 | + | <span className="bg-accent transition-colors group-hover:bg-accent-hover" style={{ flexGrow: bar.agent }} /> | |
| 109 | + | </> | |
| 110 | + | )} | |
| 111 | + | </span> | |
| 112 | + | </button> | |
| 113 | + | ); | |
| 114 | + | })} | |
| 115 | + | </div> | |
| 116 | + | <div className="mt-1.5 grid gap-1.5 sm:gap-2" style={{ gridTemplateColumns: `repeat(${days.length}, minmax(0, 1fr))` }} aria-hidden> | |
| 117 | + | {days.map((bar, i) => ( | |
| 118 | + | <span key={bar.day} className={cn("text-center font-mono text-[0.625rem] text-faint", i === days.length - 1 && "text-fg-soft")}> | |
| 119 | + | {dayName(bar.day, "short")} | |
| 120 | + | </span> | |
| 121 | + | ))} | |
| 122 | + | </div> | |
| 123 | + | <figcaption className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted"> | |
| 124 | + | <span className="text-fg-soft" aria-live="polite"> | |
| 125 | + | {days[shown] ? dayWords(days[shown]) : ""} | |
| 126 | + | </span> | |
| 127 | + | <span className="ml-auto flex items-center gap-3"> | |
| 128 | + | <span className="flex items-center gap-1.5"> | |
| 129 | + | <span className="size-2 rounded-sm bg-accent" /> | |
| 130 | + | Agents | |
| 131 | + | </span> | |
| 132 | + | <span className="flex items-center gap-1.5"> | |
| 133 | + | <span className="size-2 rounded-sm bg-line-strong" /> | |
| 134 | + | People | |
| 135 | + | </span> | |
| 136 | + | <span className="text-faint">last week faded</span> | |
| 137 | + | </span> | |
| 138 | + | </figcaption> | |
| 139 | + | </figure> | |
| 140 | + | ); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | /** A finding: what the work says, the evidence after it, where to see it, and Ask. */ | |
| 144 | + | function Finding({ signal, n }: { signal: Signal; n: number | null }) { | |
| 145 | + | return ( | |
| 146 | + | <li className={cn("grid gap-3 border-t border-line px-5 py-4", signal.trend && "md:grid-cols-[minmax(0,1fr)_9rem] md:gap-6")}> | |
| 147 | + | <div className="flex min-w-0 gap-3"> | |
| 148 | + | <span | |
| 149 | + | className={cn( | |
| 150 | + | "mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full text-[0.6875rem] font-semibold", | |
| 151 | + | n != null ? "bg-warn text-bg" : "border border-line-strong text-faint", | |
| 152 | + | )} | |
| 153 | + | aria-hidden | |
| 154 | + | > | |
| 155 | + | {n ?? "·"} | |
| 156 | + | </span> | |
| 157 | + | <div className="min-w-0 space-y-1"> | |
| 158 | + | <p className="text-sm leading-snug font-medium text-fg">{signal.headline}</p> | |
| 159 | + | <p className="text-xs leading-relaxed text-muted">{signal.facts.join(" · ")}</p> | |
| 160 | + | <div className="flex flex-wrap items-center gap-x-4 gap-y-1 pt-0.5"> | |
| 161 | + | {signal.link && ( | |
| 162 | + | <Link to={signal.link.to} className="inline-flex items-center gap-0.5 text-xs font-medium text-accent hover:underline"> | |
| 163 | + | {signal.link.label} | |
| 164 | + | <ChevronRight size={12} /> | |
| 165 | + | </Link> | |
| 166 | + | )} | |
| 167 | + | <button type="button" onClick={() => askG1t(signal.ask)} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 168 | + | <MessageSquare size={11} /> | |
| 169 | + | Ask g1t | |
| 170 | + | </button> | |
| 171 | + | </div> | |
| 172 | + | </div> | |
| 173 | + | </div> | |
| 174 | + | {signal.trend && ( | |
| 175 | + | <div className="hidden md:block"> | |
| 176 | + | <TrendLine | |
| 177 | + | values={signal.trend.values} | |
| 178 | + | points={signal.trend.points} | |
| 179 | + | label={`${signal.trend.caption}, ${signal.trend.range}`} | |
| 180 | + | width={144} | |
| 181 | + | height={32} | |
| 182 | + | /> | |
| 183 | + | <p className="mt-1 font-mono text-[0.625rem] text-faint">{signal.trend.range}</p> | |
| 184 | + | </div> | |
| 185 | + | )} | |
| 186 | + | </li> | |
| 187 | + | ); | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | /** Findings shown before "N more". */ | |
| 191 | + | const SHOWN = 4; | |
| 192 | + | ||
| 193 | + | /** Home's week: its figures, its days with findings pinned, and what is worth a look. */ | |
| 194 | + | export function HomeWeek({ view }: { view: DailySignal | null }) { | |
| 195 | + | const [more, setMore] = useState(false); | |
| 196 | + | if (view == null) { | |
| 197 | + | return ( | |
| 198 | + | <section aria-labelledby="week" className="rounded-2xl border border-line bg-surface px-5 py-4"> | |
| 199 | + | <h2 id="week" className="text-base font-semibold tracking-tight text-fg"> | |
| 200 | + | This week | |
| 201 | + | </h2> | |
| 202 | + | <p className="mt-2 flex items-center gap-1.5 text-sm text-muted"> | |
| 203 | + | <TriangleAlert size={13} className="shrink-0 text-warn" /> | |
| 204 | + | The week's work couldn't be read just now. | |
| 205 | + | </p> | |
| 206 | + | </section> | |
| 207 | + | ); | |
| 208 | + | } | |
| 209 | + | // A number for each finding pinned to a day in the chart, in the list's order. | |
| 210 | + | const days = new Set(view.days.map((bar) => bar.day)); | |
| 211 | + | const numbers = new Map<string, number>(); | |
| 212 | + | const pins = new Map<string, number[]>(); | |
| 213 | + | for (const signal of view.findings) { | |
| 214 | + | if (!signal.day || !days.has(signal.day)) continue; | |
| 215 | + | const n = numbers.size + 1; | |
| 216 | + | numbers.set(signal.key, n); | |
| 217 | + | pins.set(signal.day, [...(pins.get(signal.day) ?? []), n]); | |
| 218 | + | } | |
| 219 | + | const shown = more ? view.findings : view.findings.slice(0, SHOWN); | |
| 220 | + | return ( | |
| 221 | + | <section aria-labelledby="week" className="rounded-2xl border border-line bg-surface"> | |
| 222 | + | <header className="flex flex-wrap items-baseline gap-x-3 gap-y-1 px-5 pt-4"> | |
| 223 | + | <h2 id="week" className="text-base font-semibold tracking-tight text-fg"> | |
| 224 | + | This week | |
| 225 | + | </h2> | |
| 226 | + | <p className="text-sm text-muted">{view.summary}</p> | |
| 227 | + | </header> | |
| 228 | + | {view.tiles.length > 0 && ( | |
| 229 | + | <div className="mt-2 grid grid-cols-2 gap-1 px-2 lg:grid-cols-4"> | |
| 230 | + | {view.tiles.map((tile) => ( | |
| 231 | + | <Figure key={tile.key} tile={tile} /> | |
| 232 | + | ))} | |
| 233 | + | </div> | |
| 234 | + | )} | |
| 235 | + | <div className="mt-4"> | |
| 236 | + | <Days days={view.days} pins={pins} /> | |
| 237 | + | </div> | |
| 238 | + | <div className="mt-5"> | |
| 239 | + | <h3 className="px-5 pb-2 text-xs font-medium text-muted">Worth a look</h3> | |
| 240 | + | {view.findings.length === 0 ? ( | |
| 241 | + | <p className="border-t border-line px-5 py-4 text-sm text-muted">Nothing in the work stands out against its last weeks.</p> | |
| 242 | + | ) : ( | |
| 243 | + | <ul> | |
| 244 | + | {shown.map((signal) => ( | |
| 245 | + | <Finding key={signal.key} signal={signal} n={numbers.get(signal.key) ?? null} /> | |
| 246 | + | ))} | |
| 247 | + | </ul> | |
| 248 | + | )} | |
| 249 | + | {view.findings.length > SHOWN && ( | |
| 250 | + | <button type="button" onClick={() => setMore(!more)} className="border-t border-line px-5 py-3 text-left text-xs text-muted hover:text-fg w-full"> | |
| 251 | + | {more ? "Show fewer" : `${view.findings.length - SHOWN} more worth a look`} | |
| 252 | + | </button> | |
| 253 | + | )} | |
| 254 | + | </div> | |
| 255 | + | {!view.complete && ( | |
| 256 | + | <p className="flex items-center gap-1.5 border-t border-line px-5 py-2.5 text-xs text-faint"> | |
| 257 | + | <TriangleAlert size={12} className="shrink-0" /> | |
| 258 | + | Some projects didn't answer, so the week leaves them out. | |
| 259 | + | </p> | |
| 260 | + | )} | |
| 261 | + | <div className="h-2" /> | |
| 262 | + | </section> | |
| 263 | + | ); | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | export function HomeWeekSkeleton() { | |
| 267 | + | return ( | |
| 268 | + | <section className="space-y-4 rounded-2xl border border-line bg-surface px-5 py-4" aria-busy="true"> | |
| 269 | + | <Skeleton className="h-5 w-72" /> | |
| 270 | + | <div className="grid grid-cols-2 gap-4 lg:grid-cols-4"> | |
| 271 | + | {[0, 1, 2, 3].map((i) => ( | |
| 272 | + | <Skeleton key={i} className="h-10" /> | |
| 273 | + | ))} | |
| 274 | + | </div> | |
| 275 | + | <Skeleton className="h-36 w-full" /> | |
| 276 | + | </section> | |
| 277 | + | ); | |
| 278 | + | } |
| 1 | 1 | /** | |
| 2 | − | * Home's parts (routes/workspace/home.tsx): Where you're needed, Start | |
| 3 | − | * here, what happened in the span (what people did, what landed, builds, | |
| 4 | − | * spend) and Running now. Each takes what | |
| 2 | + | * Home's parts (routes/workspace/home.tsx): Your move, Start here, what | |
| 3 | + | * happened in the span (what people did, what landed, builds, spend) and | |
| 4 | + | * Live (sessions, runs and builds going now). Each takes what | |
| 5 | 5 | * lib/home.ts worked out and only draws it; a row that arrived while the | |
| 6 | 6 | * page was open comes in (`animate-arrive`, lib/use-home-live.ts). | |
| 7 | 7 | */ | |
| ⋯ | |||
| 28 | 28 | import type { ReactNode } from "react"; | |
| 29 | 29 | import { Link } from "react-router"; | |
| 30 | 30 | ||
| 31 | + | import type { WorkspaceSession } from "@g1t/contracts"; | |
| 32 | + | ||
| 31 | 33 | import { | |
| 32 | 34 | type Attention, | |
| 33 | 35 | type AttentionKind, | |
| ⋯ | |||
| 60 | 62 | import { useArrived } from "../lib/use-home-live"; | |
| 61 | 63 | import { cn } from "../lib/cn"; | |
| 62 | 64 | import { money } from "../lib/money"; | |
| 65 | + | import { repoName, runsIn, sessionHref, workspaceSessionTitle } from "../lib/sessions"; | |
| 63 | 66 | import { CommitText } from "./commit-message"; | |
| 67 | + | import { HarnessMark, StateDot } from "./sessions"; | |
| 64 | 68 | import { Button } from "./ui/button"; | |
| 65 | 69 | import { Hint } from "./ui/hint"; | |
| 66 | 70 | import { ButtonLink } from "./ui"; | |
| ⋯ | |||
| 842 | 846 | deploy: <Rocket size={15} className="text-info" />, | |
| 843 | 847 | }; | |
| 844 | 848 | ||
| 845 | − | export function RunningList({ rows, missing, now }: { rows: RunningRow[]; missing: string[]; now: number }) { | |
| 849 | + | export function RunningList({ rows, missing, now, sessions = 0 }: { rows: RunningRow[]; missing: string[]; now: number; sessions?: number }) { | |
| 846 | 850 | const arrived = useArrived(rows.map((row) => row.key)); | |
| 847 | 851 | return ( | |
| 848 | 852 | <> | |
| 849 | 853 | {rows.length === 0 ? ( | |
| 850 | − | <Quiet> | |
| 851 | − | {missing.length > 0 ? "Nothing is running in what could be read." : "Nothing is running. Workflow runs and builds show here while they go."} | |
| 852 | − | </Quiet> | |
| 854 | + | // With sessions going above it, an empty list of runs says nothing. | |
| 855 | + | sessions > 0 ? null : ( | |
| 856 | + | <Quiet> | |
| 857 | + | {missing.length > 0 | |
| 858 | + | ? "Nothing is running in what could be read." | |
| 859 | + | : "Nothing is running. Agents at work, workflow runs and builds show here while they go."} | |
| 860 | + | </Quiet> | |
| 861 | + | ) | |
| 853 | 862 | ) : ( | |
| 854 | 863 | <Folded | |
| 855 | 864 | items={rows} | |
| ⋯ | |||
| 878 | 887 | </> | |
| 879 | 888 | ); | |
| 880 | 889 | } | |
| 890 | + | ||
| 891 | + | /** | |
| 892 | + | * The agents at work now, each with what it is on, who runs it, how long | |
| 893 | + | * it has gone and what it has cost so far (list price, from its token | |
| 894 | + | * usage). Only g1t sees these: they are its sessions. | |
| 895 | + | */ | |
| 896 | + | export function LiveSessions({ sessions, slug, now }: { sessions: WorkspaceSession[] | null; slug: string; now: number }) { | |
| 897 | + | const arrived = useArrived((sessions ?? []).map((session) => session.id)); | |
| 898 | + | if (sessions == null) return <p className="px-5 pb-3 text-xs text-faint">Sessions couldn't be read just now.</p>; | |
| 899 | + | if (sessions.length === 0) return null; | |
| 900 | + | return ( | |
| 901 | + | <Folded | |
| 902 | + | items={sessions} | |
| 903 | + | render={(session) => { | |
| 904 | + | const to = sessionHref(slug, session); | |
| 905 | + | const cost = session.session?.costUsd; | |
| 906 | + | const title = workspaceSessionTitle(session); | |
| 907 | + | return ( | |
| 908 | + | <li key={session.id} className={cn(rowClass, "items-start px-4", arrived.has(session.id) && ARRIVED)}> | |
| 909 | + | <span className="mt-1"> | |
| 910 | + | <StateDot state={session.state} /> | |
| 911 | + | </span> | |
| 912 | + | <div className="min-w-0 grow"> | |
| 913 | + | {to ? ( | |
| 914 | + | <Link to={to} className="line-clamp-2 text-sm text-fg hover:underline"> | |
| 915 | + | {title} | |
| 916 | + | </Link> | |
| 917 | + | ) : ( | |
| 918 | + | <p className="line-clamp-2 text-sm text-fg">{title}</p> | |
| 919 | + | )} | |
| 920 | + | <p className="flex min-w-0 items-center gap-1.5 truncate text-xs text-muted"> | |
| 921 | + | <HarnessMark session={session} size={12} /> | |
| 922 | + | <span className="truncate">{[session.actor, runsIn(session), repoName(session.repo, slug)].filter(Boolean).join(" · ")}</span> | |
| 923 | + | </p> | |
| 924 | + | <p className="mt-1 text-xs text-fg-soft tabular-nums" suppressHydrationWarning> | |
| 925 | + | {waited(Date.parse(session.startedAt), now)} | |
| 926 | + | {cost != null && Number.isFinite(cost) && <span className="text-faint"> · {money(Math.round(cost * 1_000_000))} so far</span>} | |
| 927 | + | {session.subagents > 0 && <span className="text-faint"> · {plural(session.subagents, "subagent")}</span>} | |
| 928 | + | </p> | |
| 929 | + | </div> | |
| 930 | + | </li> | |
| 931 | + | ); | |
| 932 | + | }} | |
| 933 | + | /> | |
| 934 | + | ); | |
| 935 | + | } | |
| 1 | 1 | /** | |
| 2 | − | * The daily signal on Home (lib/signals.ts): the past week in four | |
| 3 | − | * figures, each against the week before and drawn over ten, and the | |
| 4 | − | * findings worth a look today, each a sentence with its evidence, where | |
| 5 | − | * to see it, and a way to ask g1t about it. | |
| 2 | + | * The week's figures on the Workspace overview (lib/signals.ts), each | |
| 3 | + | * against the week before and drawn over ten, and the trend line they and | |
| 4 | + | * Home's week (components/home-week.tsx) draw with. | |
| 6 | 5 | */ | |
| 7 | − | import { ChevronRight, MessageSquare, TriangleAlert } from "lucide-react"; | |
| 8 | − | import { type ReactNode, useId, useState } from "react"; | |
| 6 | + | import { useId, useState } from "react"; | |
| 9 | 7 | import { Link } from "react-router"; | |
| 10 | 8 | ||
| 11 | − | import { askG1t } from "../lib/ask"; | |
| 12 | 9 | import { cn } from "../lib/cn"; | |
| 13 | − | import type { DailySignal as DailySignalView, PulseTile, Signal } from "../lib/signals"; | |
| 10 | + | import type { PulseTile } from "../lib/signals"; | |
| 14 | 11 | import { Skeleton } from "./ui/skeleton"; | |
| 15 | 12 | ||
| 16 | 13 | // --- A trend line ------------------------------------------------------------------- | |
| ⋯ | |||
| 157 | 154 | <Skeleton className="h-6 w-16" /> | |
| 158 | 155 | <Skeleton className="h-5 w-32" /> | |
| 159 | 156 | </div> | |
| 160 | − | ))} | |
| 161 | − | </div> | |
| 162 | − | ); | |
| 163 | − | } | |
| 164 | − | ||
| 165 | − | // --- The findings ------------------------------------------------------------------- | |
| 166 | − | ||
| 167 | − | function Facts({ facts }: { facts: string[] }) { | |
| 168 | − | return ( | |
| 169 | − | <p className="font-mono text-[0.75rem] leading-relaxed text-muted"> | |
| 170 | − | {facts.map((fact, i) => ( | |
| 171 | − | <span key={i}> | |
| 172 | − | {i > 0 && <span className="px-1.5 text-faint">·</span>} | |
| 173 | − | {fact} | |
| 174 | − | </span> | |
| 175 | 157 | ))} | |
| 176 | − | </p> | |
| 177 | − | ); | |
| 178 | − | } | |
| 179 | − | ||
| 180 | − | function Actions({ signal }: { signal: Signal }) { | |
| 181 | − | return ( | |
| 182 | − | <div className="flex flex-wrap items-center gap-x-4 gap-y-1"> | |
| 183 | − | {signal.link && ( | |
| 184 | − | <Link to={signal.link.to} className="inline-flex items-center gap-0.5 text-sm font-medium text-accent hover:underline"> | |
| 185 | − | {signal.link.label} | |
| 186 | − | <ChevronRight size={14} /> | |
| 187 | − | </Link> | |
| 188 | − | )} | |
| 189 | − | <button type="button" onClick={() => askG1t(signal.ask)} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg"> | |
| 190 | − | <MessageSquare size={12} /> | |
| 191 | − | Ask g1t | |
| 192 | − | </button> | |
| 193 | 158 | </div> | |
| 194 | − | ); | |
| 195 | − | } | |
| 196 | − | ||
| 197 | − | function Trend({ trend }: { trend: NonNullable<Signal["trend"]> }) { | |
| 198 | − | const [point, setPoint] = useState<number | null>(null); | |
| 199 | − | return ( | |
| 200 | − | <figure className="space-y-1.5"> | |
| 201 | − | <TrendLine values={trend.values} points={trend.points} label={`${trend.caption}, ${trend.range}`} width={196} height={40} onPoint={setPoint} /> | |
| 202 | − | <figcaption> | |
| 203 | − | <p className="text-xs text-fg-soft">{trend.caption}</p> | |
| 204 | − | <p className="font-mono text-[0.6875rem] text-faint">{point != null ? trend.points[point] : trend.range}</p> | |
| 205 | − | </figcaption> | |
| 206 | − | </figure> | |
| 207 | − | ); | |
| 208 | − | } | |
| 209 | − | ||
| 210 | − | function Finding({ signal }: { signal: Signal }) { | |
| 211 | − | return ( | |
| 212 | − | <li className={cn("grid gap-4 py-5 first:pt-0 last:pb-0", signal.trend && "md:grid-cols-[minmax(0,1fr)_12.5rem] md:gap-8")}> | |
| 213 | − | <div className="min-w-0 space-y-2"> | |
| 214 | − | <h3 className="font-display text-lg leading-snug text-fg md:text-xl">{signal.headline}</h3> | |
| 215 | − | <Facts facts={signal.facts} /> | |
| 216 | − | <Actions signal={signal} /> | |
| 217 | − | </div> | |
| 218 | − | {signal.trend && ( | |
| 219 | − | <div className="md:pt-1"> | |
| 220 | − | <Trend trend={signal.trend} /> | |
| 221 | − | </div> | |
| 222 | − | )} | |
| 223 | − | </li> | |
| 224 | − | ); | |
| 225 | − | } | |
| 226 | − | ||
| 227 | − | /** Findings under the lead, before "and N more". */ | |
| 228 | − | const SHOWN = 4; | |
| 229 | − | ||
| 230 | − | /** | |
| 231 | − | * The daily signal: the day, the finding that matters most as the page's | |
| 232 | − | * headline, and the others under it. A quiet week still gets a sentence. | |
| 233 | − | * Null `view`: the work service did not answer. | |
| 234 | − | */ | |
| 235 | − | export function DailySignal({ date, view, line, footer }: { date: string; view: DailySignalView | null; line?: ReactNode; footer?: ReactNode }) { | |
| 236 | − | const [more, setMore] = useState(false); | |
| 237 | − | const lead = view?.findings[0] ?? null; | |
| 238 | − | const rest = view?.findings.slice(1) ?? []; | |
| 239 | − | const shown = more ? rest : rest.slice(0, SHOWN); | |
| 240 | − | return ( | |
| 241 | − | <section aria-labelledby="daily-signal" className="rounded-2xl border border-line bg-surface px-5 py-6 md:px-8 md:py-8"> | |
| 242 | − | <p className="font-mono text-[0.6875rem] tracking-[0.16em] text-accent uppercase">Daily signal</p> | |
| 243 | − | <p className="mt-2 font-display text-xl text-fg-soft md:text-2xl">{date}</p> | |
| 244 | − | {view == null ? ( | |
| 245 | − | <> | |
| 246 | − | <h1 id="daily-signal" className="mt-4 font-display text-3xl leading-tight tracking-tight text-fg md:text-[2.5rem]"> | |
| 247 | − | Here's your day. | |
| 248 | − | </h1> | |
| 249 | − | <p className="mt-3 flex items-center gap-1.5 text-sm text-muted"> | |
| 250 | − | <TriangleAlert size={13} className="shrink-0 text-warn" /> | |
| 251 | − | What the work says couldn't be read just now, so today's findings aren't here. | |
| 252 | − | </p> | |
| 253 | − | </> | |
| 254 | − | ) : ( | |
| 255 | − | <> | |
| 256 | − | <h1 | |
| 257 | − | id="daily-signal" | |
| 258 | − | className="mt-4 max-w-3xl font-display text-3xl leading-[1.12] tracking-tight text-balance text-fg md:text-[2.25rem] xl:text-[2.5rem]" | |
| 259 | − | > | |
| 260 | − | {lead?.headline ?? view.quiet} | |
| 261 | − | </h1> | |
| 262 | − | {lead && ( | |
| 263 | − | <div className={cn("mt-4 grid gap-4", lead.trend && "md:grid-cols-[minmax(0,1fr)_12.5rem] md:gap-8")}> | |
| 264 | − | <div className="space-y-2"> | |
| 265 | − | <Facts facts={lead.facts} /> | |
| 266 | − | <Actions signal={lead} /> | |
| 267 | − | </div> | |
| 268 | − | {lead.trend && <Trend trend={lead.trend} />} | |
| 269 | − | </div> | |
| 270 | − | )} | |
| 271 | − | {!view.complete && ( | |
| 272 | − | <p className="mt-3 flex items-center gap-1.5 text-xs text-faint"> | |
| 273 | − | <TriangleAlert size={12} className="shrink-0" /> | |
| 274 | − | Some projects didn't answer, so these findings leave them out. | |
| 275 | − | </p> | |
| 276 | − | )} | |
| 277 | − | </> | |
| 278 | − | )} | |
| 279 | − | {line && <p className="mt-4 text-sm text-muted">{line}</p>} | |
| 280 | − | {shown.length > 0 && ( | |
| 281 | − | <> | |
| 282 | − | <hr className="my-7 border-line" /> | |
| 283 | − | <ul className="divide-y divide-line"> | |
| 284 | − | {shown.map((signal) => ( | |
| 285 | − | <Finding key={signal.key} signal={signal} /> | |
| 286 | − | ))} | |
| 287 | − | </ul> | |
| 288 | − | {rest.length > SHOWN && ( | |
| 289 | − | <button type="button" onClick={() => setMore(!more)} className="mt-5 text-xs text-muted hover:text-fg"> | |
| 290 | − | {more ? "Show fewer" : `${rest.length - SHOWN} more ${rest.length - SHOWN === 1 ? "finding" : "findings"}`} | |
| 291 | − | </button> | |
| 292 | − | )} | |
| 293 | − | </> | |
| 294 | − | )} | |
| 295 | − | {footer && <div className="mt-8 border-t border-line pt-6">{footer}</div>} | |
| 296 | − | </section> | |
| 297 | − | ); | |
| 298 | − | } | |
| 299 | − | ||
| 300 | − | export function DailySignalSkeleton({ date }: { date: string }) { | |
| 301 | − | return ( | |
| 302 | − | <section className="rounded-2xl border border-line bg-surface px-5 py-6 md:px-8 md:py-8"> | |
| 303 | − | <p className="font-mono text-[0.6875rem] tracking-[0.16em] text-accent uppercase">Daily signal</p> | |
| 304 | − | <p className="mt-2 font-display text-xl text-fg-soft md:text-2xl">{date}</p> | |
| 305 | − | <Skeleton className="mt-5 h-9 w-full max-w-2xl" /> | |
| 306 | − | <Skeleton className="mt-2 h-9 w-2/3 max-w-xl" /> | |
| 307 | − | <Skeleton className="mt-5 h-3 w-64" /> | |
| 308 | − | </section> | |
| 309 | 159 | ); | |
| 310 | 160 | } | |
| 8 | 8 | type WeekTotals, | |
| 9 | 9 | agentStep, | |
| 10 | 10 | blockedSignal, | |
| 11 | + | dayBars, | |
| 11 | 12 | dayList, | |
| 12 | 13 | emptySignals, | |
| 13 | 14 | findings, | |
| ⋯ | |||
| 18 | 19 | pulse, | |
| 19 | 20 | quarterSpend, | |
| 20 | 21 | quarterStart, | |
| 21 | − | quietHeadline, | |
| 22 | 22 | reviewSignal, | |
| 23 | 23 | reworkSignal, | |
| 24 | 24 | rhythm, | |
| ⋯ | |||
| 33 | 33 | spreading, | |
| 34 | 34 | stalledSignal, | |
| 35 | 35 | weekStarts, | |
| 36 | + | weekSummary, | |
| 36 | 37 | weekTotals, | |
| 37 | 38 | weeklySpend, | |
| 38 | 39 | } from "./signals.ts"; | |
| ⋯ | |||
| 196 | 197 | ], | |
| 197 | 198 | }; | |
| 198 | 199 | const signal = blockedSignal(ctx({ signals }))!; | |
| 199 | − | assert.equal(signal.headline, "Three work items are waiting on one unreviewed change."); | |
| 200 | + | assert.equal(signal.headline, "tax#4821 holds up three work items, and no one has reviewed it yet."); | |
| 200 | 201 | assert.deepEqual(signal.facts, ["tax#4821 Nexus threshold configuration", "open 2 days", "holds up #8, #9, #10"]); | |
| 201 | 202 | assert.equal(signal.link!.to, "/acme/tax/pull/4821"); | |
| 202 | 203 | assert.equal(blockedSignal(ctx()), null); | |
| ⋯ | |||
| 232 | 233 | ], | |
| 233 | 234 | }); | |
| 234 | 235 | const signal = spendSignal(ctx({ signals, quarterUsage }))!; | |
| 235 | − | assert.equal(signal.headline, "More than half of this quarter's agent spend is going into the Checkout replatform."); | |
| 236 | + | assert.equal(signal.headline, "The Checkout replatform took more than half of the agent spend this quarter."); | |
| 236 | 237 | assert.deepEqual(signal.facts, ["$28.3K of $52.4K", "quarter to date"]); | |
| 237 | 238 | const even = usage({ byPull: quarterUsage.byPull.map((slice) => ({ ...slice, micros: 1_000_000 })) }); | |
| 238 | 239 | assert.equal(spendSignal(ctx({ signals, quarterUsage: even })), null); | |
| ⋯ | |||
| 251 | 252 | ], | |
| 252 | 253 | }; | |
| 253 | 254 | const signal = shippedSignal(ctx({ signals }))!; | |
| 254 | − | assert.equal(signal.headline, "Single sign-on shipped on Tuesday, three weeks ahead of schedule."); | |
| 255 | + | assert.equal(signal.headline, "Single sign-on closed Tuesday, three weeks ahead of schedule."); | |
| 256 | + | assert.equal(signal.day, "2026-10-13"); | |
| 255 | 257 | assert.deepEqual(signal.facts, ["11 weeks in build", "2 changes", "2 projects"]); | |
| 256 | 258 | const old = { ...signals, milestones: [{ ...signals.milestones[0]!, closedAt: "2026-09-01T00:00:00Z" }] }; | |
| 257 | 259 | assert.equal(shippedSignal(ctx({ signals: old })), null, "only the last week's"); | |
| ⋯ | |||
| 298 | 300 | assert.equal(signal.headline, "bo is starting to work in web, which ana has carried almost alone."); | |
| 299 | 301 | }); | |
| 300 | 302 | ||
| 301 | − | test("findings come highest first, and a quiet week still says something", () => { | |
| 303 | + | test("findings come highest first, and the week is said in a sentence that claims nothing about what needs anyone", () => { | |
| 302 | 304 | const signals = { | |
| 303 | 305 | ...emptySignals(), | |
| 304 | 306 | waiting: [{ repoId: "r1", number: 2, title: "Fix", createdAt: "2026-10-16T00:00:00Z", agent: false, draftSeconds: null, item: 1, blocks: [3, 4] }], | |
| ⋯ | |||
| 308 | 310 | found.map((s) => s.key), | |
| 309 | 311 | ["blocked", "stalled"], | |
| 310 | 312 | ); | |
| 311 | − | assert.equal(quietHeadline(week("x")), "A quiet week: nothing has landed yet, and nothing is stuck."); | |
| 312 | − | assert.equal( | |
| 313 | − | quietHeadline(week("x", { merged: 4, agentMerged: 2 })), | |
| 314 | − | "Four changes landed this week, 50% of them an agent's, and nothing needs a closer look.", | |
| 315 | − | ); | |
| 313 | + | assert.equal(weekSummary(week("x")), "Nothing has landed this week yet."); | |
| 314 | + | assert.equal(weekSummary(week("x", { merged: 4, agentMerged: 2 })), "Four changes landed this week, 50% of them by agents."); | |
| 316 | 315 | // A workspace that pushes straight to main: agents' direct commits are what landed. | |
| 317 | − | assert.equal( | |
| 318 | − | quietHeadline(week("x", { merged: 1, agentMerged: 0, agentCommits: 9 })), | |
| 319 | − | "Ten changes landed this week, 90% of them an agent's, and nothing needs a closer look.", | |
| 320 | − | ); | |
| 316 | + | assert.equal(weekSummary(week("x", { merged: 1, agentMerged: 0, agentCommits: 9 })), "Ten changes landed this week, 90% of them by agents."); | |
| 321 | 317 | }); | |
| 322 | 318 | ||
| 323 | 319 | test("waits are said in words and in a few characters", () => { | |
| ⋯ | |||
| 441 | 437 | test("agents' direct commits count as what they landed, and as what spend is spread over", () => { | |
| 442 | 438 | const weeks = [week("2026-10-05", { merged: 2, agentMerged: 1, agentCommits: 3 }), week("2026-10-12", { merged: 1, agentMerged: 1, agentCommits: 9 })]; | |
| 443 | 439 | const [changes, spend] = pulse({ slug: "acme", weeks, spend: [4_000_000, 20_000_000] }); | |
| 444 | − | assert.equal(changes!.label, "Agent-authored changes"); | |
| 440 | + | assert.equal(changes!.label, "Landed by agents"); | |
| 445 | 441 | assert.equal(changes!.value, "10"); | |
| 446 | 442 | assert.deepEqual(changes!.delta, { text: "+6", tone: "good" }); | |
| 447 | 443 | assert.equal(changes!.points[1], "Week of Oct 12: 1 merged, 9 pushed directly, of 10 changes"); | |
| 448 | 444 | assert.equal(spend!.value, "$2.00", "$20 over ten agent changes"); | |
| 449 | 445 | }); | |
| 446 | + | ||
| 447 | + | test("the last two weeks day by day: every repository added up, agents' and people's, and empty days as zeros", () => { | |
| 448 | + | const bars = dayBars( | |
| 449 | + | [ | |
| 450 | + | { day: "2026-10-18", merged: 3, agentMerged: 2, agentCommits: 4, sessions: 5 }, | |
| 451 | + | { day: "2026-10-18", merged: 1, agentMerged: 0, agentCommits: 0, sessions: 1 }, | |
| 452 | + | { day: "2026-10-10", merged: 2, agentMerged: 2, agentCommits: 0, sessions: 0 }, | |
| 453 | + | // Outside the two weeks: left out. | |
| 454 | + | { day: "2026-09-01", merged: 9, agentMerged: 9, agentCommits: 9, sessions: 9 }, | |
| 455 | + | ], | |
| 456 | + | NOW, | |
| 457 | + | ); | |
| 458 | + | assert.equal(bars.length, 14); | |
| 459 | + | assert.equal(bars[0]!.day, "2026-10-05"); | |
| 460 | + | assert.deepEqual(bars.at(-1), { day: "2026-10-18", agent: 6, people: 2, sessions: 6 }); | |
| 461 | + | assert.deepEqual( | |
| 462 | + | bars.find((bar) => bar.day === "2026-10-10"), | |
| 463 | + | { day: "2026-10-10", agent: 2, people: 0, sessions: 0 }, | |
| 464 | + | ); | |
| 465 | + | assert.deepEqual( | |
| 466 | + | bars.find((bar) => bar.day === "2026-10-11"), | |
| 467 | + | { day: "2026-10-11", agent: 0, people: 0, sessions: 0 }, | |
| 468 | + | ); | |
| 469 | + | }); | |
| 315 | 315 | return [ | |
| 316 | 316 | { | |
| 317 | 317 | key: "merges", | |
| 318 | − | label: "Agent-authored changes", | |
| 318 | + | label: "Landed by agents", | |
| 319 | 319 | value: String(agentChanges(now)), | |
| 320 | 320 | delta: deltaOf(agentChanges(now), before ? agentChanges(before) : null, count, true), | |
| 321 | 321 | series: weeks.map(agentChanges), | |
| ⋯ | |||
| 325 | 325 | }, | |
| 326 | 326 | { | |
| 327 | 327 | key: "spend", | |
| 328 | − | label: "Spend per agent change", | |
| 328 | + | label: "Cost per agent change", | |
| 329 | 329 | value: dollars(perMerge[at] ?? null), | |
| 330 | 330 | delta: deltaOf(perMerge[at] ?? null, before ? (perMerge[at - 1] ?? null) : null, (abs) => dollars(abs), false), | |
| 331 | 331 | series: perMerge, | |
| ⋯ | |||
| 335 | 335 | }, | |
| 336 | 336 | { | |
| 337 | 337 | key: "stalled", | |
| 338 | − | label: "Stalled changes", | |
| 338 | + | label: "Gone quiet", | |
| 339 | 339 | value: String(now.stalled), | |
| 340 | 340 | delta: deltaOf(now.stalled, before?.stalled ?? null, count, false), | |
| 341 | 341 | series: weeks.map((week) => week.stalled), | |
| ⋯ | |||
| 345 | 345 | }, | |
| 346 | 346 | { | |
| 347 | 347 | key: "rework", | |
| 348 | − | label: "Agent rework rate", | |
| 348 | + | label: "Sent back", | |
| 349 | 349 | value: percent(rework[at] ?? null), | |
| 350 | 350 | delta: deltaOf(rework[at] ?? null, before ? (rework[at - 1] ?? null) : null, (abs) => `${(abs * 100).toFixed(1).replace(/\.0$/, "")} pts`, false), | |
| 351 | 351 | series: rework, | |
| ⋯ | |||
| 369 | 369 | trend?: { values: (number | null)[]; points: string[]; caption: string; range: string }; | |
| 370 | 370 | /** How much it matters today: findings are shown highest first. */ | |
| 371 | 371 | weight: number; | |
| 372 | + | /** The UTC day it happened (`YYYY-MM-DD`), when it happened on one: Home pins it there on the week. */ | |
| 373 | + | day?: string; | |
| 372 | 374 | /** What Ask is asked, to look into it. */ | |
| 373 | 375 | ask: string; | |
| 374 | 376 | }; | |
| ⋯ | |||
| 411 | 413 | const shown = top.blocks.slice(0, 3).map((number) => `#${number}`); | |
| 412 | 414 | return { | |
| 413 | 415 | key: "blocked", | |
| 414 | − | headline: n === 1 ? "A work item is waiting on a change no one has reviewed." : `${countWord(n)} work items are waiting on one unreviewed change.`, | |
| 416 | + | headline: | |
| 417 | + | n === 1 | |
| 418 | + | ? `${repo.name}#${top.number} holds up a work item, and no one has reviewed it yet.` | |
| 419 | + | : `${repo.name}#${top.number} holds up ${countWord(n).toLowerCase()} work items, and no one has reviewed it yet.`, | |
| 415 | 420 | facts: [ | |
| 416 | 421 | `${repo.name}#${top.number} ${top.title}`, | |
| 417 | 422 | `open ${spoken(ago(ctx.now, top.createdAt))}`, | |
| ⋯ | |||
| 542 | 547 | if (!top) return null; | |
| 543 | 548 | const share = top.micros / total; | |
| 544 | 549 | if (share < 0.35 || groups.size < 2) return null; | |
| 545 | − | const amount = share > 0.5 ? "More than half" : share > 1 / 3 ? "More than a third" : `${Math.round(share * 100)}%`; | |
| 550 | + | const amount = share > 0.5 ? "more than half" : share > 1 / 3 ? "more than a third" : `${Math.round(share * 100)}%`; | |
| 546 | 551 | return { | |
| 547 | 552 | key: "spend", | |
| 548 | − | headline: `${amount} of this quarter's agent spend is going into ${top.label}.`, | |
| 553 | + | headline: `${top.label.charAt(0).toUpperCase()}${top.label.slice(1)} took ${amount} of the agent spend this quarter.`, | |
| 549 | 554 | facts: [`${dollars(top.micros)} of ${dollars(total)}`, "quarter to date"], | |
| 550 | 555 | link: { label: "See the spend", to: `/${ctx.slug}/-/workspace#spend` }, | |
| 551 | 556 | weight: 25 + share * 40, | |
| ⋯ | |||
| 580 | 585 | } | |
| 581 | 586 | return { | |
| 582 | 587 | key: "shipped", | |
| 583 | − | headline: `${recent.title} shipped on ${weekday}${schedule}.`, | |
| 588 | + | headline: `${recent.title} closed ${weekday}${schedule}.`, | |
| 589 | + | day: recent.closedAt!.slice(0, 10), | |
| 584 | 590 | facts: [ | |
| 585 | 591 | `${weeksIn} ${weeksIn === 1 ? "week" : "weeks"} in build`, | |
| 586 | 592 | `${pulls.length} ${pulls.length === 1 ? "change" : "changes"}`, | |
| ⋯ | |||
| 607 | 613 | trend: { | |
| 608 | 614 | values: ctx.weeks.map((week) => week.stalled), | |
| 609 | 615 | points: ctx.weeks.map((week) => `Week of ${weekLabel(week.start)}: ${week.stalled}`), | |
| 610 | − | caption: "Stalled changes", | |
| 616 | + | caption: "Gone quiet", | |
| 611 | 617 | range: `${ctx.weeks[0]!.stalled} → ${now.stalled} over ${ctx.weeks.length} weeks`, | |
| 612 | 618 | }, | |
| 613 | 619 | weight: 30 + Math.min(25, more * 2), | |
| ⋯ | |||
| 815 | 821 | facts: [`${first.elsewhere} merges elsewhere before`, `first in ${repo.name}`], | |
| 816 | 822 | link: { label: "See the people", to: `/${ctx.slug}/-/workspace#quiet` }, | |
| 817 | 823 | weight: 20 + Math.min(10, others * 3), | |
| 824 | + | day: first.firstMergedAt.slice(0, 10), | |
| 818 | 825 | ask: `What did ${first.name} change in ${repo.namespace}/${repo.name}?`, | |
| 819 | 826 | }; | |
| 820 | 827 | } | |
| ⋯ | |||
| 835 | 842 | return found.sort((a, b) => b.weight - a.weight); | |
| 836 | 843 | } | |
| 837 | 844 | ||
| 838 | − | /** The headline when nothing stands out: still a sentence about the week. */ | |
| 839 | − | export function quietHeadline(week: WeekTotals | undefined): string { | |
| 845 | + | /** | |
| 846 | + | * The week in a sentence: what landed and how much of it was agents'. It | |
| 847 | + | * says nothing about what needs anyone; Home leads with that on its own. | |
| 848 | + | */ | |
| 849 | + | export function weekSummary(week: WeekTotals | undefined): string { | |
| 840 | 850 | const landed = week ? allChanges(week) : 0; | |
| 841 | − | if (!week || landed === 0) return "A quiet week: nothing has landed yet, and nothing is stuck."; | |
| 851 | + | if (!week || landed === 0) return "Nothing has landed this week yet."; | |
| 842 | 852 | const share = agentShare(week); | |
| 843 | − | return `${countWord(landed)} ${landed === 1 ? "change" : "changes"} landed this week${share ? `, ${percent(share)} of them an agent's` : ""}, and nothing needs a closer look.`; | |
| 853 | + | return `${countWord(landed)} ${landed === 1 ? "change" : "changes"} landed this week${share ? `, ${percent(share)} of them by agents` : ""}.`; | |
| 854 | + | } | |
| 855 | + | ||
| 856 | + | /** One day on Home's week: what landed, agents' and people's, and the sessions started. */ | |
| 857 | + | export type DayBar = { day: string; agent: number; people: number; sessions: number }; | |
| 858 | + | ||
| 859 | + | /** | |
| 860 | + | * The last `count` UTC days to `now`, oldest first, every repository's | |
| 861 | + | * rows added up; a day with nothing in it is zeros. Agents' are their | |
| 862 | + | * merged pull requests and the commits their sessions pushed directly; | |
| 863 | + | * people's, the other merged pull requests. | |
| 864 | + | */ | |
| 865 | + | export function dayBars(days: Pick<SignalDay, "day" | "merged" | "agentMerged" | "agentCommits" | "sessions">[], now: number, count = SIGNAL_DAYS): DayBar[] { | |
| 866 | + | const until = utcDay(now); | |
| 867 | + | const bars = new Map<string, DayBar>(); | |
| 868 | + | for (let i = count - 1; i >= 0; i--) { | |
| 869 | + | const day = addDays(until, -i); | |
| 870 | + | bars.set(day, { day, agent: 0, people: 0, sessions: 0 }); | |
| 871 | + | } | |
| 872 | + | for (const row of days) { | |
| 873 | + | const bar = bars.get(row.day); | |
| 874 | + | if (!bar) continue; | |
| 875 | + | bar.agent += row.agentMerged + (row.agentCommits ?? 0); | |
| 876 | + | bar.people += Math.max(0, row.merged - row.agentMerged); | |
| 877 | + | bar.sessions += row.sessions; | |
| 878 | + | } | |
| 879 | + | return [...bars.values()]; | |
| 844 | 880 | } | |
| 845 | 881 | ||
| 846 | − | /** What Home shows: the pulse, and the findings with the one that leads. */ | |
| 882 | + | /** What Home shows of the work: the week's figures, its days, and the findings, most important first. */ | |
| 847 | 883 | export type DailySignal = { | |
| 848 | 884 | tiles: PulseTile[]; | |
| 849 | 885 | findings: Signal[]; | |
| 850 | − | /** The headline when no finding stands out. */ | |
| 851 | − | quiet: string; | |
| 886 | + | /** The week in a sentence (`weekSummary`). */ | |
| 887 | + | summary: string; | |
| 888 | + | days: DayBar[]; | |
| 852 | 889 | complete: boolean; | |
| 853 | 890 | }; | |
| 854 | 891 | ||
| ⋯ | |||
| 874 | 911 | quarterUsage: data.quarterUsage, | |
| 875 | 912 | quarter: data.range.quarter, | |
| 876 | 913 | }), | |
| 877 | − | quiet: quietHeadline(data.weeks[data.weeks.length - 1]), | |
| 914 | + | summary: weekSummary(data.weeks[data.weeks.length - 1]), | |
| 915 | + | days: dayBars(data.signals.days, data.now), | |
| 878 | 916 | complete: data.complete, | |
| 879 | 917 | }; | |
| 880 | 918 | } | |
| 1 | 1 | import { Suspense, useEffect, useState } from "react"; | |
| 2 | 2 | import { Await, data } from "react-router"; | |
| 3 | 3 | ||
| 4 | − | import { hasCodeAccess } from "@g1t/contracts"; | |
| 4 | + | import { type WorkspaceSession, hasCodeAccess } from "@g1t/contracts"; | |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/home"; | |
| 7 | 7 | import { | |
| 8 | 8 | Card, | |
| 9 | 9 | LandedFeed, | |
| 10 | + | LiveSessions, | |
| 10 | 11 | NeedsList, | |
| 11 | 12 | PeopleSummary, | |
| 12 | 13 | RowsSkeleton, | |
| ⋯ | |||
| 18 | 19 | WindowSwitch, | |
| 19 | 20 | } from "../../components/home"; | |
| 20 | 21 | import { HomeAsk, WhatChangedCard } from "../../components/home-ask"; | |
| 21 | − | import { DailySignal, DailySignalSkeleton, PulseSkeleton, PulseStrip } from "../../components/signals"; | |
| 22 | + | import { HomeWeek, HomeWeekSkeleton } from "../../components/home-week"; | |
| 22 | 23 | import { Skeleton } from "../../components/ui/skeleton"; | |
| 23 | 24 | import { sidebarOrNull } from "../../lib/chat.server"; | |
| 24 | 25 | import { | |
| ⋯ | |||
| 66 | 67 | } from "../../lib/home.server"; | |
| 67 | 68 | import { HOME_WATCH_MAX, type HomeRepo } from "../../lib/home-live"; | |
| 68 | 69 | import { readCookie } from "../../lib/mission"; | |
| 69 | − | import { dailySignal, signalDate } from "../../lib/signals"; | |
| 70 | + | import { dailySignal } from "../../lib/signals"; | |
| 70 | 71 | import { loadSignalData } from "../../lib/signals.server"; | |
| 71 | 72 | import { useHomeLive } from "../../lib/use-home-live"; | |
| 72 | 73 | import { guideIsFor, loadOnboarding } from "../../lib/onboarding.server"; | |
| 73 | 74 | import { GettingStartedButton, OnboardingOnPage } from "../../components/onboarding"; | |
| 74 | 75 | import { loadMissionControl } from "../../lib/mission-control.server"; | |
| 75 | 76 | import { page } from "../../lib/meta"; | |
| 76 | − | import { inbox } from "../../lib/services.server"; | |
| 77 | + | import { inbox, work } from "../../lib/services.server"; | |
| 77 | 78 | import { requireUser, roleIn } from "../../lib/session.server"; | |
| 78 | 79 | import { homePagePath } from "../../lib/workspace-nav"; | |
| 79 | 80 | import { Boundary } from "../../components/boundary"; | |
| ⋯ | |||
| 82 | 83 | return page(args, { title: `Home · ${params.owner} · g1t` }); | |
| 83 | 84 | } | |
| 84 | 85 | ||
| 85 | − | /** Unread notifications read for Needs you. */ | |
| 86 | + | /** Unread notifications read for Your move. */ | |
| 86 | 87 | const NOTIFICATIONS_READ = 50; | |
| 88 | + | /** Live sessions read for Live. */ | |
| 89 | + | const LIVE_READ = 12; | |
| 87 | 90 | ||
| 88 | 91 | /** | |
| 89 | − | * Home: the workspace's front page. First the past week in four figures | |
| 90 | − | * and the daily signal: what the workspace's work says today, the finding | |
| 91 | − | * that matters most as the page's headline, each with its evidence | |
| 92 | − | * (lib/signals.ts), and under them a box that asks g1t about any of it | |
| 92 | + | * Home: the workspace's front page. First the box that asks g1t | |
| 93 | 93 | * (components/home-ask: What changed summarizes the span on the page, and | |
| 94 | − | * Explain on each change that landed asks about it); then where you're | |
| 95 | − | * needed now, with no time window; what happened since you were last here (or over the last 24 | |
| 96 | − | * hours or 7 days, `?window=`): what people did, from the events | |
| 97 | − | * service's digest of the span and chat's, then what landed; what is | |
| 98 | − | * running; what the span cost. The last visit is kept by notify, per | |
| 94 | + | * Explain on each change that landed asks about it). Then Your move: | |
| 95 | + | * everything waiting on you, with no time window, beside Live: the agents | |
| 96 | + | * at work, workflow runs and builds going now. Then the week's work | |
| 97 | + | * (components/home-week, lib/signals.ts): four figures, the last two weeks | |
| 98 | + | * day by day, and what is worth a look, each with its evidence and pinned | |
| 99 | + | * to its day where it has one. Then what happened since you were last | |
| 100 | + | * here (or over the last 24 hours or 7 days, `?window=`): what people | |
| 101 | + | * did, from the events service's digest of the span and chat's, then what | |
| 102 | + | * landed; beside it what the span cost and what settled by itself. The last visit is kept by notify, per | |
| 99 | 103 | * person and workspace, and marked by the page once it has been looked | |
| 100 | 104 | * at (routes/workspace/home-seen.ts), so a refresh never wipes it. Only | |
| 101 | 105 | * the last visit (for the span) is waited for; every section streams in | |
| ⋯ | |||
| 219 | 223 | missing: [...(code && (!workflows || !workflows.complete) ? ["Workflows"] : []), ...(code && !deploys ? ["Deployments"] : [])], | |
| 220 | 224 | })) | |
| 221 | 225 | .catch(() => ({ rows: [], missing: ["Workflows", "Deployments"] })); | |
| 226 | + | // The agents at work now: harness and hosted sessions that are live. | |
| 227 | + | const live: Promise<WorkspaceSession[] | null> = code | |
| 228 | + | ? work | |
| 229 | + | .listWorkspaceSessions(slug, viewer, { shown: "live", sessionsOnly: true, limit: LIVE_READ }) | |
| 230 | + | .then((result) => (result.ok ? result.value.sessions : null)) | |
| 231 | + | .catch(() => null) | |
| 232 | + | : Promise.resolve([]); | |
| 222 | 233 | const spend = loadSpend(viewer, slug, span).catch(() => null); | |
| 223 | 234 | // What the workspace's work says: the pulse and the findings, for members who see Code. | |
| 224 | 235 | const signal = code | |
| ⋯ | |||
| 247 | 258 | attention: waiting, | |
| 248 | 259 | away, | |
| 249 | 260 | running: runningNow, | |
| 250 | − | /** The sentence under the heading needs the digest, what landed and what waits. */ | |
| 251 | − | head: Promise.all([digest, happened, waiting]), | |
| 261 | + | live, | |
| 262 | + | /** The sentence about the span needs the digest and what landed. */ | |
| 263 | + | head: Promise.all([digest, happened]), | |
| 252 | 264 | spend, | |
| 253 | 265 | signal, | |
| 254 | − | date: signalDate(now, tz), | |
| 255 | 266 | watch, | |
| 256 | 267 | onboarding, | |
| 257 | 268 | setupAsked, | |
| ⋯ | |||
| 316 | 327 | } | |
| 317 | 328 | ||
| 318 | 329 | export default function Home({ loaderData }: Route.ComponentProps) { | |
| 319 | − | const { slug, now, tz, span, digest, happened, attention: waiting, away, running: runningNow, head, spend, signal } = loaderData; | |
| 330 | + | const { slug, now, tz, span, digest, happened, attention: waiting, away, running: runningNow, live, head, spend, signal } = loaderData; | |
| 320 | 331 | useMarkSeen(slug, now); | |
| 321 | 332 | useHomeLive(slug, useWatched(loaderData.watch)); | |
| 322 | 333 | return ( | |
| 323 | − | <div className="mx-auto w-full max-w-260 space-y-5 px-4 py-5 md:px-10 md:py-8"> | |
| 334 | + | <div className="mx-auto w-full max-w-360 space-y-5 px-4 py-5 md:px-10 md:py-8"> | |
| 324 | 335 | <OnboardingOnPage slug={slug} view={loaderData.onboarding} asked={loaderData.setupAsked} /> | |
| 325 | 336 | {loaderData.guide && ( | |
| 326 | 337 | <div className="flex justify-end"> | |
| 327 | 338 | <GettingStartedButton slug={slug} variant="soft" /> | |
| 328 | 339 | </div> | |
| 329 | 340 | )} | |
| 330 | − | {/* 0. The past week, against the week before, for members who see Code. */} | |
| 341 | + | {/* 0. The box that asks g1t leads the page, above everything it can be asked about. */} | |
| 342 | + | <HomeAsk span={span} /> | |
| 343 | + | {/* | |
| 344 | + | What needs you and what is going on now, side by side; on a phone, | |
| 345 | + | one after the other. Then the week's work, then what happened since | |
| 346 | + | you were last here beside what it cost. | |
| 347 | + | */} | |
| 348 | + | <div className="grid grid-cols-[minmax(0,1fr)] items-start gap-5 lg:grid-cols-[minmax(0,1fr)_24rem]"> | |
| 349 | + | {/* 1. Your move: everything waiting on you, the one to start with on top, with no time window. */} | |
| 350 | + | <div id="needed" className="scroll-mt-4"> | |
| 351 | + | <Suspense | |
| 352 | + | fallback={ | |
| 353 | + | <Card title="Your move"> | |
| 354 | + | <RowsSkeleton rows={4} /> | |
| 355 | + | </Card> | |
| 356 | + | } | |
| 357 | + | > | |
| 358 | + | {/* Each section's data says what did not answer; a section that fails to draw keeps that to itself. */} | |
| 359 | + | <Await resolve={waiting}> | |
| 360 | + | {(att) => ( | |
| 361 | + | <Card title="Your move" hint={waitingSentence(att.rows.length)} link={{ label: "Notifications", to: "/notifications" }}> | |
| 362 | + | <Boundary what="Your move"> | |
| 363 | + | <NeedsList attention={att} now={now} /> | |
| 364 | + | </Boundary> | |
| 365 | + | </Card> | |
| 366 | + | )} | |
| 367 | + | </Await> | |
| 368 | + | </Suspense> | |
| 369 | + | </div> | |
| 370 | + | ||
| 371 | + | {/* 2. Live: agents at work, then workflow runs and builds, as they go. */} | |
| 372 | + | <Card title="Live" hint="Agents, runs and builds going now"> | |
| 373 | + | <Suspense fallback={<RowsSkeleton rows={2} />}> | |
| 374 | + | <Await resolve={Promise.all([live, runningNow])}> | |
| 375 | + | {([sessions, value]) => ( | |
| 376 | + | <Boundary what="Live"> | |
| 377 | + | <LiveSessions sessions={sessions} slug={slug} now={now} /> | |
| 378 | + | <RunningList rows={value.rows} missing={value.missing} now={now} sessions={sessions?.length ?? 0} /> | |
| 379 | + | </Boundary> | |
| 380 | + | )} | |
| 381 | + | </Await> | |
| 382 | + | </Suspense> | |
| 383 | + | </Card> | |
| 384 | + | </div> | |
| 385 | + | ||
| 386 | + | {/* 3. The week's work, for members who see Code: its figures, its days, and what is worth a look. */} | |
| 331 | 387 | {signal && ( | |
| 332 | − | <Suspense fallback={<PulseSkeleton />}> | |
| 388 | + | <Suspense fallback={<HomeWeekSkeleton />}> | |
| 333 | 389 | <Await resolve={signal}> | |
| 334 | − | {(value) => | |
| 335 | − | value && ( | |
| 336 | − | <Boundary what="The past week"> | |
| 337 | − | <PulseStrip tiles={value.tiles} /> | |
| 338 | − | </Boundary> | |
| 339 | − | ) | |
| 340 | − | } | |
| 390 | + | {(value) => ( | |
| 391 | + | <Boundary what="This week"> | |
| 392 | + | <HomeWeek view={value} /> | |
| 393 | + | </Boundary> | |
| 394 | + | )} | |
| 341 | 395 | </Await> | |
| 342 | 396 | </Suspense> | |
| 343 | 397 | )} | |
| 344 | − | {/* | |
| 345 | − | Two columns on wide screens: what to do and what happened on the | |
| 346 | − | left, read top to bottom; what is going on now and what it costs on | |
| 347 | − | the right, kept in view. On a phone it is one column: Where you're | |
| 348 | − | needed, Running now, what happened, then Spent and Settled (the side | |
| 349 | − | column's cards join it, `contents`, each in its place by `order`). | |
| 350 | − | */} | |
| 351 | − | <div className="grid grid-cols-[minmax(0,1fr)] items-start gap-5 lg:grid-cols-[minmax(0,1fr)_20rem]"> | |
| 352 | − | {/* 1. Where you're needed now, with the one to start with on top: no window. */} | |
| 353 | − | <div className="order-1 space-y-5 lg:col-start-1 lg:row-start-1"> | |
| 354 | − | {/* 1. The daily signal, then a box that asks g1t about any of it; the line under it is yours. */} | |
| 355 | − | {(() => { | |
| 356 | − | const line = ( | |
| 357 | − | <Suspense fallback={<Skeleton className="inline-block h-3.5 w-72 align-middle" />}> | |
| 358 | − | <Await resolve={head}> | |
| 359 | − | {([digest, happened, att]) => ( | |
| 360 | − | <> | |
| 361 | − | <span className="text-fg-soft">{waitingSentence(att.rows.length)}</span> {spanSentence(span, digest, happened?.landed?.length ?? null)} | |
| 362 | − | </> | |
| 363 | − | )} | |
| 364 | − | </Await> | |
| 365 | − | </Suspense> | |
| 366 | − | ); | |
| 367 | − | const ask = <HomeAsk span={span} compact />; | |
| 368 | − | // Without Code there is no work to read: the box leads, as it always has. | |
| 369 | − | if (!signal) | |
| 370 | − | return ( | |
| 371 | − | <header className="space-y-3"> | |
| 372 | − | <HomeAsk span={span} /> | |
| 373 | − | <p className="mx-auto max-w-2xl text-center text-sm leading-relaxed text-muted">{line}</p> | |
| 374 | − | </header> | |
| 375 | − | ); | |
| 376 | − | return ( | |
| 377 | − | <Suspense fallback={<DailySignalSkeleton date={loaderData.date} />}> | |
| 378 | − | <Await resolve={signal}> | |
| 379 | − | {(value) => ( | |
| 380 | − | <Boundary what="The daily signal"> | |
| 381 | − | <DailySignal date={loaderData.date} view={value} line={line} footer={ask} /> | |
| 382 | − | </Boundary> | |
| 383 | − | )} | |
| 384 | − | </Await> | |
| 385 | − | </Suspense> | |
| 386 | − | ); | |
| 387 | − | })()} | |
| 388 | − | <div id="needed" className="scroll-mt-4"> | |
| 389 | − | <Card | |
| 390 | − | title="Where you're needed" | |
| 391 | − | hint="Everything waiting on you, however long ago it started" | |
| 392 | − | link={{ label: "Notifications", to: "/notifications" }} | |
| 393 | − | > | |
| 394 | − | <Suspense fallback={<RowsSkeleton rows={4} />}> | |
| 395 | − | {/* Each section's data says what did not answer; a section that fails to draw keeps that to itself. */} | |
| 396 | − | <Await resolve={waiting}> | |
| 397 | − | {(att) => ( | |
| 398 | − | <Boundary what="Where you're needed"> | |
| 399 | − | <NeedsList attention={att} now={now} /> | |
| 400 | − | </Boundary> | |
| 401 | − | )} | |
| 402 | − | </Await> | |
| 403 | − | </Suspense> | |
| 404 | − | </Card> | |
| 405 | − | </div> | |
| 406 | − | </div> | |
| 407 | 398 | ||
| 408 | − | {/* 2. Now: what is running, what it cost, what settled by itself. */} | |
| 409 | − | <aside aria-label="Now" className="contents lg:sticky lg:top-4 lg:col-start-2 lg:row-span-2 lg:row-start-1 lg:block lg:space-y-5"> | |
| 410 | − | <Card title="Running now" className="order-2"> | |
| 411 | − | <Suspense fallback={<RowsSkeleton rows={2} />}> | |
| 412 | − | <Await resolve={runningNow}> | |
| 413 | − | {(value) => ( | |
| 414 | − | <Boundary what="Running now"> | |
| 415 | − | <RunningList rows={value.rows} missing={value.missing} now={now} /> | |
| 416 | − | </Boundary> | |
| 417 | − | )} | |
| 418 | − | </Await> | |
| 419 | − | </Suspense> | |
| 420 | − | </Card> | |
| 421 | − | <div className="order-4"> | |
| 422 | − | <Suspense | |
| 423 | − | fallback={ | |
| 424 | − | <Card title="Spent"> | |
| 425 | − | <SpendSkeleton /> | |
| 426 | − | </Card> | |
| 427 | − | } | |
| 428 | − | > | |
| 429 | − | <Await resolve={spend}> | |
| 430 | − | {(value) => ( | |
| 431 | − | <Boundary what="Spent"> | |
| 432 | − | <SpendCard spend={value} slug={slug} span={span} /> | |
| 433 | − | </Boundary> | |
| 434 | − | )} | |
| 435 | − | </Await> | |
| 436 | − | </Suspense> | |
| 437 | − | </div> | |
| 438 | − | <div className="order-5"> | |
| 439 | − | <Suspense | |
| 440 | − | fallback={ | |
| 441 | − | <Card title="Settled for you"> | |
| 442 | − | <RowsSkeleton rows={1} /> | |
| 443 | − | </Card> | |
| 444 | − | } | |
| 445 | − | > | |
| 446 | − | <Await resolve={away}> | |
| 447 | − | {(value) => ( | |
| 448 | − | <Boundary what="Settled for you"> | |
| 449 | − | <SettledCard away={value} now={now} /> | |
| 450 | − | </Boundary> | |
| 451 | − | )} | |
| 452 | − | </Await> | |
| 453 | − | </Suspense> | |
| 454 | − | </div> | |
| 455 | − | </aside> | |
| 456 | − | ||
| 457 | − | {/* 3. What happened in the span: what changed in plain English first, then who did what, then what landed. */} | |
| 458 | − | <section aria-labelledby="since" className="order-3 rounded-2xl border border-line bg-surface lg:col-start-1 lg:row-start-2"> | |
| 399 | + | <div className="grid grid-cols-[minmax(0,1fr)] items-start gap-5 lg:grid-cols-[minmax(0,1fr)_24rem]"> | |
| 400 | + | {/* 4. What happened in the span: in a sentence, then in plain English, then who did what, then what landed. */} | |
| 401 | + | <section aria-labelledby="since" className="rounded-2xl border border-line bg-surface"> | |
| 459 | 402 | <header className="flex flex-wrap items-center gap-x-3 gap-y-2 px-5 pt-4"> | |
| 460 | 403 | <div className="flex min-w-0 flex-wrap items-baseline gap-x-3 gap-y-1"> | |
| 461 | 404 | <h2 id="since" className="text-base font-semibold tracking-tight text-fg"> | |
| ⋯ | |||
| 467 | 410 | <WindowSwitch span={span} /> | |
| 468 | 411 | </div> | |
| 469 | 412 | </header> | |
| 413 | + | <p className="px-5 pt-2 text-sm text-muted"> | |
| 414 | + | <Suspense fallback={<Skeleton className="inline-block h-3.5 w-72 align-middle" />}> | |
| 415 | + | <Await resolve={head}>{([digest, happened]) => spanSentence(span, digest, happened?.landed?.length ?? null)}</Await> | |
| 416 | + | </Suspense> | |
| 417 | + | </p> | |
| 470 | 418 | {/* Summarize leads: the plain-English account is what most people come for. */} | |
| 471 | 419 | <div className="mt-4"> | |
| 472 | 420 | <WhatChangedCard key={span.key} slug={slug} span={span} tz={tz} /> | |
| ⋯ | |||
| 506 | 454 | </Await> | |
| 507 | 455 | </Suspense> | |
| 508 | 456 | </section> | |
| 457 | + | ||
| 458 | + | {/* 5. What the span cost, and what settled by itself: kept in view beside it. */} | |
| 459 | + | <aside aria-label="Cost and settled" className="space-y-5 lg:sticky lg:top-4"> | |
| 460 | + | <Suspense | |
| 461 | + | fallback={ | |
| 462 | + | <Card title="Spent"> | |
| 463 | + | <SpendSkeleton /> | |
| 464 | + | </Card> | |
| 465 | + | } | |
| 466 | + | > | |
| 467 | + | <Await resolve={spend}> | |
| 468 | + | {(value) => ( | |
| 469 | + | <Boundary what="Spent"> | |
| 470 | + | <SpendCard spend={value} slug={slug} span={span} /> | |
| 471 | + | </Boundary> | |
| 472 | + | )} | |
| 473 | + | </Await> | |
| 474 | + | </Suspense> | |
| 475 | + | <Suspense | |
| 476 | + | fallback={ | |
| 477 | + | <Card title="Settled for you"> | |
| 478 | + | <RowsSkeleton rows={1} /> | |
| 479 | + | </Card> | |
| 480 | + | } | |
| 481 | + | > | |
| 482 | + | <Await resolve={away}> | |
| 483 | + | {(value) => ( | |
| 484 | + | <Boundary what="Settled for you"> | |
| 485 | + | <SettledCard away={value} now={now} /> | |
| 486 | + | </Boundary> | |
| 487 | + | )} | |
| 488 | + | </Await> | |
| 489 | + | </Suspense> | |
| 490 | + | </aside> | |
| 509 | 491 | </div> | |
| 510 | 492 | </div> | |
| 511 | 493 | ); | |
Comments
Sign in to comment.