g1t/apps/web/app/components/outcome.tsx
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Dark gray base with lavender as an accent, and a live outcome view for plans | 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"; | |
| What happened across an outcome, as a feed beside its graph | 13 | import { type ReactNode, useEffect, useRef, useState } from "react"; |
| Dark gray base with lavender as an accent, and a live outcome view for plans | 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 | }, | |
| Agents asked while not at work are woken to answer | 55 | answering: { |
| 56 | label: "Answering an agent", | |
| 57 | icon: <Sparkles size={13} />, | |
| 58 | ring: "ring-merged/60", | |
| 59 | text: "text-merged", | |
| 60 | bar: "bg-merged", | |
| 61 | live: true, | |
| 62 | }, | |
| Dark gray base with lavender as an accent, and a live outcome view for plans | 63 | catching_up: { |
| 64 | label: "Catching up", | |
| 65 | icon: <Loader2 size={13} className="animate-spin" />, | |
| 66 | ring: "ring-merged/60", | |
| 67 | text: "text-merged", | |
| 68 | bar: "bg-merged", | |
| 69 | live: true, | |
| 70 | }, | |
| 71 | checking: { | |
| 72 | label: "Checking", | |
| 73 | icon: <Terminal size={13} />, | |
| 74 | ring: "ring-info/60", | |
| 75 | text: "text-info", | |
| 76 | bar: "bg-info", | |
| 77 | live: true, | |
| 78 | }, | |
| 79 | reviewing: { | |
| 80 | label: "In review", | |
| 81 | icon: <Eye size={13} />, | |
| 82 | ring: "ring-info/60", | |
| 83 | text: "text-info", | |
| 84 | bar: "bg-info", | |
| 85 | live: true, | |
| 86 | }, | |
| 87 | queued: { | |
| 88 | label: "In the merge queue", | |
| 89 | icon: <Layers size={13} />, | |
| 90 | ring: "ring-accent/60", | |
| 91 | text: "text-accent", | |
| 92 | bar: "bg-accent/60", | |
| 93 | live: true, | |
| 94 | }, | |
| 95 | ready: { label: "Ready to merge", icon: <Check size={13} />, ring: "ring-accent/60", text: "text-accent", bar: "bg-accent/60" }, | |
| 96 | needs_you: { label: "Needs you", icon: <Hand size={13} />, ring: "ring-warn/70", text: "text-warn", bar: "bg-warn" }, | |
| 97 | landed: { label: "Landed", icon: <Check size={13} />, ring: "ring-accent/30", text: "text-accent", bar: "bg-accent" }, | |
| 98 | closed: { label: "Closed", icon: <X size={13} />, ring: "ring-line", text: "text-faint", bar: "bg-line" }, | |
| 99 | }; | |
| 100 | ||
| 101 | const look = (state: string) => LOOK[state] ?? LOOK.open!; | |
| 102 | ||
| 103 | /** Columns by how deep each issue sits in the plan's dependencies. */ | |
| 104 | function columns(plan: Plan): { position: number; item: IssueProgress }[][] { | |
| 105 | const byNumber = new Map(plan.progress.map((item) => [item.number, item])); | |
| 106 | const depth = new Map<number, number>(); | |
| 107 | const depthOf = (position: number, seen = new Set<number>()): number => { | |
| 108 | if (depth.has(position)) return depth.get(position)!; | |
| 109 | if (seen.has(position)) return 0; | |
| 110 | seen.add(position); | |
| 111 | const deps = plan.issues[position - 1]?.dependsOn ?? []; | |
| 112 | const value = deps.length === 0 ? 0 : 1 + Math.max(...deps.map((dep) => depthOf(dep, seen))); | |
| 113 | depth.set(position, value); | |
| 114 | return value; | |
| 115 | }; | |
| 116 | const result: { position: number; item: IssueProgress }[][] = []; | |
| 117 | plan.issues.forEach((issue, index) => { | |
| 118 | const item = issue.number != null ? byNumber.get(issue.number) : undefined; | |
| 119 | if (!item) return; | |
| 120 | const column = depthOf(index + 1); | |
| 121 | (result[column] ??= []).push({ position: index + 1, item }); | |
| 122 | }); | |
| 123 | return result.filter(Boolean); | |
| 124 | } | |
| 125 | ||
| 126 | function Node({ item, base }: { item: IssueProgress; base: string }) { | |
| 127 | const style = look(item.state); | |
| 128 | const to = item.pull != null ? `${base}/pull/${item.pull}` : `${base}/issues/${item.number}`; | |
| 129 | return ( | |
| 130 | <Link | |
| 131 | to={to} | |
| 132 | prefetch="intent" | |
| 133 | data-node={item.number} | |
| 134 | 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}`} | |
| 135 | > | |
| 136 | {style.live && ( | |
| 137 | <span aria-hidden="true" className={`absolute top-3 right-3 size-2 animate-pulse rounded-full ${style.bar}`} /> | |
| 138 | )} | |
| 139 | <p className={`flex items-center gap-1.5 text-xs font-medium ${style.text}`}> | |
| 140 | {style.icon} | |
| 141 | {style.label} | |
| 142 | </p> | |
| 143 | <p className={`mt-2 text-sm leading-snug font-medium ${item.state === "closed" ? "text-faint line-through" : ""}`}> | |
| 144 | {item.title} <span className="font-normal text-faint">#{item.number}</span> | |
| 145 | </p> | |
| 146 | {item.detail && <p className="mt-1.5 line-clamp-2 text-xs leading-5 text-muted">{item.detail}</p>} | |
| 147 | {item.agent && ( | |
| 148 | <p className="mt-2.5 flex items-center gap-1.5 font-mono text-[0.6875rem] text-faint"> | |
| 149 | <Avatar name={item.agent} size={14} /> | |
| 150 | {item.agent} | |
| 151 | {item.pull != null && <span>· #{item.pull}</span>} | |
| 152 | </p> | |
| 153 | )} | |
| 154 | </Link> | |
| 155 | ); | |
| 156 | } | |
| 157 | ||
| 158 | /** Lines from each issue to the ones that depend on it, measured from the page. */ | |
| 159 | function Edges({ plan, container }: { plan: Plan; container: React.RefObject<HTMLDivElement | null> }) { | |
| 160 | const [paths, setPaths] = useState<{ d: string; done: boolean }[]>([]); | |
| What happened across an outcome, as a feed beside its graph | 161 | // After commit, when the graph's own ref is attached: a child's layout |
| 162 | // effect runs before its parent's ref is set. | |
| 163 | useEffect(() => { | |
| Dark gray base with lavender as an accent, and a live outcome view for plans | 164 | const element = container.current; |
| 165 | if (!element) return; | |
| 166 | const draw = () => { | |
| 167 | const box = element.getBoundingClientRect(); | |
| 168 | const at = (number: number) => | |
| 169 | element.querySelector<HTMLElement>(`[data-node="${number}"]`)?.getBoundingClientRect(); | |
| 170 | const next: { d: string; done: boolean }[] = []; | |
| 171 | plan.issues.forEach((issue) => { | |
| 172 | if (issue.number == null) return; | |
| 173 | const to = at(issue.number); | |
| 174 | for (const dep of issue.dependsOn) { | |
| 175 | const fromNumber = plan.issues[dep - 1]?.number; | |
| 176 | if (fromNumber == null) continue; | |
| 177 | const from = at(fromNumber); | |
| 178 | if (!from || !to) continue; | |
| 179 | const x1 = from.right - box.left; | |
| 180 | const y1 = from.top + from.height / 2 - box.top; | |
| 181 | const x2 = to.left - box.left; | |
| 182 | const y2 = to.top + to.height / 2 - box.top; | |
| 183 | const mid = (x1 + x2) / 2; | |
| 184 | const done = plan.progress.find((item) => item.number === fromNumber)?.state === "landed"; | |
| 185 | next.push({ d: `M${x1},${y1} C${mid},${y1} ${mid},${y2} ${x2},${y2}`, done }); | |
| 186 | } | |
| 187 | }); | |
| 188 | setPaths(next); | |
| 189 | }; | |
| 190 | draw(); | |
| 191 | const observer = new ResizeObserver(draw); | |
| 192 | observer.observe(element); | |
| 193 | return () => observer.disconnect(); | |
| 194 | }, [plan, container]); | |
| 195 | return ( | |
| 196 | <svg aria-hidden="true" className="pointer-events-none absolute inset-0 size-full overflow-visible"> | |
| 197 | {paths.map((path, index) => ( | |
| 198 | <path | |
| 199 | key={index} | |
| 200 | d={path.d} | |
| 201 | fill="none" | |
| 202 | stroke={path.done ? "var(--color-accent)" : "var(--color-line-strong)"} | |
| 203 | strokeOpacity={path.done ? 0.7 : 1} | |
| 204 | strokeWidth={1.5} | |
| 205 | strokeDasharray={path.done ? undefined : "4 5"} | |
| 206 | className={path.done ? undefined : "art-flow"} | |
| 207 | /> | |
| 208 | ))} | |
| 209 | </svg> | |
| 210 | ); | |
| 211 | } | |
| 212 | ||
| 213 | /** | |
| 214 | * An outcome being converged: the plan's issues as a graph, each showing | |
| 215 | * where it stands now, and how far the whole has come. | |
| 216 | */ | |
| 217 | export function Outcome({ plan, base, costMicros }: { plan: Plan; base: string; costMicros: number | null }) { | |
| 218 | const graph = useRef<HTMLDivElement>(null); | |
| 219 | const total = plan.progress.length; | |
| 220 | const count = (states: string[]) => plan.progress.filter((item) => states.includes(item.state)).length; | |
| 221 | const landed = count(["landed"]); | |
| 222 | const live = plan.progress.filter((item) => look(item.state).live).length; | |
| 223 | const needsYou = count(["needs_you"]); | |
| 224 | const blocked = count(["blocked", "waiting", "open"]); | |
| Agents asked while not at work are woken to answer | 225 | const order = ["landed", "queued", "ready", "reviewing", "checking", "working", "revising", "catching_up", "answering", "needs_you", "waiting", "blocked", "open", "closed"]; |
| Dark gray base with lavender as an accent, and a live outcome view for plans | 226 | const sorted = [...plan.progress].sort((a, b) => order.indexOf(a.state) - order.indexOf(b.state)); |
| 227 | ||
| 228 | return ( | |
| 229 | <div> | |
| 230 | <div className="grid gap-4 sm:grid-cols-4"> | |
| 231 | {[ | |
| 232 | ["Landed", `${landed} of ${total}`, "text-accent"], | |
| 233 | ["Agents at work", String(live), "text-merged"], | |
| 234 | ["Needs you", String(needsYou), needsYou > 0 ? "text-warn" : "text-muted"], | |
| 235 | [ | |
| 236 | "Agents have cost", | |
| 237 | costMicros == null ? "—" : `$${(costMicros / 1_000_000).toFixed(2)}`, | |
| 238 | "text-fg", | |
| 239 | ], | |
| 240 | ].map(([label, value, tone]) => ( | |
| 241 | <div key={label} className="rounded-2xl bg-surface px-4 py-3 ring-1 ring-line"> | |
| 242 | <p className="text-xs text-muted">{label}</p> | |
| 243 | <p className={`mt-1 text-2xl font-semibold tabular-nums ${tone}`}>{value}</p> | |
| 244 | </div> | |
| 245 | ))} | |
| 246 | </div> | |
| 247 | {/* Every issue, as one bar. */} | |
| 248 | <div className="mt-4 flex h-2 gap-0.5 overflow-hidden rounded-full" role="img" aria-label={`${landed} of ${total} landed`}> | |
| 249 | {sorted.map((item) => ( | |
| 250 | <span key={item.number} className={`grow transition-colors duration-700 ${look(item.state).bar}`} /> | |
| 251 | ))} | |
| 252 | </div> | |
| 253 | <p className="mt-2 text-xs text-muted"> | |
| 254 | {landed === total | |
| 255 | ? "Everything in the plan has landed." | |
| 256 | : `${live} in flight, ${blocked} waiting their turn${needsYou ? `, ${needsYou} waiting on you` : ""}.`} | |
| 257 | </p> | |
| 258 | ||
| 259 | {columns(plan).length === 1 ? ( | |
| 260 | // Nothing depends on anything: every issue starts at once, side by side. | |
| 261 | <div className="mt-10"> | |
| 262 | <p className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase"> | |
| 263 | All start at once | |
| 264 | </p> | |
| 265 | <div className="mt-4 grid gap-4 sm:grid-cols-2 xl:grid-cols-4"> | |
| 266 | {columns(plan)[0]!.map(({ item }) => ( | |
| 267 | <Node key={item.number} item={item} base={base} /> | |
| 268 | ))} | |
| 269 | </div> | |
| 270 | </div> | |
| 271 | ) : ( | |
| 272 | <div className="mt-10 overflow-x-auto pb-4"> | |
| 273 | <div ref={graph} className="relative flex min-w-max gap-14"> | |
| 274 | <Edges plan={plan} container={graph} /> | |
| 275 | {columns(plan).map((column, index) => ( | |
| 276 | <div key={index} className="flex w-64 flex-col justify-center gap-4"> | |
| 277 | <p className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase"> | |
| 278 | {index === 0 ? "Starts at once" : `Then, step ${index + 1}`} | |
| 279 | </p> | |
| 280 | {column.map(({ item }) => ( | |
| 281 | <Node key={item.number} item={item} base={base} /> | |
| 282 | ))} | |
| 283 | </div> | |
| 284 | ))} | |
| 285 | </div> | |
| 286 | </div> | |
| 287 | )} | |
| 288 | </div> | |
| 289 | ); | |
| 290 | } |