Dark gray base with lavender as an accent, and a live outcome view for plans
The palette moves to near-neutral dark grays; lavender and peach are accents for art and status, mint for what is live or passed. An applied plan's page now shows the outcome converging: each issue as a card in a dependency graph with where it stands (blocked, waiting, being worked, in review, in the queue, landed, needing you), how many have landed, how many agents are at work, and what they have cost.
8 files+464−320/8 viewed
| 186 | 186 | } | |
| 187 | 187 | ||
| 188 | 188 | /* | |
| 189 | − | * The sky after dusk: deep indigo overhead, a lavender and peach glow at | |
| 190 | − | * the horizon, a few stars. Behind the landing page's opening. | |
| 189 | + | * Behind the landing page's opening: the same dark gray, with a faint | |
| 190 | + | * lavender and peach glow low on the horizon and a few stars. | |
| 191 | 191 | */ | |
| 192 | 192 | .dusk { | |
| 193 | 193 | background: | |
| 194 | − | radial-gradient(1px 1px at 12% 18%, rgb(255 255 255 / 0.5), transparent), | |
| 195 | − | radial-gradient(1px 1px at 27% 8%, rgb(255 255 255 / 0.35), transparent), | |
| 196 | − | radial-gradient(1px 1px at 44% 22%, rgb(255 255 255 / 0.3), transparent), | |
| 197 | − | radial-gradient(1px 1px at 63% 12%, rgb(255 255 255 / 0.45), transparent), | |
| 198 | − | radial-gradient(1px 1px at 81% 26%, rgb(255 255 255 / 0.3), transparent), | |
| 199 | − | radial-gradient(1.5px 1.5px at 90% 9%, rgb(255 255 255 / 0.5), transparent), | |
| 200 | − | radial-gradient(70% 45% at 50% 100%, rgb(255 189 140 / 0.22), transparent 70%), | |
| 201 | − | radial-gradient(90% 60% at 50% 95%, rgb(182 168 255 / 0.28), transparent 75%), | |
| 202 | − | linear-gradient(to bottom, #0b0c18 0%, #15163a 55%, #2a2556 100%); | |
| 194 | + | radial-gradient(1px 1px at 12% 18%, rgb(255 255 255 / 0.35), transparent), | |
| 195 | + | radial-gradient(1px 1px at 27% 8%, rgb(255 255 255 / 0.25), transparent), | |
| 196 | + | radial-gradient(1px 1px at 63% 12%, rgb(255 255 255 / 0.3), transparent), | |
| 197 | + | radial-gradient(1.5px 1.5px at 90% 9%, rgb(255 255 255 / 0.35), transparent), | |
| 198 | + | radial-gradient(60% 35% at 50% 100%, rgb(255 189 140 / 0.08), transparent 70%), | |
| 199 | + | radial-gradient(80% 45% at 50% 100%, rgb(182 168 255 / 0.1), transparent 75%), | |
| 200 | + | var(--color-bg); | |
| 203 | 201 | } | |
| 204 | 202 | /* A planet rising behind the horizon, its lit edge catching the glow. */ | |
| 205 | 203 | .dusk-planet { | |
| 206 | − | background: radial-gradient(circle at 70% 30%, rgb(182 168 255 / 0.35), rgb(255 189 140 / 0.12) 45%, transparent 70%); | |
| 207 | − | box-shadow: inset 18px -10px 60px rgb(255 214 186 / 0.18); | |
| 204 | + | background: radial-gradient(circle at 70% 30%, rgb(182 168 255 / 0.12), rgb(255 189 140 / 0.04) 45%, transparent 70%); | |
| 205 | + | box-shadow: inset 14px -8px 50px rgb(255 214 186 / 0.07); | |
| 208 | 206 | } |
| 1 | + | import { | |
| 2 | + | Check, | |
| 3 | + | CircleDashed, | |
| 4 | + | Eye, | |
| 5 | + | Hand, | |
| 6 | + | Layers, | |
| 7 | + | Lock, | |
| 8 | + | Loader2, | |
| 9 | + | Sparkles, | |
| 10 | + | Terminal, | |
| 11 | + | X, | |
| 12 | + | } from "lucide-react"; | |
| 13 | + | import { type ReactNode, useLayoutEffect, useRef, useState } from "react"; | |
| 14 | + | import { Link } from "react-router"; | |
| 15 | + | ||
| 16 | + | import type { IssueProgress, Plan } from "@g1t/contracts"; | |
| 17 | + | ||
| 18 | + | import { Avatar } from "./ui"; | |
| 19 | + | ||
| 20 | + | type Look = { label: string; icon: ReactNode; ring: string; text: string; bar: string; live?: boolean }; | |
| 21 | + | ||
| 22 | + | /** How each state looks: its colour, icon and a word for it. */ | |
| 23 | + | const LOOK: Record<string, Look> = { | |
| 24 | + | blocked: { | |
| 25 | + | label: "Blocked", | |
| 26 | + | icon: <Lock size={13} />, | |
| 27 | + | ring: "ring-line border-dashed", | |
| 28 | + | text: "text-faint", | |
| 29 | + | bar: "bg-line-strong", | |
| 30 | + | }, | |
| 31 | + | open: { label: "Open", icon: <CircleDashed size={13} />, ring: "ring-line", text: "text-muted", bar: "bg-line-strong" }, | |
| 32 | + | waiting: { | |
| 33 | + | label: "Waiting for an agent", | |
| 34 | + | icon: <CircleDashed size={13} />, | |
| 35 | + | ring: "ring-line-strong", | |
| 36 | + | text: "text-muted", | |
| 37 | + | bar: "bg-faint", | |
| 38 | + | }, | |
| 39 | + | working: { | |
| 40 | + | label: "Agent working", | |
| 41 | + | icon: <Sparkles size={13} />, | |
| 42 | + | ring: "ring-merged/60", | |
| 43 | + | text: "text-merged", | |
| 44 | + | bar: "bg-merged", | |
| 45 | + | live: true, | |
| 46 | + | }, | |
| 47 | + | revising: { | |
| 48 | + | label: "Revising", | |
| 49 | + | icon: <Sparkles size={13} />, | |
| 50 | + | ring: "ring-merged/60", | |
| 51 | + | text: "text-merged", | |
| 52 | + | bar: "bg-merged", | |
| 53 | + | live: true, | |
| 54 | + | }, | |
| 55 | + | catching_up: { | |
| 56 | + | label: "Catching up", | |
| 57 | + | icon: <Loader2 size={13} className="animate-spin" />, | |
| 58 | + | ring: "ring-merged/60", | |
| 59 | + | text: "text-merged", | |
| 60 | + | bar: "bg-merged", | |
| 61 | + | live: true, | |
| 62 | + | }, | |
| 63 | + | checking: { | |
| 64 | + | label: "Checking", | |
| 65 | + | icon: <Terminal size={13} />, | |
| 66 | + | ring: "ring-info/60", | |
| 67 | + | text: "text-info", | |
| 68 | + | bar: "bg-info", | |
| 69 | + | live: true, | |
| 70 | + | }, | |
| 71 | + | reviewing: { | |
| 72 | + | label: "In review", | |
| 73 | + | icon: <Eye size={13} />, | |
| 74 | + | ring: "ring-info/60", | |
| 75 | + | text: "text-info", | |
| 76 | + | bar: "bg-info", | |
| 77 | + | live: true, | |
| 78 | + | }, | |
| 79 | + | queued: { | |
| 80 | + | label: "In the merge queue", | |
| 81 | + | icon: <Layers size={13} />, | |
| 82 | + | ring: "ring-accent/60", | |
| 83 | + | text: "text-accent", | |
| 84 | + | bar: "bg-accent/60", | |
| 85 | + | live: true, | |
| 86 | + | }, | |
| 87 | + | ready: { label: "Ready to merge", icon: <Check size={13} />, ring: "ring-accent/60", text: "text-accent", bar: "bg-accent/60" }, | |
| 88 | + | needs_you: { label: "Needs you", icon: <Hand size={13} />, ring: "ring-warn/70", text: "text-warn", bar: "bg-warn" }, | |
| 89 | + | landed: { label: "Landed", icon: <Check size={13} />, ring: "ring-accent/30", text: "text-accent", bar: "bg-accent" }, | |
| 90 | + | closed: { label: "Closed", icon: <X size={13} />, ring: "ring-line", text: "text-faint", bar: "bg-line" }, | |
| 91 | + | }; | |
| 92 | + | ||
| 93 | + | const look = (state: string) => LOOK[state] ?? LOOK.open!; | |
| 94 | + | ||
| 95 | + | /** Columns by how deep each issue sits in the plan's dependencies. */ | |
| 96 | + | function columns(plan: Plan): { position: number; item: IssueProgress }[][] { | |
| 97 | + | const byNumber = new Map(plan.progress.map((item) => [item.number, item])); | |
| 98 | + | const depth = new Map<number, number>(); | |
| 99 | + | const depthOf = (position: number, seen = new Set<number>()): number => { | |
| 100 | + | if (depth.has(position)) return depth.get(position)!; | |
| 101 | + | if (seen.has(position)) return 0; | |
| 102 | + | seen.add(position); | |
| 103 | + | const deps = plan.issues[position - 1]?.dependsOn ?? []; | |
| 104 | + | const value = deps.length === 0 ? 0 : 1 + Math.max(...deps.map((dep) => depthOf(dep, seen))); | |
| 105 | + | depth.set(position, value); | |
| 106 | + | return value; | |
| 107 | + | }; | |
| 108 | + | const result: { position: number; item: IssueProgress }[][] = []; | |
| 109 | + | plan.issues.forEach((issue, index) => { | |
| 110 | + | const item = issue.number != null ? byNumber.get(issue.number) : undefined; | |
| 111 | + | if (!item) return; | |
| 112 | + | const column = depthOf(index + 1); | |
| 113 | + | (result[column] ??= []).push({ position: index + 1, item }); | |
| 114 | + | }); | |
| 115 | + | return result.filter(Boolean); | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | function Node({ item, base }: { item: IssueProgress; base: string }) { | |
| 119 | + | const style = look(item.state); | |
| 120 | + | const to = item.pull != null ? `${base}/pull/${item.pull}` : `${base}/issues/${item.number}`; | |
| 121 | + | return ( | |
| 122 | + | <Link | |
| 123 | + | to={to} | |
| 124 | + | prefetch="intent" | |
| 125 | + | data-node={item.number} | |
| 126 | + | className={`group relative block rounded-2xl border border-transparent bg-surface p-4 ring-1 transition-all hover:-translate-y-0.5 hover:bg-raised ${style.ring}`} | |
| 127 | + | > | |
| 128 | + | {style.live && ( | |
| 129 | + | <span aria-hidden="true" className={`absolute top-3 right-3 size-2 animate-pulse rounded-full ${style.bar}`} /> | |
| 130 | + | )} | |
| 131 | + | <p className={`flex items-center gap-1.5 text-xs font-medium ${style.text}`}> | |
| 132 | + | {style.icon} | |
| 133 | + | {style.label} | |
| 134 | + | </p> | |
| 135 | + | <p className={`mt-2 text-sm leading-snug font-medium ${item.state === "closed" ? "text-faint line-through" : ""}`}> | |
| 136 | + | {item.title} <span className="font-normal text-faint">#{item.number}</span> | |
| 137 | + | </p> | |
| 138 | + | {item.detail && <p className="mt-1.5 line-clamp-2 text-xs leading-5 text-muted">{item.detail}</p>} | |
| 139 | + | {item.agent && ( | |
| 140 | + | <p className="mt-2.5 flex items-center gap-1.5 font-mono text-[0.6875rem] text-faint"> | |
| 141 | + | <Avatar name={item.agent} size={14} /> | |
| 142 | + | {item.agent} | |
| 143 | + | {item.pull != null && <span>· #{item.pull}</span>} | |
| 144 | + | </p> | |
| 145 | + | )} | |
| 146 | + | </Link> | |
| 147 | + | ); | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | /** Lines from each issue to the ones that depend on it, measured from the page. */ | |
| 151 | + | function Edges({ plan, container }: { plan: Plan; container: React.RefObject<HTMLDivElement | null> }) { | |
| 152 | + | const [paths, setPaths] = useState<{ d: string; done: boolean }[]>([]); | |
| 153 | + | useLayoutEffect(() => { | |
| 154 | + | const element = container.current; | |
| 155 | + | if (!element) return; | |
| 156 | + | const draw = () => { | |
| 157 | + | const box = element.getBoundingClientRect(); | |
| 158 | + | const at = (number: number) => | |
| 159 | + | element.querySelector<HTMLElement>(`[data-node="${number}"]`)?.getBoundingClientRect(); | |
| 160 | + | const next: { d: string; done: boolean }[] = []; | |
| 161 | + | plan.issues.forEach((issue) => { | |
| 162 | + | if (issue.number == null) return; | |
| 163 | + | const to = at(issue.number); | |
| 164 | + | for (const dep of issue.dependsOn) { | |
| 165 | + | const fromNumber = plan.issues[dep - 1]?.number; | |
| 166 | + | if (fromNumber == null) continue; | |
| 167 | + | const from = at(fromNumber); | |
| 168 | + | if (!from || !to) continue; | |
| 169 | + | const x1 = from.right - box.left; | |
| 170 | + | const y1 = from.top + from.height / 2 - box.top; | |
| 171 | + | const x2 = to.left - box.left; | |
| 172 | + | const y2 = to.top + to.height / 2 - box.top; | |
| 173 | + | const mid = (x1 + x2) / 2; | |
| 174 | + | const done = plan.progress.find((item) => item.number === fromNumber)?.state === "landed"; | |
| 175 | + | next.push({ d: `M${x1},${y1} C${mid},${y1} ${mid},${y2} ${x2},${y2}`, done }); | |
| 176 | + | } | |
| 177 | + | }); | |
| 178 | + | setPaths(next); | |
| 179 | + | }; | |
| 180 | + | draw(); | |
| 181 | + | const observer = new ResizeObserver(draw); | |
| 182 | + | observer.observe(element); | |
| 183 | + | return () => observer.disconnect(); | |
| 184 | + | }, [plan, container]); | |
| 185 | + | return ( | |
| 186 | + | <svg aria-hidden="true" className="pointer-events-none absolute inset-0 size-full overflow-visible"> | |
| 187 | + | {paths.map((path, index) => ( | |
| 188 | + | <path | |
| 189 | + | key={index} | |
| 190 | + | d={path.d} | |
| 191 | + | fill="none" | |
| 192 | + | stroke={path.done ? "var(--color-accent)" : "var(--color-line-strong)"} | |
| 193 | + | strokeOpacity={path.done ? 0.7 : 1} | |
| 194 | + | strokeWidth={1.5} | |
| 195 | + | strokeDasharray={path.done ? undefined : "4 5"} | |
| 196 | + | className={path.done ? undefined : "art-flow"} | |
| 197 | + | /> | |
| 198 | + | ))} | |
| 199 | + | </svg> | |
| 200 | + | ); | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | /** | |
| 204 | + | * An outcome being converged: the plan's issues as a graph, each showing | |
| 205 | + | * where it stands now, and how far the whole has come. | |
| 206 | + | */ | |
| 207 | + | export function Outcome({ plan, base, costMicros }: { plan: Plan; base: string; costMicros: number | null }) { | |
| 208 | + | const graph = useRef<HTMLDivElement>(null); | |
| 209 | + | const total = plan.progress.length; | |
| 210 | + | const count = (states: string[]) => plan.progress.filter((item) => states.includes(item.state)).length; | |
| 211 | + | const landed = count(["landed"]); | |
| 212 | + | const live = plan.progress.filter((item) => look(item.state).live).length; | |
| 213 | + | const needsYou = count(["needs_you"]); | |
| 214 | + | const blocked = count(["blocked", "waiting", "open"]); | |
| 215 | + | const order = ["landed", "queued", "ready", "reviewing", "checking", "working", "revising", "catching_up", "needs_you", "waiting", "blocked", "open", "closed"]; | |
| 216 | + | const sorted = [...plan.progress].sort((a, b) => order.indexOf(a.state) - order.indexOf(b.state)); | |
| 217 | + | ||
| 218 | + | return ( | |
| 219 | + | <div> | |
| 220 | + | <div className="grid gap-4 sm:grid-cols-4"> | |
| 221 | + | {[ | |
| 222 | + | ["Landed", `${landed} of ${total}`, "text-accent"], | |
| 223 | + | ["Agents at work", String(live), "text-merged"], | |
| 224 | + | ["Needs you", String(needsYou), needsYou > 0 ? "text-warn" : "text-muted"], | |
| 225 | + | [ | |
| 226 | + | "Agents have cost", | |
| 227 | + | costMicros == null ? "—" : `$${(costMicros / 1_000_000).toFixed(2)}`, | |
| 228 | + | "text-fg", | |
| 229 | + | ], | |
| 230 | + | ].map(([label, value, tone]) => ( | |
| 231 | + | <div key={label} className="rounded-2xl bg-surface px-4 py-3 ring-1 ring-line"> | |
| 232 | + | <p className="text-xs text-muted">{label}</p> | |
| 233 | + | <p className={`mt-1 text-2xl font-semibold tabular-nums ${tone}`}>{value}</p> | |
| 234 | + | </div> | |
| 235 | + | ))} | |
| 236 | + | </div> | |
| 237 | + | {/* Every issue, as one bar. */} | |
| 238 | + | <div className="mt-4 flex h-2 gap-0.5 overflow-hidden rounded-full" role="img" aria-label={`${landed} of ${total} landed`}> | |
| 239 | + | {sorted.map((item) => ( | |
| 240 | + | <span key={item.number} className={`grow transition-colors duration-700 ${look(item.state).bar}`} /> | |
| 241 | + | ))} | |
| 242 | + | </div> | |
| 243 | + | <p className="mt-2 text-xs text-muted"> | |
| 244 | + | {landed === total | |
| 245 | + | ? "Everything in the plan has landed." | |
| 246 | + | : `${live} in flight, ${blocked} waiting their turn${needsYou ? `, ${needsYou} waiting on you` : ""}.`} | |
| 247 | + | </p> | |
| 248 | + | ||
| 249 | + | {columns(plan).length === 1 ? ( | |
| 250 | + | // Nothing depends on anything: every issue starts at once, side by side. | |
| 251 | + | <div className="mt-10"> | |
| 252 | + | <p className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase"> | |
| 253 | + | All start at once | |
| 254 | + | </p> | |
| 255 | + | <div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-4"> | |
| 256 | + | {columns(plan)[0]!.map(({ item }) => ( | |
| 257 | + | <Node key={item.number} item={item} base={base} /> | |
| 258 | + | ))} | |
| 259 | + | </div> | |
| 260 | + | </div> | |
| 261 | + | ) : ( | |
| 262 | + | <div className="mt-10 overflow-x-auto pb-4"> | |
| 263 | + | <div ref={graph} className="relative flex min-w-max gap-14"> | |
| 264 | + | <Edges plan={plan} container={graph} /> | |
| 265 | + | {columns(plan).map((column, index) => ( | |
| 266 | + | <div key={index} className="flex w-64 flex-col justify-center gap-4"> | |
| 267 | + | <p className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase"> | |
| 268 | + | {index === 0 ? "Starts at once" : `Then, step ${index + 1}`} | |
| 269 | + | </p> | |
| 270 | + | {column.map(({ item }) => ( | |
| 271 | + | <Node key={item.number} item={item} base={base} /> | |
| 272 | + | ))} | |
| 273 | + | </div> | |
| 274 | + | ))} | |
| 275 | + | </div> | |
| 276 | + | </div> | |
| 277 | + | )} | |
| 278 | + | </div> | |
| 279 | + | ); | |
| 280 | + | } |
| 332 | 332 | <head> | |
| 333 | 333 | <meta charSet="utf-8" /> | |
| 334 | 334 | <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| 335 | − | <meta name="theme-color" content="#0b0c18" /> | |
| 335 | + | <meta name="theme-color" content="#0f0f11" /> | |
| 336 | 336 | <Meta /> | |
| 337 | 337 | <Links /> | |
| 338 | 338 | </head> |
| 5 | 5 | ||
| 6 | 6 | import type { Route } from "./+types/plan"; | |
| 7 | 7 | import { Markdown } from "../../components/markdown"; | |
| 8 | + | import { Outcome } from "../../components/outcome"; | |
| 8 | 9 | import { Button, ErrorText, TimeAgo } from "../../components/ui"; | |
| 9 | 10 | import { Label } from "../../components/work"; | |
| 10 | − | import { work } from "../../lib/services.server"; | |
| 11 | + | import { billing, work } from "../../lib/services.server"; | |
| 11 | 12 | import { | |
| 12 | 13 | assertSameOrigin, | |
| 13 | 14 | getViewer, | |
| 27 | 28 | // Members only; to anyone else the page does not exist. | |
| 28 | 29 | if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 }); | |
| 29 | 30 | const path = { namespace: params.owner, name: params.repo }; | |
| 30 | − | return { plan: unwrap(await work.getPlan(path, viewer, params.id)) }; | |
| 31 | + | const [plan, ledger] = await Promise.all([ | |
| 32 | + | work.getPlan(path, viewer, params.id), | |
| 33 | + | billing.ledger(params.owner, viewer), | |
| 34 | + | ]); | |
| 35 | + | const found = unwrap(plan); | |
| 36 | + | // What the agents working on this outcome have cost so far. | |
| 37 | + | const pulls = new Set(found.progress.flatMap((item) => (item.pull != null ? [item.pull] : []))); | |
| 38 | + | const repo = `${params.owner}/${params.repo}`; | |
| 39 | + | const costMicros = ledger.ok | |
| 40 | + | ? ledger.value | |
| 41 | + | .filter((entry) => entry.kind === "usage" && entry.repo === repo && entry.number != null && pulls.has(entry.number)) | |
| 42 | + | .reduce((sum, entry) => sum - entry.amountMicros, 0) | |
| 43 | + | : null; | |
| 44 | + | return { plan: found, costMicros }; | |
| 31 | 45 | } | |
| 32 | 46 | ||
| 33 | 47 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 43 | 57 | } | |
| 44 | 58 | ||
| 45 | 59 | export default function PlanPage({ loaderData, actionData, params }: Route.ComponentProps) { | |
| 46 | − | const { plan } = loaderData; | |
| 60 | + | const { plan, costMicros } = loaderData; | |
| 47 | 61 | const base = `/${params.owner}/${params.repo}`; | |
| 48 | 62 | const applying = useNavigation().state === "submitting"; | |
| 49 | 63 | ||
| 50 | 64 | // The agent is still writing it. | |
| 51 | 65 | const revalidator = useRevalidator(); | |
| 52 | 66 | const planning = plan.status === "planning"; | |
| 67 | + | // Planning, or agents still converging what was applied. | |
| 68 | + | const moving = | |
| 69 | + | planning || plan.progress.some((item) => item.state !== "landed" && item.state !== "closed"); | |
| 53 | 70 | useEffect(() => { | |
| 54 | − | if (!planning) return; | |
| 71 | + | if (!moving) return; | |
| 55 | 72 | const timer = setInterval(() => { | |
| 56 | 73 | if (document.visibilityState === "visible") revalidator.revalidate(); | |
| 57 | 74 | }, REFRESH_MS); | |
| 58 | 75 | return () => clearInterval(timer); | |
| 59 | − | }, [planning, revalidator]); | |
| 76 | + | }, [moving, revalidator]); | |
| 77 | + | const converging = plan.status === "applied" && plan.progress.length > 0; | |
| 60 | 78 | ||
| 61 | 79 | const independent = plan.issues.filter((issue) => issue.dependsOn.length === 0).length; | |
| 62 | 80 | return ( | |
| 63 | − | <div className="max-w-4xl"> | |
| 81 | + | <div className={converging ? "" : "max-w-4xl"}> | |
| 64 | 82 | <p className="text-sm"> | |
| 65 | 83 | <Link to={`${base}/plans`} className="text-muted hover:text-fg"> | |
| 66 | 84 | Plans | |
| 74 | 92 | Asked for by {plan.author.username} <TimeAgo at={plan.createdAt} /> | |
| 75 | 93 | </p> | |
| 76 | 94 | ||
| 95 | + | {converging && ( | |
| 96 | + | <section className="mt-8"> | |
| 97 | + | <Outcome plan={plan} base={base} costMicros={costMicros} /> | |
| 98 | + | </section> | |
| 99 | + | )} | |
| 100 | + | ||
| 77 | 101 | {planning && ( | |
| 78 | 102 | <div className="mt-8 flex items-center gap-3 rounded-xl border border-accent/30 bg-accent/5 px-4 py-4 text-sm"> | |
| 79 | 103 | <span className="size-2 animate-pulse rounded-full bg-accent" /> |
| 792 | 792 | pub created_at: String, | |
| 793 | 793 | /// RFC 3339. | |
| 794 | 794 | pub finished_at: Option<String>, | |
| 795 | + | /// Once applied: where each issue it opened stands now, in plan order. | |
| 796 | + | /// Filled in by `get_plan` only. | |
| 797 | + | #[serde(default)] | |
| 798 | + | pub progress: Vec<IssueProgress>, | |
| 799 | + | } | |
| 800 | + | ||
| 801 | + | /// Where one issue of an applied plan stands. | |
| 802 | + | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 803 | + | #[serde(rename_all = "camelCase")] | |
| 804 | + | pub struct IssueProgress { | |
| 805 | + | pub number: u32, | |
| 806 | + | pub title: String, | |
| 807 | + | /// `blocked` (waiting on issues it depends on), `waiting` (for an | |
| 808 | + | /// agent), `open` (nobody on it), one of the lifecycle stages | |
| 809 | + | /// (`working`, `checking`, `reviewing`, `revising`, `catching_up`, | |
| 810 | + | /// `queued`, `ready`, `needs_you`), `landed` or `closed`. | |
| 811 | + | pub state: String, | |
| 812 | + | /// One sentence about where it stands. | |
| 813 | + | pub detail: String, | |
| 814 | + | /// The issues it is waiting on that are still open. | |
| 815 | + | pub blocked_by: Vec<u32>, | |
| 816 | + | /// The pull request carrying it, the newest if several. | |
| 817 | + | pub pull: Option<u32>, | |
| 818 | + | /// Who or what is working on it, e.g. `g1t-agent`. | |
| 819 | + | pub agent: Option<String>, | |
| 795 | 820 | } | |
| 796 | 821 | ||
| 797 | 822 | /// `start_plan`: records an outcome to plan for. Members of the |
| 377 | 377 | createdAt: string; | |
| 378 | 378 | /** RFC 3339. */ | |
| 379 | 379 | finishedAt: string | null; | |
| 380 | + | /** Once applied: where each issue it opened stands now, in plan order. */ | |
| 381 | + | progress: IssueProgress[]; | |
| 380 | 382 | }; | |
| 381 | 383 | ||
| 382 | 384 | /** What a sandbox needs to write a plan. */ | |
| 678 | 680 | ||
| 679 | 681 | ||
| 680 | 682 | /** Where a pull request in a merge queue stands. */ | |
| 683 | + | /** Where one issue of an applied plan stands. */ | |
| 684 | + | export type IssueProgress = { | |
| 685 | + | number: number; | |
| 686 | + | title: string; | |
| 687 | + | /** | |
| 688 | + | * `blocked`, `waiting` (for an agent), `open`, a lifecycle stage, | |
| 689 | + | * `landed` or `closed`. | |
| 690 | + | */ | |
| 691 | + | state: string; | |
| 692 | + | detail: string; | |
| 693 | + | /** The issues it is waiting on that are still open. */ | |
| 694 | + | blockedBy: number[]; | |
| 695 | + | pull: number | null; | |
| 696 | + | agent: string | null; | |
| 697 | + | }; | |
| 698 | + | ||
| 681 | 699 | export type QueueState = "waiting" | "testing" | "passed" | "failed" | "landed" | "removed"; | |
| 682 | 700 | ||
| 683 | 701 | /** One pull request's place in a merge queue. */ |
| 8 | 8 | 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; | |
| 9 | 9 | --g1t-font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; | |
| 10 | 10 | ||
| 11 | − | /* Indigo night, three elevations: the sky after dusk. */ | |
| 12 | − | --g1t-bg: #0b0c18; | |
| 13 | − | --g1t-surface: #121327; | |
| 14 | − | --g1t-raised: #1a1b33; | |
| 15 | − | --g1t-line: #252743; | |
| 16 | − | --g1t-line-strong: #363960; | |
| 11 | + | /* Very dark gray, nearly neutral, three elevations. */ | |
| 12 | + | --g1t-bg: #0f0f11; | |
| 13 | + | --g1t-surface: #161618; | |
| 14 | + | --g1t-raised: #1e1e21; | |
| 15 | + | --g1t-line: #28282c; | |
| 16 | + | --g1t-line-strong: #38383e; | |
| 17 | 17 | ||
| 18 | − | --g1t-fg: #eeedf8; | |
| 19 | − | --g1t-fg-soft: #dddcf0; | |
| 20 | − | --g1t-muted: #a3a2c3; | |
| 21 | − | --g1t-faint: #6c6c90; | |
| 18 | + | --g1t-fg: #ededef; | |
| 19 | + | --g1t-fg-soft: #dcdce0; | |
| 20 | + | --g1t-muted: #a0a0a8; | |
| 21 | + | --g1t-faint: #6a6a72; | |
| 22 | 22 | ||
| 23 | 23 | /* Mint: what is live, what passed, what was added. */ | |
| 24 | 24 | --g1t-accent: #86efc4; | |
| 25 | 25 | --g1t-accent-dim: #4fb38e; | |
| 26 | 26 | --g1t-accent-low: #10302a; | |
| 27 | 27 | ||
| 28 | − | /* The dusk: lavender and peach, for art and for status. */ | |
| 28 | + | /* Accents: lavender and peach, for art and for status. */ | |
| 29 | 29 | --g1t-info: #8ab4ff; | |
| 30 | 30 | --g1t-merged: #b6a8ff; | |
| 31 | 31 | --g1t-warn: #ffbd8c; |
| 73 | 73 | author: user(row.author_id, row.author_name), | |
| 74 | 74 | created_at: row.created_at, | |
| 75 | 75 | finished_at: row.finished_at, | |
| 76 | + | progress: Vec::new(), | |
| 76 | 77 | } | |
| 77 | 78 | } | |
| 78 | 79 | } | |
| 79 | 80 | ||
| 81 | + | #[derive(Deserialize)] | |
| 82 | + | struct LatestPull { | |
| 83 | + | number: u32, | |
| 84 | + | agent: String, | |
| 85 | + | status: String, | |
| 86 | + | stage: Option<String>, | |
| 87 | + | stage_detail: Option<String>, | |
| 88 | + | } | |
| 89 | + | ||
| 80 | 90 | /// An issue waiting for a g1t agent, as selected. | |
| 81 | 91 | #[derive(Deserialize)] | |
| 82 | 92 | struct QueuedRow { | |
| 248 | 258 | .await? | |
| 249 | 259 | .filter(|row| row.repo_id == repo.id) | |
| 250 | 260 | { | |
| 251 | − | Some(row) => Outcome::Ok(row.into()), | |
| 261 | + | Some(row) => { | |
| 262 | + | let mut plan: Plan = row.into(); | |
| 263 | + | if plan.status == PlanStatus::Applied { | |
| 264 | + | plan.progress = self.progress(&repo.id, &plan).await?; | |
| 265 | + | } | |
| 266 | + | Outcome::Ok(plan) | |
| 267 | + | } | |
| 252 | 268 | None => Outcome::fail(FailureCode::NotFound, "Plan not found."), | |
| 253 | 269 | }, | |
| 254 | 270 | ) | |
| 255 | 271 | } | |
| 256 | 272 | ||
| 273 | + | /// Where each issue an applied plan opened stands now, all at once. | |
| 274 | + | async fn progress(&self, repo_id: &str, plan: &Plan) -> Result<Vec<IssueProgress>> { | |
| 275 | + | let numbers: Vec<u32> = plan.issues.iter().filter_map(|issue| issue.number).collect(); | |
| 276 | + | let issues = futures_util::future::try_join_all( | |
| 277 | + | numbers.iter().map(|number| self.issue(repo_id, *number)), | |
| 278 | + | ) | |
| 279 | + | .await?; | |
| 280 | + | let pulls = futures_util::future::try_join_all(numbers.iter().map(|number| async move { | |
| 281 | + | self.db | |
| 282 | + | .prepare( | |
| 283 | + | "SELECT number, agent, status, stage, stage_detail FROM pulls | |
| 284 | + | WHERE repo_id = ? AND issue_number = ? AND status != 'closed' | |
| 285 | + | ORDER BY number DESC LIMIT 1", | |
| 286 | + | ) | |
| 287 | + | .bind(&[repo_id.into(), (*number).into()])? | |
| 288 | + | .first::<LatestPull>(None) | |
| 289 | + | .await | |
| 290 | + | })) | |
| 291 | + | .await?; | |
| 292 | + | let open: std::collections::HashSet<u32> = issues | |
| 293 | + | .iter() | |
| 294 | + | .flatten() | |
| 295 | + | .filter(|issue| issue.state == State::Open) | |
| 296 | + | .map(|issue| issue.number) | |
| 297 | + | .collect(); | |
| 298 | + | Ok(issues | |
| 299 | + | .into_iter() | |
| 300 | + | .zip(pulls) | |
| 301 | + | .filter_map(|(issue, pull)| { | |
| 302 | + | let issue = issue?; | |
| 303 | + | let blocked_by: Vec<u32> = issue | |
| 304 | + | .blocked_by | |
| 305 | + | .iter() | |
| 306 | + | .copied() | |
| 307 | + | .filter(|number| open.contains(number)) | |
| 308 | + | .collect(); | |
| 309 | + | let (state, detail) = if issue.state == State::Closed { | |
| 310 | + | match (issue.reason, issue.resolved_by) { | |
| 311 | + | (Some(IssueReason::Completed), Some(by)) => { | |
| 312 | + | ("landed".to_owned(), format!("Landed with #{by}.")) | |
| 313 | + | } | |
| 314 | + | _ => ("closed".to_owned(), "Closed without landing.".to_owned()), | |
| 315 | + | } | |
| 316 | + | } else if let Some(pull) = pull.as_ref().filter(|pull| pull.status != "merged") { | |
| 317 | + | ( | |
| 318 | + | pull.stage.clone().unwrap_or_else(|| { | |
| 319 | + | if pull.status == "draft" { "working" } else { "ready" }.to_owned() | |
| 320 | + | }), | |
| 321 | + | pull.stage_detail.clone().unwrap_or_default(), | |
| 322 | + | ) | |
| 323 | + | } else if !blocked_by.is_empty() { | |
| 324 | + | let named: Vec<String> = blocked_by.iter().map(|n| format!("#{n}")).collect(); | |
| 325 | + | ("blocked".to_owned(), format!("Waiting for {} to land.", named.join(", "))) | |
| 326 | + | } else if issue.queued { | |
| 327 | + | ("waiting".to_owned(), "Waiting for an agent to be free.".to_owned()) | |
| 328 | + | } else { | |
| 329 | + | ("open".to_owned(), "Nobody is working on it.".to_owned()) | |
| 330 | + | }; | |
| 331 | + | Some(IssueProgress { | |
| 332 | + | number: issue.number, | |
| 333 | + | title: issue.title, | |
| 334 | + | state, | |
| 335 | + | detail, | |
| 336 | + | blocked_by, | |
| 337 | + | pull: pull.as_ref().map(|pull| pull.number), | |
| 338 | + | agent: pull.map(|pull| pull.agent).or(issue.agent), | |
| 339 | + | }) | |
| 340 | + | }) | |
| 341 | + | .collect()) | |
| 342 | + | } | |
| 343 | + | ||
| 257 | 344 | pub(crate) async fn list_plans(&self, a: ViewArgs) -> Result<Outcome<Vec<Plan>>> { | |
| 258 | 345 | let repo = match self.repo(&a.repo, &a.viewer).await? { | |
| 259 | 346 | Outcome::Ok(repo) => repo, |