g1t/apps/web/app/components/outcome.tsx
| 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, useEffect, 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 | // After commit, when the graph's own ref is attached: a child's layout |
| 154 | // effect runs before its parent's ref is set. |
| 155 | useEffect(() => { |
| 156 | const element = container.current; |
| 157 | if (!element) return; |
| 158 | const draw = () => { |
| 159 | const box = element.getBoundingClientRect(); |
| 160 | const at = (number: number) => |
| 161 | element.querySelector<HTMLElement>(`[data-node="${number}"]`)?.getBoundingClientRect(); |
| 162 | const next: { d: string; done: boolean }[] = []; |
| 163 | plan.issues.forEach((issue) => { |
| 164 | if (issue.number == null) return; |
| 165 | const to = at(issue.number); |
| 166 | for (const dep of issue.dependsOn) { |
| 167 | const fromNumber = plan.issues[dep - 1]?.number; |
| 168 | if (fromNumber == null) continue; |
| 169 | const from = at(fromNumber); |
| 170 | if (!from || !to) continue; |
| 171 | const x1 = from.right - box.left; |
| 172 | const y1 = from.top + from.height / 2 - box.top; |
| 173 | const x2 = to.left - box.left; |
| 174 | const y2 = to.top + to.height / 2 - box.top; |
| 175 | const mid = (x1 + x2) / 2; |
| 176 | const done = plan.progress.find((item) => item.number === fromNumber)?.state === "landed"; |
| 177 | next.push({ d: `M${x1},${y1} C${mid},${y1} ${mid},${y2} ${x2},${y2}`, done }); |
| 178 | } |
| 179 | }); |
| 180 | setPaths(next); |
| 181 | }; |
| 182 | draw(); |
| 183 | const observer = new ResizeObserver(draw); |
| 184 | observer.observe(element); |
| 185 | return () => observer.disconnect(); |
| 186 | }, [plan, container]); |
| 187 | return ( |
| 188 | <svg aria-hidden="true" className="pointer-events-none absolute inset-0 size-full overflow-visible"> |
| 189 | {paths.map((path, index) => ( |
| 190 | <path |
| 191 | key={index} |
| 192 | d={path.d} |
| 193 | fill="none" |
| 194 | stroke={path.done ? "var(--color-accent)" : "var(--color-line-strong)"} |
| 195 | strokeOpacity={path.done ? 0.7 : 1} |
| 196 | strokeWidth={1.5} |
| 197 | strokeDasharray={path.done ? undefined : "4 5"} |
| 198 | className={path.done ? undefined : "art-flow"} |
| 199 | /> |
| 200 | ))} |
| 201 | </svg> |
| 202 | ); |
| 203 | } |
| 204 | |
| 205 | /** |
| 206 | * An outcome being converged: the plan's issues as a graph, each showing |
| 207 | * where it stands now, and how far the whole has come. |
| 208 | */ |
| 209 | export function Outcome({ plan, base, costMicros }: { plan: Plan; base: string; costMicros: number | null }) { |
| 210 | const graph = useRef<HTMLDivElement>(null); |
| 211 | const total = plan.progress.length; |
| 212 | const count = (states: string[]) => plan.progress.filter((item) => states.includes(item.state)).length; |
| 213 | const landed = count(["landed"]); |
| 214 | const live = plan.progress.filter((item) => look(item.state).live).length; |
| 215 | const needsYou = count(["needs_you"]); |
| 216 | const blocked = count(["blocked", "waiting", "open"]); |
| 217 | const order = ["landed", "queued", "ready", "reviewing", "checking", "working", "revising", "catching_up", "needs_you", "waiting", "blocked", "open", "closed"]; |
| 218 | const sorted = [...plan.progress].sort((a, b) => order.indexOf(a.state) - order.indexOf(b.state)); |
| 219 | |
| 220 | return ( |
| 221 | <div> |
| 222 | <div className="grid gap-4 sm:grid-cols-4"> |
| 223 | {[ |
| 224 | ["Landed", `${landed} of ${total}`, "text-accent"], |
| 225 | ["Agents at work", String(live), "text-merged"], |
| 226 | ["Needs you", String(needsYou), needsYou > 0 ? "text-warn" : "text-muted"], |
| 227 | [ |
| 228 | "Agents have cost", |
| 229 | costMicros == null ? "—" : `$${(costMicros / 1_000_000).toFixed(2)}`, |
| 230 | "text-fg", |
| 231 | ], |
| 232 | ].map(([label, value, tone]) => ( |
| 233 | <div key={label} className="rounded-2xl bg-surface px-4 py-3 ring-1 ring-line"> |
| 234 | <p className="text-xs text-muted">{label}</p> |
| 235 | <p className={`mt-1 text-2xl font-semibold tabular-nums ${tone}`}>{value}</p> |
| 236 | </div> |
| 237 | ))} |
| 238 | </div> |
| 239 | {/* Every issue, as one bar. */} |
| 240 | <div className="mt-4 flex h-2 gap-0.5 overflow-hidden rounded-full" role="img" aria-label={`${landed} of ${total} landed`}> |
| 241 | {sorted.map((item) => ( |
| 242 | <span key={item.number} className={`grow transition-colors duration-700 ${look(item.state).bar}`} /> |
| 243 | ))} |
| 244 | </div> |
| 245 | <p className="mt-2 text-xs text-muted"> |
| 246 | {landed === total |
| 247 | ? "Everything in the plan has landed." |
| 248 | : `${live} in flight, ${blocked} waiting their turn${needsYou ? `, ${needsYou} waiting on you` : ""}.`} |
| 249 | </p> |
| 250 | |
| 251 | {columns(plan).length === 1 ? ( |
| 252 | // Nothing depends on anything: every issue starts at once, side by side. |
| 253 | <div className="mt-10"> |
| 254 | <p className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase"> |
| 255 | All start at once |
| 256 | </p> |
| 257 | <div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-4"> |
| 258 | {columns(plan)[0]!.map(({ item }) => ( |
| 259 | <Node key={item.number} item={item} base={base} /> |
| 260 | ))} |
| 261 | </div> |
| 262 | </div> |
| 263 | ) : ( |
| 264 | <div className="mt-10 overflow-x-auto pb-4"> |
| 265 | <div ref={graph} className="relative flex min-w-max gap-14"> |
| 266 | <Edges plan={plan} container={graph} /> |
| 267 | {columns(plan).map((column, index) => ( |
| 268 | <div key={index} className="flex w-64 flex-col justify-center gap-4"> |
| 269 | <p className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase"> |
| 270 | {index === 0 ? "Starts at once" : `Then, step ${index + 1}`} |
| 271 | </p> |
| 272 | {column.map(({ item }) => ( |
| 273 | <Node key={item.number} item={item} base={base} /> |
| 274 | ))} |
| 275 | </div> |
| 276 | ))} |
| 277 | </div> |
| 278 | </div> |
| 279 | )} |
| 280 | </div> |
| 281 | ); |
| 282 | } |