| 1 | /** @jsxImportSource hono/jsx */ |
| 2 | import { raw } from "hono/html"; |
| 3 | import type { Child, FC } from "hono/jsx"; |
| 4 | import { ICONS } from "../web/assets.js"; |
| 5 | import { Actor, Status, Time } from "../web/ui.js"; |
| 6 | import { AGENT_TEXT, NEEDS_TEXT, TASK_TEXT, type AgentItem, type NeedsItem, type Today } from "./today.js"; |
| 7 | import type { TodayPull } from "./today-repo.js"; |
| 8 | import type { MyTask } from "./tasks.js"; |
| 9 | |
| 10 | /** |
| 11 | * The Today view's sections on the dashboard (web/account.tsx): what needs you, your agents, and the last day. |
| 12 | * Each section folds away (the site script remembers which in COLLAPSED_COOKIE, so the page renders them that |
| 13 | * way) and shows its newest SHOWN rows, with the rest behind "Show N more". |
| 14 | */ |
| 15 | |
| 16 | /** Rows a section shows before "Show N more". */ |
| 17 | export const SHOWN = 3; |
| 18 | /** Which sections are folded away: their ids, joined with dots. Written by the site script (web/assets.ts). */ |
| 19 | export const COLLAPSED_COOKIE = "today_collapsed"; |
| 20 | const SECTIONS = ["needs-you", "deployments", "agents", "tasks", "landed", "notifications"]; |
| 21 | |
| 22 | /** The sections a COLLAPSED_COOKIE value folds away. */ |
| 23 | export function collapsedSections(cookie: string | undefined): Set<string> { |
| 24 | return new Set((cookie ?? "").split(".").filter((id) => SECTIONS.includes(id))); |
| 25 | } |
| 26 | |
| 27 | const ChecksIcon: FC<{ p: TodayPull }> = ({ p }) => |
| 28 | p.checks === "none" ? ( |
| 29 | <span class="st faint" title="No checks">{raw(ICONS.pr)}</span> |
| 30 | ) : p.checks === "pending" ? ( |
| 31 | <Status status="in_progress" /> |
| 32 | ) : ( |
| 33 | <Status conclusion={p.checks} /> |
| 34 | ); |
| 35 | |
| 36 | const PullLine: FC<{ repo: { fullName: string; url: string }; p: TodayPull; badge: { label: string; tone: string } }> = ({ repo, p, badge }) => ( |
| 37 | <li> |
| 38 | <ChecksIcon p={p} /> |
| 39 | <div class="grow"> |
| 40 | <a class="title" href={`${repo.url}/pull/${p.number}`}> |
| 41 | {p.title} |
| 42 | </a>{" "} |
| 43 | <span class={`badge ${badge.tone}`}>{badge.label}</span> |
| 44 | <div class="meta"> |
| 45 | <a href={repo.url} class="muted"> |
| 46 | {repo.fullName} |
| 47 | </a>{" "} |
| 48 | #{p.number} by <Actor login={p.author.login} kind={p.author.kind} avatar={false} /> · updated <Time at={p.updatedAt} /> |
| 49 | {p.failingChecks.length ? <> · failing: {p.failingChecks.slice(0, 3).join(", ")}</> : null} |
| 50 | {p.changesRequestedBy.length ? <> · changes requested by {p.changesRequestedBy.join(", ")}</> : null} |
| 51 | </div> |
| 52 | {p.intent ? <div class="meta">Intent: {p.intent.length > 160 ? `${p.intent.slice(0, 157)}…` : p.intent}</div> : null} |
| 53 | </div> |
| 54 | </li> |
| 55 | ); |
| 56 | |
| 57 | const Tile: FC<{ n: number; label: string; href: string; tone?: string }> = ({ n, label, href, tone }) => ( |
| 58 | <a class={`tile ${n && tone ? tone : ""}`} href={href}> |
| 59 | <span class="num">{n}</span> |
| 60 | <span class="lbl">{label}</span> |
| 61 | </a> |
| 62 | ); |
| 63 | |
| 64 | export const TodayView: FC<{ today: Today; name: string; collapsed?: Set<string> }> = ({ today, name, collapsed = new Set() }) => { |
| 65 | const working = today.agentsAtWork.filter((x) => x.state !== "draft").length; |
| 66 | const landedByAgents = today.landed.filter((x) => x.pull.author.kind === "agent").length; |
| 67 | return ( |
| 68 | <div> |
| 69 | <div class="row between mb"> |
| 70 | <h1 class="m0" style="font-size:22px">Today</h1> |
| 71 | <span class="muted small"> |
| 72 | {name} · {today.repositories} active {today.repositories === 1 ? "repository" : "repositories"} |
| 73 | {today.incomplete ? " (some left out)" : ""} |
| 74 | </span> |
| 75 | </div> |
| 76 | <div class="tiles"> |
| 77 | <Tile n={today.needsYou.length} label="need you" href="#needs-you" tone="purple" /> |
| 78 | <Tile n={working} label="agent pull requests open" href="#agents" /> |
| 79 | <Tile n={today.landed.length} label={`merged since yesterday${landedByAgents ? ` (${landedByAgents} by agents)` : ""}`} href="#landed" /> |
| 80 | <Tile n={today.failures.length} label="failed runs" href="#landed" tone="red" /> |
| 81 | </div> |
| 82 | <NeedsYou items={today.needsYou} collapsed={collapsed} /> |
| 83 | {today.deployments.length ? <Deployments items={today.deployments} collapsed={collapsed} /> : null} |
| 84 | <Agents items={today.agentsAtWork} collapsed={collapsed} /> |
| 85 | {today.tasks.length ? <Tasks items={today.tasks} collapsed={collapsed} /> : null} |
| 86 | <SinceYesterday today={today} collapsed={collapsed} /> |
| 87 | </div> |
| 88 | ); |
| 89 | }; |
| 90 | |
| 91 | /** A Today section that folds away from its header (the dashboard's unread notifications use it too). */ |
| 92 | export const Section: FC<{ id: string; icon: string; title: string; count: number; note?: string; collapsed: Set<string>; action?: Child; children?: Child }> = (p) => ( |
| 93 | <details class="box today-sec" id={p.id} data-today-sec={p.id} open={!p.collapsed.has(p.id)}> |
| 94 | <summary class="head"> |
| 95 | <span class="tog">{raw(ICONS.caret)}</span> |
| 96 | <h2> |
| 97 | {raw(p.icon)} {p.title} |
| 98 | </h2> |
| 99 | {p.count ? <span class="count">{p.count}</span> : null} |
| 100 | {p.note ? <span class="muted small note">{p.note}</span> : null} |
| 101 | {p.action} |
| 102 | </summary> |
| 103 | {p.children} |
| 104 | </details> |
| 105 | ); |
| 106 | |
| 107 | /** The first SHOWN rows, and the rest behind "Show N more". */ |
| 108 | export function Rows<T>({ items, row }: { items: T[]; row: (x: T) => Child }) { |
| 109 | const rest = items.slice(SHOWN); |
| 110 | return ( |
| 111 | <> |
| 112 | <ul class="list">{items.slice(0, SHOWN).map(row)}</ul> |
| 113 | {rest.length ? ( |
| 114 | <details class="today-more"> |
| 115 | <summary> |
| 116 | <span class="btn sm"> |
| 117 | Show {rest.length} more {raw(ICONS.caret)} |
| 118 | </span> |
| 119 | </summary> |
| 120 | <ul class="list">{rest.map(row)}</ul> |
| 121 | <div class="fewer" hidden> |
| 122 | <button type="button" class="btn sm" data-show-fewer> |
| 123 | Show fewer |
| 124 | </button> |
| 125 | </div> |
| 126 | </details> |
| 127 | ) : null} |
| 128 | </> |
| 129 | ); |
| 130 | } |
| 131 | |
| 132 | const Tasks: FC<{ items: MyTask[]; collapsed: Set<string> }> = ({ items, collapsed }) => ( |
| 133 | <Section id="tasks" icon={ICONS.zap} title="Your agent tasks" count={items.length} note="Running, or changed since yesterday" collapsed={collapsed}> |
| 134 | <Rows |
| 135 | items={items} |
| 136 | row={(t) => ( |
| 137 | <li> |
| 138 | <div class="grow"> |
| 139 | <a href={`/${t.repository}/tasks/${t.number}`}> |
| 140 | <b>{t.title}</b> |
| 141 | </a>{" "} |
| 142 | <span class={`badge ${TASK_TEXT[t.state].tone}`}>{TASK_TEXT[t.state].label}</span> |
| 143 | <div class="muted small"> |
| 144 | {t.repository} · task #{t.number} · {t.agent} |
| 145 | {t.prNumber ? ( |
| 146 | <> |
| 147 | {" "} |
| 148 | · <a href={`/${t.repository}/pull/${t.prNumber}`}>#{t.prNumber}</a> |
| 149 | </> |
| 150 | ) : null} |
| 151 | </div> |
| 152 | </div> |
| 153 | <span class="muted small"> |
| 154 | <Time at={t.updatedAt} /> |
| 155 | </span> |
| 156 | </li> |
| 157 | )} |
| 158 | /> |
| 159 | </Section> |
| 160 | ); |
| 161 | |
| 162 | const Deployments: FC<{ items: Today["deployments"]; collapsed: Set<string> }> = ({ items, collapsed }) => ( |
| 163 | <Section id="deployments" icon={ICONS.rocket} title="Deployments waiting for you" count={items.length} note="You're a required reviewer for these environments" collapsed={collapsed}> |
| 164 | <Rows |
| 165 | items={items} |
| 166 | row={({ repo, deployment: d }) => ( |
| 167 | <li> |
| 168 | <div class="grow"> |
| 169 | <a href={`${repo.url}/actions/runs/${d.runId}`}> |
| 170 | <b>{d.environment}</b>: {d.title || d.workflow} |
| 171 | </a> |
| 172 | <div class="muted small"> |
| 173 | {repo.fullName} · {d.workflow} #{d.runNumber} on {d.ref.replace(/^refs\/(heads|tags)\//, "")} · started by {d.actor.login} |
| 174 | </div> |
| 175 | </div> |
| 176 | <span class="muted small"> |
| 177 | <Time at={d.since} /> |
| 178 | </span> |
| 179 | </li> |
| 180 | )} |
| 181 | /> |
| 182 | </Section> |
| 183 | ); |
| 184 | |
| 185 | const NeedsYou: FC<{ items: NeedsItem[]; collapsed: Set<string> }> = ({ items, collapsed }) => ( |
| 186 | <Section id="needs-you" icon={ICONS.eye} title="Needs you" count={items.length} note="Reviews you were asked for, agent work waiting for a person, and your own blocked pull requests" collapsed={collapsed}> |
| 187 | {items.length ? <Rows items={items.slice(0, 30)} row={(x) => <PullLine repo={x.repo} p={x.pull} badge={NEEDS_TEXT[x.kind]} />} /> : <div class="empty">Nothing is waiting on you.</div>} |
| 188 | </Section> |
| 189 | ); |
| 190 | |
| 191 | const Agents: FC<{ items: AgentItem[]; collapsed: Set<string> }> = ({ items, collapsed }) => ( |
| 192 | <Section |
| 193 | id="agents" |
| 194 | icon={ICONS.bot} |
| 195 | title="Agents at work" |
| 196 | count={items.length} |
| 197 | note="Open pull requests from agents you and your organizations own" |
| 198 | collapsed={collapsed} |
| 199 | action={ |
| 200 | <a class="small manage" href="/settings/agents"> |
| 201 | Manage agents |
| 202 | </a> |
| 203 | } |
| 204 | > |
| 205 | {items.length ? ( |
| 206 | <Rows items={items.slice(0, 30)} row={(x) => <PullLine repo={x.repo} p={x.pull} badge={AGENT_TEXT[x.state]} />} /> |
| 207 | ) : ( |
| 208 | <div class="empty"> |
| 209 | Your agents have no open pull requests. <a href="/docs/agents/overview">Set up a coding agent</a> |
| 210 | </div> |
| 211 | )} |
| 212 | </Section> |
| 213 | ); |
| 214 | |
| 215 | type Happened = { at: string; row: () => Child }; |
| 216 | |
| 217 | const SinceYesterday: FC<{ today: Today; collapsed: Set<string> }> = ({ today, collapsed }) => { |
| 218 | // One list, newest first, so the first rows are the latest whatever they are. |
| 219 | const items: Happened[] = [ |
| 220 | ...today.releases.map((x) => ({ |
| 221 | at: x.release.publishedAt, |
| 222 | row: () => ( |
| 223 | <li> |
| 224 | <span class="st ok">{raw(ICONS.tag)}</span> |
| 225 | <div class="grow"> |
| 226 | <a class="title" href={`${x.repo.url}/releases/tag/${encodeURIComponent(x.release.tag)}`}> |
| 227 | {x.release.name} |
| 228 | </a>{" "} |
| 229 | <span class="badge green">{x.release.prerelease ? "Pre-release" : "Released"}</span> |
| 230 | <div class="meta"> |
| 231 | {x.repo.fullName} · by <Actor login={x.release.author.login} kind={x.release.author.kind} avatar={false} /> · <Time at={x.release.publishedAt} /> |
| 232 | </div> |
| 233 | </div> |
| 234 | </li> |
| 235 | ), |
| 236 | })), |
| 237 | ...today.failures.map((x) => ({ |
| 238 | at: x.run.at, |
| 239 | row: () => ( |
| 240 | <li> |
| 241 | <span class="st bad">{raw(ICONS.failure)}</span> |
| 242 | <div class="grow"> |
| 243 | <a class="title" href={`${x.repo.url}/actions/runs/${x.run.id}`}> |
| 244 | {x.run.title || x.run.workflow} |
| 245 | </a>{" "} |
| 246 | <span class="badge red">{x.run.workflow} failed</span> |
| 247 | <div class="meta"> |
| 248 | {x.repo.fullName} · {x.run.ref.replace(/^refs\/heads\//, "")} · <Actor login={x.run.actor.login} kind={x.run.actor.kind} avatar={false} /> · <Time at={x.run.at} /> |
| 249 | </div> |
| 250 | </div> |
| 251 | </li> |
| 252 | ), |
| 253 | })), |
| 254 | ...today.landed.map((x) => ({ |
| 255 | at: x.pull.mergedAt, |
| 256 | row: () => ( |
| 257 | <li> |
| 258 | <span class="st" style="color:var(--purple)"> |
| 259 | {raw(ICONS.prMerged)} |
| 260 | </span> |
| 261 | <div class="grow"> |
| 262 | <a class="title" href={`${x.repo.url}/pull/${x.pull.number}`}> |
| 263 | {x.pull.title} |
| 264 | </a>{" "} |
| 265 | <span class="badge purple">Merged</span> |
| 266 | <div class="meta"> |
| 267 | {x.repo.fullName} #{x.pull.number} by <Actor login={x.pull.author.login} kind={x.pull.author.kind} avatar={false} /> |
| 268 | {x.pull.mergedBy ? <> · merged by {x.pull.mergedBy}</> : null} · <Time at={x.pull.mergedAt} /> |
| 269 | </div> |
| 270 | </div> |
| 271 | </li> |
| 272 | ), |
| 273 | })), |
| 274 | ].sort((a, b) => b.at.localeCompare(a.at)); |
| 275 | return ( |
| 276 | <Section id="landed" icon={ICONS.history} title="Since yesterday" count={items.length} note="Merged, released and failed in the last 24 hours" collapsed={collapsed}> |
| 277 | {items.length ? <Rows items={items} row={(x) => x.row()} /> : <div class="empty">Nothing merged, released or failed in the last 24 hours.</div>} |
| 278 | </Section> |
| 279 | ); |
| 280 | }; |