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.
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 1 | /** |
| 2 | * The context hub, as the workspace's Context page and a project's Memory | |
| 3 | * page show it: the catalog and its relations, one search over everything, | |
| 4 | * scorecards whose failing rules become issues for an agent, and the | |
| 5 | * Review queue of memory candidates (keep, edit, dismiss). Pages post to | |
| 6 | * their own action with the intents in `reviewAction` and `contextAction`. | |
| 7 | */ | |
| 8 | import { | |
| 9 | Bot, | |
| 10 | Boxes, | |
| 11 | Check, | |
| 12 | CircleDashed, | |
| 13 | FileText, | |
| 14 | Globe, | |
| 15 | Languages, | |
| 16 | Package, | |
| 17 | Pencil, | |
| 18 | Plug, | |
| 19 | Search, | |
| 20 | Server, | |
| 21 | Sparkles, | |
| 22 | User, | |
| 23 | Webhook, | |
| 24 | X, | |
| 25 | XCircle, | |
| 26 | } from "lucide-react"; | |
| 27 | import { type ReactNode, useEffect, useState } from "react"; | |
| 28 | import { Form, Link, useFetcher, useNavigation } from "react-router"; | |
| 29 | ||
| 30 | import { | |
| 31 | ENTITY_KINDS, | |
| 32 | MEMORY_KINDS, | |
| 33 | type Catalog, | |
| 34 | type Entity, | |
| 35 | type EntityKind, | |
| 36 | type Memory, | |
| 37 | type MemoryKind, | |
| 38 | type MemoryReviewApi, | |
| 39 | type Result, | |
| 40 | type RuleResult, | |
| 41 | type Scorecard, | |
| 42 | type SearchHit, | |
| 43 | type SearchResult, | |
| 44 | type User as Actor, | |
| 45 | } from "@g1t/contracts"; | |
| 46 | ||
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 47 | import { InlineMarkdown } from "./inline-markdown"; |
| Merge branch 'worktree-agent-a985c01f43538af01' | 48 | import { SubmitButton, TimeAgo } from "./ui"; |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 49 | import { Button } from "./ui/button"; |
| 50 | import { Card } from "./ui/card"; | |
| Merge branch 'worktree-agent-ae1299e92e4462012' | 51 | import { Hint } from "./ui/hint"; |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 52 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; |
| 53 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; | |
| 54 | ||
| 55 | type Done = { ok: boolean; error?: string; message?: string } | undefined; | |
| 56 | ||
| 57 | export const KIND_LABEL: Record<EntityKind, string> = { | |
| 58 | project: "Projects", | |
| 59 | app: "Apps", | |
| 60 | api: "APIs", | |
| 61 | package: "Packages", | |
| 62 | language: "Languages", | |
| 63 | owner: "Owners", | |
| 64 | environment: "Environments", | |
| 65 | integration: "Integrations", | |
| 66 | doc: "Docs", | |
| 67 | }; | |
| 68 | ||
| 69 | const KIND_ICON: Record<string, ReactNode> = { | |
| 70 | project: <Boxes size={14} />, | |
| 71 | app: <Server size={14} />, | |
| 72 | api: <Webhook size={14} />, | |
| 73 | package: <Package size={14} />, | |
| 74 | language: <Languages size={14} />, | |
| 75 | owner: <User size={14} />, | |
| 76 | environment: <Globe size={14} />, | |
| 77 | integration: <Plug size={14} />, | |
| 78 | doc: <FileText size={14} />, | |
| 79 | memory: <Sparkles size={14} />, | |
| 80 | issue: <CircleDashed size={14} />, | |
| 81 | pull: <Bot size={14} />, | |
| 82 | }; | |
| 83 | ||
| 84 | const TEXTAREA = | |
| 85 | "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"; | |
| 86 | ||
| 87 | // --- Review queue ------------------------------------------------------------- | |
| 88 | ||
| 89 | /** Where a candidate came from, in words, linked where it can be. */ | |
| 90 | function CandidateSource({ memory }: { memory: Memory }) { | |
| 91 | const { source } = memory; | |
| 92 | const repo = source.repo; | |
| 93 | const on = repo && source.number != null ? ( | |
| 94 | <Link to={`/${repo.namespace}/${repo.name}/pull/${source.number}`} className="hover:text-fg"> | |
| 95 | {repo.name}#{source.number} | |
| 96 | </Link> | |
| 97 | ) : null; | |
| 98 | const path = source.reference?.startsWith("doc:") ? source.reference.split(":").slice(2).join(":") : null; | |
| 99 | const label = | |
| 100 | source.kind === "doc" ? ( | |
| 101 | <>from {repo ? <Link to={`/${repo.namespace}/${repo.name}/blob/HEAD/${path}`} className="hover:text-fg">{path}</Link> : path}</> | |
| 102 | ) : source.kind === "review" ? ( | |
| 103 | <>from a review on {on}</> | |
| 104 | ) : source.kind === "pr" ? ( | |
| 105 | <>from merging {on}</> | |
| 106 | ) : source.kind === "run" ? ( | |
| 107 | <> | |
| 108 | learned by{" "} | |
| 109 | {source.runId && repo ? ( | |
| 110 | <Link to={`/${repo.namespace}/${repo.name}/agents/runs/${source.runId}`} className="hover:text-fg"> | |
| 111 | an agent run | |
| 112 | </Link> | |
| 113 | ) : ( | |
| 114 | "an agent run" | |
| 115 | )} | |
| 116 | {on && <> on {on}</>} | |
| 117 | </> | |
| 118 | ) : ( | |
| 119 | <>added by {memory.createdBy}</> | |
| 120 | ); | |
| 121 | return <span>{label}</span>; | |
| 122 | } | |
| 123 | ||
| 124 | function KindSelect({ value }: { value: MemoryKind }) { | |
| 125 | return ( | |
| 126 | <Select name="kind" defaultValue={value}> | |
| 127 | <SelectTrigger size="sm" className="w-36"> | |
| 128 | <SelectValue /> | |
| 129 | </SelectTrigger> | |
| 130 | <SelectContent> | |
| 131 | {MEMORY_KINDS.map((kind) => ( | |
| 132 | <SelectItem key={kind} value={kind}> | |
| 133 | {kind[0].toUpperCase() + kind.slice(1)} | |
| 134 | </SelectItem> | |
| 135 | ))} | |
| 136 | </SelectContent> | |
| 137 | </Select> | |
| 138 | ); | |
| 139 | } | |
| 140 | ||
| 141 | function EditAndKeep({ memory, action }: { memory: Memory; action: string }) { | |
| 142 | const fetcher = useFetcher<Done>(); | |
| 143 | const [open, setOpen] = useState(false); | |
| 144 | useEffect(() => { | |
| 145 | if (fetcher.state === "idle" && fetcher.data?.ok) setOpen(false); | |
| 146 | }, [fetcher.state, fetcher.data]); | |
| 147 | return ( | |
| 148 | <Dialog open={open} onOpenChange={setOpen}> | |
| 149 | <DialogTrigger className="inline-flex items-center gap-1 rounded-md border border-line px-2 py-1 text-xs text-muted hover:border-line-strong hover:text-fg"> | |
| 150 | <Pencil size={12} /> | |
| 151 | Edit | |
| 152 | </DialogTrigger> | |
| 153 | <DialogContent> | |
| 154 | <DialogHeader> | |
| 155 | <DialogTitle>Edit and keep</DialogTitle> | |
| 156 | <DialogDescription>Agents are given the kept wording from their next run on.</DialogDescription> | |
| 157 | </DialogHeader> | |
| 158 | <fetcher.Form method="post" action={action} className="space-y-3"> | |
| 159 | <input type="hidden" name="intent" value="keep" /> | |
| 160 | <input type="hidden" name="id" value={memory.id} /> | |
| 161 | <textarea name="text" required rows={4} maxLength={1000} defaultValue={memory.text} className={TEXTAREA} /> | |
| 162 | <div className="flex items-center justify-between gap-3"> | |
| 163 | <KindSelect value={memory.kind} /> | |
| Merge branch 'worktree-agent-a985c01f43538af01' | 164 | <SubmitButton fetcher={fetcher} pending="Keeping…"> |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 165 | Keep |
| Merge branch 'worktree-agent-a985c01f43538af01' | 166 | </SubmitButton> |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 167 | </div> |
| 168 | {fetcher.data?.error && <p className="text-sm text-danger">{fetcher.data.error}</p>} | |
| 169 | </fetcher.Form> | |
| 170 | </DialogContent> | |
| 171 | </Dialog> | |
| 172 | ); | |
| 173 | } | |
| 174 | ||
| 175 | function Candidate({ memory, action }: { memory: Memory; action: string }) { | |
| 176 | const fetcher = useFetcher<Done>(); | |
| 177 | const decided = fetcher.formData?.get("intent"); | |
| 178 | if (decided === "keep" || decided === "dismiss") return null; | |
| 179 | return ( | |
| 180 | <li className="flex gap-3 px-4 py-3"> | |
| 181 | <div className="min-w-0 grow"> | |
| 182 | <div className="flex flex-wrap items-center gap-2 text-[0.6875rem] text-muted"> | |
| 183 | <span className="rounded-full border border-line px-2 py-px">{memory.kind}</span> | |
| 184 | <span>{memory.scope === "workspace" ? "every project" : memory.repo ? memory.repo.name : "this project"}</span> | |
| 185 | {(memory.seen ?? 1) > 1 && <span>seen {memory.seen} times</span>} | |
| 186 | {memory.confidence != null && <span>{Math.round(memory.confidence * 100)}% sure</span>} | |
| 187 | </div> | |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 188 | <p className="mt-1.5 text-sm whitespace-pre-wrap wrap-break-word">{memory.text}</p> |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 189 | {memory.source.evidence && ( |
| Phones reach every page: each mode's menu from the top bar or by tapping the tab you're on, Docs first in the avatar sheet, a project's pages as a swipeable strip; dialogs as bottom sheets above the keyboard, safe areas, menus kept on screen, 40px targets, touch-visible controls, save bars above the tab bar | 190 | <p className="mt-1 border-l-2 border-line pl-2 text-xs text-muted whitespace-pre-wrap wrap-break-word">{memory.source.evidence}</p> |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 191 | )} |
| 192 | <p className="mt-1.5 flex flex-wrap gap-x-3 text-xs text-faint"> | |
| 193 | <CandidateSource memory={memory} /> | |
| 194 | <span> | |
| 195 | <TimeAgo at={memory.updatedAt} /> | |
| 196 | </span> | |
| 197 | </p> | |
| 198 | {fetcher.data?.error && <p className="mt-1.5 text-xs text-danger">{fetcher.data.error}</p>} | |
| 199 | </div> | |
| 200 | <div className="flex shrink-0 items-start gap-1.5"> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 201 | <Button |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 202 | type="button" |
| 203 | onClick={() => fetcher.submit({ intent: "keep", id: memory.id }, { method: "post", action })} | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 204 | size="inline" |
| 205 | className="gap-1 px-2 py-1 text-xs" | |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 206 | > |
| 207 | <Check size={12} /> | |
| 208 | Keep | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 209 | </Button> |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 210 | <EditAndKeep memory={memory} action={action} /> |
| Merge branch 'worktree-agent-ae1299e92e4462012' | 211 | <Hint label="Dismiss: never suggested again in these words"> |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 212 | <Button |
| Merge branch 'worktree-agent-ae1299e92e4462012' | 213 | type="button" |
| 214 | aria-label="Dismiss" | |
| 215 | onClick={() => fetcher.submit({ intent: "dismiss", id: memory.id }, { method: "post", action })} | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 216 | variant="outline" |
| 217 | size="inline" | |
| 218 | className="gap-1 px-2 py-1 text-xs text-muted hover:text-danger font-normal" | |
| Merge branch 'worktree-agent-ae1299e92e4462012' | 219 | > |
| 220 | <X size={12} /> | |
| 221 | Dismiss | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 222 | </Button> |
| Merge branch 'worktree-agent-ae1299e92e4462012' | 223 | </Hint> |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 224 | </div> |
| 225 | </li> | |
| 226 | ); | |
| 227 | } | |
| 228 | ||
| 229 | /** Memory candidates waiting for a person: keep, edit and keep, or dismiss. */ | |
| 230 | export function ReviewQueue({ candidates, action, empty }: { candidates: Memory[]; action: string; empty: string }) { | |
| 231 | if (candidates.length === 0) { | |
| 232 | return <p className="rounded-xl border border-dashed border-line px-4 py-6 text-sm text-muted">{empty}</p>; | |
| 233 | } | |
| 234 | return ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 235 | <Card asChild divided> |
| 236 | <ul> | |
| 237 | {candidates.map((memory) => ( | |
| 238 | <Candidate key={memory.id} memory={memory} action={action} /> | |
| 239 | ))} | |
| 240 | </ul> | |
| 241 | </Card> | |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 242 | ); |
| 243 | } | |
| 244 | ||
| 245 | /** The intents of the Review queue: keep (with an edit, or as it is) and dismiss. Null for other intents. */ | |
| 246 | export async function reviewAction(api: MemoryReviewApi, actor: Actor, workspace: string, form: FormData): Promise<Done | null> { | |
| 247 | const intent = String(form.get("intent") ?? ""); | |
| 248 | if (intent !== "keep" && intent !== "dismiss") return null; | |
| 249 | const text = form.get("text"); | |
| 250 | const kind = String(form.get("kind") ?? ""); | |
| 251 | const reviewed = await api.reviewMemory(actor, workspace, String(form.get("id") ?? ""), intent, { | |
| 252 | text: text == null ? undefined : String(text), | |
| 253 | kind: MEMORY_KINDS.includes(kind as MemoryKind) ? (kind as MemoryKind) : undefined, | |
| 254 | }); | |
| 255 | return reviewed.ok ? { ok: true } : { ok: false, error: reviewed.error.message }; | |
| 256 | } | |
| 257 | ||
| 258 | // --- Catalog ------------------------------------------------------------------ | |
| 259 | ||
| 260 | /** | |
| 261 | * The projects and how they depend on each other, drawn in a circle; a | |
| 262 | * project's owners and apps hang off it. Small workspaces only: past 24 | |
| 263 | * projects the list says it all. | |
| 264 | */ | |
| 265 | export function RelationsGraph({ catalog }: { catalog: Catalog }) { | |
| 266 | const projects = catalog.entities.filter((entity) => entity.kind === "project").slice(0, 24); | |
| 267 | if (projects.length < 2) return null; | |
| 268 | const size = 420; | |
| 269 | const center = size / 2; | |
| 270 | const radius = size / 2 - 60; | |
| 271 | const at = new Map( | |
| 272 | projects.map((project, i) => { | |
| 273 | const angle = (2 * Math.PI * i) / projects.length - Math.PI / 2; | |
| 274 | return [project.id, { x: center + radius * Math.cos(angle), y: center + radius * Math.sin(angle), project }]; | |
| 275 | }), | |
| 276 | ); | |
| 277 | const edges = catalog.relations.filter((relation) => relation.kind === "depends_on" && at.has(relation.from) && at.has(relation.to)); | |
| 278 | return ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 279 | <Card asChild className="p-3"> |
| 280 | <figure> | |
| 281 | <svg viewBox={`0 0 ${size} ${size}`} className="mx-auto block h-auto w-full max-w-md" role="img" aria-label="How the workspace's projects depend on each other"> | |
| 282 | <defs> | |
| 283 | <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"> | |
| 284 | <path d="M 0 0 L 10 5 L 0 10 z" className="fill-muted" /> | |
| 285 | </marker> | |
| 286 | </defs> | |
| 287 | {edges.map((edge) => { | |
| 288 | const from = at.get(edge.from)!; | |
| 289 | const to = at.get(edge.to)!; | |
| 290 | const dx = to.x - from.x; | |
| 291 | const dy = to.y - from.y; | |
| 292 | const length = Math.hypot(dx, dy) || 1; | |
| 293 | const pad = 26; | |
| 294 | return ( | |
| 295 | <line | |
| 296 | key={`${edge.from}-${edge.to}`} | |
| 297 | x1={from.x + (dx / length) * pad} | |
| 298 | y1={from.y + (dy / length) * pad} | |
| 299 | x2={to.x - (dx / length) * pad} | |
| 300 | y2={to.y - (dy / length) * pad} | |
| 301 | className="stroke-muted" | |
| 302 | strokeWidth={1.25} | |
| 303 | markerEnd="url(#arrow)" | |
| 304 | /> | |
| 305 | ); | |
| 306 | })} | |
| 307 | {[...at.values()].map(({ x, y, project }) => ( | |
| 308 | <g key={project.id}> | |
| 309 | <circle cx={x} cy={y} r={20} className="fill-bg stroke-accent" strokeWidth={1.5} /> | |
| 310 | <text x={x} y={y + 36} textAnchor="middle" className="fill-fg text-[11px]"> | |
| 311 | {project.name.length > 16 ? `${project.name.slice(0, 15)}…` : project.name} | |
| 312 | </text> | |
| 313 | <text x={x} y={y + 4} textAnchor="middle" className="fill-accent text-[11px] font-semibold"> | |
| 314 | {project.name[0]?.toUpperCase()} | |
| 315 | </text> | |
| 316 | </g> | |
| 317 | ))} | |
| 318 | </svg> | |
| 319 | <figcaption className="mt-1 text-center text-xs text-faint">An arrow points from a project to one it uses.</figcaption> | |
| 320 | </figure> | |
| 321 | </Card> | |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 322 | ); |
| 323 | } | |
| 324 | ||
| 325 | function EntityRow({ entity, names }: { entity: Entity; names: Map<string, Entity> }) { | |
| 326 | const link = entity.ref?.startsWith("/") ? ( | |
| 327 | <Link to={entity.ref} className="font-medium hover:text-accent"> | |
| 328 | {entity.name} | |
| 329 | </Link> | |
| 330 | ) : entity.ref ? ( | |
| 331 | <a href={entity.ref} className="font-medium hover:text-accent" rel="noreferrer"> | |
| 332 | {entity.name} | |
| 333 | </a> | |
| 334 | ) : ( | |
| 335 | <span className="font-medium">{entity.name}</span> | |
| 336 | ); | |
| 337 | void names; | |
| 338 | return ( | |
| 339 | <li className="flex gap-3 px-4 py-2.5"> | |
| 340 | <span className="mt-0.5 text-muted">{KIND_ICON[entity.kind]}</span> | |
| 341 | <div className="min-w-0 grow"> | |
| 342 | <div className="flex flex-wrap items-baseline gap-x-2 text-sm"> | |
| 343 | {link} | |
| 344 | {entity.project && entity.kind !== "project" && <span className="text-xs text-faint">{entity.project}</span>} | |
| 345 | {entity.private && <span className="text-[0.6875rem] text-faint">private</span>} | |
| 346 | </div> | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 347 | {entity.summary && <p className="mt-0.5 text-xs text-muted"><InlineMarkdown text={entity.summary} /></p>} |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 348 | </div> |
| 349 | </li> | |
| 350 | ); | |
| 351 | } | |
| 352 | ||
| 353 | /** The catalog: filters by kind and project, the graph, and the entries. */ | |
| 354 | export function CatalogView({ catalog, kind, project, base }: { catalog: Catalog; kind: EntityKind | null; project: string | null; base: string }) { | |
| 355 | const projects = catalog.entities.filter((entity) => entity.kind === "project"); | |
| 356 | const names = new Map(catalog.entities.map((entity) => [entity.id, entity])); | |
| 357 | const shown = catalog.entities.filter((entity) => (!kind || entity.kind === kind) && (!project || entity.project === project || entity.project == null)); | |
| 358 | const counts = new Map<string, number>(); | |
| 359 | for (const entity of catalog.entities) counts.set(entity.kind, (counts.get(entity.kind) ?? 0) + 1); | |
| 360 | const href = (k: string | null, p: string | null) => { | |
| 361 | const params = new URLSearchParams({ tab: "catalog" }); | |
| 362 | if (k) params.set("kind", k); | |
| 363 | if (p) params.set("project", p); | |
| 364 | return `${base}?${params}`; | |
| 365 | }; | |
| 366 | return ( | |
| 367 | <div className="space-y-5"> | |
| 368 | <div className="flex flex-wrap gap-1.5"> | |
| 369 | <Link to={href(null, project)} className={`rounded-full border px-2.5 py-1 text-xs ${!kind ? "border-accent text-fg" : "border-line text-muted hover:text-fg"}`}> | |
| 370 | Everything {catalog.entities.length} | |
| 371 | </Link> | |
| 372 | {ENTITY_KINDS.filter((k) => counts.get(k)).map((k) => ( | |
| 373 | <Link key={k} to={href(k, project)} className={`inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs ${kind === k ? "border-accent text-fg" : "border-line text-muted hover:text-fg"}`}> | |
| 374 | {KIND_ICON[k]} | |
| 375 | {KIND_LABEL[k]} {counts.get(k)} | |
| 376 | </Link> | |
| 377 | ))} | |
| 378 | </div> | |
| 379 | {projects.length > 1 && ( | |
| 380 | <div className="flex flex-wrap items-center gap-1.5 text-xs"> | |
| 381 | <span className="text-faint">Project:</span> | |
| 382 | <Link to={href(kind, null)} className={!project ? "text-fg" : "text-muted hover:text-fg"}> | |
| 383 | all | |
| 384 | </Link> | |
| 385 | {projects.map((p) => ( | |
| 386 | <Link key={p.id} to={href(kind, p.key)} className={project === p.key ? "text-fg" : "text-muted hover:text-fg"}> | |
| 387 | {p.name} | |
| 388 | </Link> | |
| 389 | ))} | |
| 390 | </div> | |
| 391 | )} | |
| 392 | {!kind && !project && <RelationsGraph catalog={catalog} />} | |
| 393 | {shown.length === 0 ? ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 394 | <Card asChild tone="plain" className="border-dashed px-4 py-6 text-sm text-muted"> |
| 395 | <p> | |
| 396 | Nothing here yet. The catalog builds itself from each project's default branch: its manifests, docs and | |
| 397 | workflows, with its deployments and integrations. | |
| 398 | </p> | |
| 399 | </Card> | |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 400 | ) : ( |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 401 | <Card asChild divided> |
| 402 | <ul> | |
| 403 | {shown.slice(0, 500).map((entity) => ( | |
| 404 | <EntityRow key={entity.id} entity={entity} names={names} /> | |
| 405 | ))} | |
| 406 | </ul> | |
| 407 | </Card> | |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 408 | )} |
| 409 | {catalog.builtAt && ( | |
| 410 | <p className="text-xs text-faint"> | |
| 411 | Last built <TimeAgo at={catalog.builtAt} />. | |
| 412 | </p> | |
| 413 | )} | |
| 414 | </div> | |
| 415 | ); | |
| 416 | } | |
| 417 | ||
| 418 | // --- Search ------------------------------------------------------------------- | |
| 419 | ||
| 420 | function Hit({ hit }: { hit: SearchHit }) { | |
| 421 | const title = hit.url?.startsWith("/") ? ( | |
| 422 | <Link to={hit.url} className="font-medium hover:text-accent"> | |
| 423 | {hit.title} | |
| 424 | </Link> | |
| 425 | ) : hit.url ? ( | |
| 426 | <a href={hit.url} className="font-medium hover:text-accent" rel="noreferrer"> | |
| 427 | {hit.title} | |
| 428 | </a> | |
| 429 | ) : ( | |
| 430 | <span className="font-medium">{hit.title}</span> | |
| 431 | ); | |
| 432 | return ( | |
| 433 | <li className="flex gap-3 px-4 py-3"> | |
| 434 | <span className="mt-0.5 text-muted">{KIND_ICON[hit.kind]}</span> | |
| 435 | <div className="min-w-0 grow"> | |
| 436 | <div className="text-sm">{title}</div> | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 437 | {hit.snippet && <p className="mt-0.5 text-xs text-muted"><InlineMarkdown text={hit.snippet} /></p>} |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 438 | <p className="mt-1 flex flex-wrap gap-x-3 text-[0.6875rem] text-faint"> |
| 439 | <span>{hit.kind}</span> | |
| 440 | <span>{hit.source}</span> | |
| 441 | {hit.project && <span>{hit.project}</span>} | |
| 442 | {hit.by && <span>by {hit.by}</span>} | |
| 443 | {hit.updatedAt && ( | |
| 444 | <span> | |
| 445 | <TimeAgo at={hit.updatedAt} /> | |
| 446 | </span> | |
| 447 | )} | |
| 448 | </p> | |
| 449 | </div> | |
| 450 | </li> | |
| 451 | ); | |
| 452 | } | |
| 453 | ||
| 454 | /** One search over the catalog, docs, issues, pull requests and memory. */ | |
| 455 | export function SearchView({ result, query, base }: { result: SearchResult | null; query: string; base: string }) { | |
| 456 | const navigation = useNavigation(); | |
| 457 | const searching = navigation.state === "loading" && navigation.location?.search.includes("tab=search"); | |
| 458 | return ( | |
| 459 | <div className="space-y-4"> | |
| 460 | <Form method="get" action={base} className="flex gap-2"> | |
| 461 | <input type="hidden" name="tab" value="search" /> | |
| 462 | <label className="relative grow"> | |
| 463 | <Search size={15} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" /> | |
| 464 | <input | |
| 465 | name="q" | |
| 466 | defaultValue={query} | |
| 467 | placeholder="How do we deploy the api? Who owns billing? Why did we keep v1 webhooks?" | |
| 468 | autoComplete="off" | |
| 469 | data-1p-ignore | |
| 470 | className="w-full rounded-md border border-line bg-bg py-2 pr-3 pl-9 text-sm outline-none placeholder:text-faint hover:border-line-strong focus:border-accent-dim" | |
| 471 | /> | |
| 472 | </label> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 473 | <Button type="submit"> |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 474 | {searching ? "Searching…" : "Search"} |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 475 | </Button> |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 476 | </Form> |
| 477 | {result && | |
| 478 | (result.hits.length === 0 ? ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 479 | <Card asChild tone="plain" className="border-dashed px-4 py-6 text-sm text-muted"> |
| 480 | <p>Nothing matched “{result.query}”.</p> | |
| 481 | </Card> | |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 482 | ) : ( |
| 483 | <> | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 484 | <Card asChild divided> |
| 485 | <ul> | |
| 486 | {result.hits.map((hit) => ( | |
| 487 | <Hit key={`${hit.kind}:${hit.id}`} hit={hit} /> | |
| 488 | ))} | |
| 489 | </ul> | |
| 490 | </Card> | |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 491 | <p className="text-xs text-faint"> |
| 492 | {result.mode === "semantic" ? "Ranked by meaning, then by matching words." : "Matched by words: the search index did not answer."} | |
| 493 | </p> | |
| 494 | </> | |
| 495 | ))} | |
| 496 | </div> | |
| 497 | ); | |
| 498 | } | |
| 499 | ||
| 500 | // --- Scorecards --------------------------------------------------------------- | |
| 501 | ||
| 502 | function Rule({ rule, project, action }: { rule: RuleResult; project: string; action: string }) { | |
| 503 | const fetcher = useFetcher<Done>(); | |
| 504 | const icon = | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 505 | rule.status === "pass" ? <Check size={14} className="text-success" /> : rule.status === "fail" ? <XCircle size={14} className="text-danger" /> : <CircleDashed size={14} className="text-faint" />; |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 506 | return ( |
| 507 | <li className="flex items-start gap-2.5 py-1.5"> | |
| 508 | <span className="mt-0.5">{icon}</span> | |
| 509 | <div className="min-w-0 grow text-sm"> | |
| 510 | <span className={rule.status === "na" ? "text-muted" : ""}>{rule.title}</span> | |
| 511 | <p className="text-xs text-muted">{rule.detail}</p> | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 512 | {fetcher.data?.message && <p className="mt-1 text-xs text-success">{fetcher.data.message}</p>} |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 513 | {fetcher.data?.error && <p className="mt-1 text-xs text-danger">{fetcher.data.error}</p>} |
| 514 | </div> | |
| 515 | {rule.fix && !fetcher.data?.ok && ( | |
| 516 | <fetcher.Form method="post" action={action}> | |
| 517 | <input type="hidden" name="intent" value="fix" /> | |
| 518 | <input type="hidden" name="project" value={project} /> | |
| 519 | <input type="hidden" name="rule" value={rule.rule} /> | |
| Merge branch 'worktree-agent-ae1299e92e4462012' | 520 | <Hint label={`Opens “${rule.fix.title}” and puts an agent on it`}> |
| 521 | <SubmitButton | |
| 522 | fetcher={fetcher} | |
| 523 | pending="Opening…" | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 524 | variant="outline" |
| 525 | size="inline" | |
| 526 | className="gap-1 px-2 py-1 text-xs text-muted hover:border-accent-dim font-normal" | |
| Merge branch 'worktree-agent-ae1299e92e4462012' | 527 | > |
| 528 | <Bot size={12} /> | |
| 529 | Fix with an agent | |
| 530 | </SubmitButton> | |
| 531 | </Hint> | |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 532 | </fetcher.Form> |
| 533 | )} | |
| 534 | </li> | |
| 535 | ); | |
| 536 | } | |
| 537 | ||
| 538 | /** Each project's scorecard; a failing rule becomes an issue for an agent in one click. */ | |
| 539 | export function ScorecardsView({ cards, action }: { cards: Scorecard[]; action: string }) { | |
| 540 | if (cards.length === 0) return <p className="rounded-xl border border-dashed border-line px-4 py-6 text-sm text-muted">No projects yet.</p>; | |
| 541 | return ( | |
| 542 | <div className="grid gap-3 md:grid-cols-2"> | |
| 543 | {cards.map((card) => ( | |
| The site is built from its shared components: one Button with its variants and sizes, one Badge, Card for every panel, Alert for notices, Kbd for keys, Table for the security tables, and Avatar, Command, Progress, ScrollArea, ToggleGroup, Collapsible and Accordion in the kit; raw buttons, hand-drawn cards and pills and per-file button class lists are gone, outline buttons stand the same height as filled ones, and the phone's Ask g1t loses its tile. Every page was compared pixel by pixel before and after. | 544 | <Card asChild key={card.project} className="p-4"> |
| 545 | <section> | |
| 546 | <div className="flex items-baseline justify-between gap-3"> | |
| 547 | <Link to={`/${card.repo.namespace}/${card.repo.name}`} className="font-medium hover:text-accent"> | |
| 548 | {card.name} | |
| 549 | </Link> | |
| 550 | <span className={`text-xs ${card.passed === card.total ? "text-success" : "text-muted"}`}> | |
| 551 | {card.passed} of {card.total} | |
| 552 | </span> | |
| 553 | </div> | |
| 554 | <ul className="mt-2"> | |
| 555 | {card.rules.map((rule) => ( | |
| 556 | <Rule key={rule.rule} rule={rule} project={card.project} action={action} /> | |
| 557 | ))} | |
| 558 | </ul> | |
| 559 | </section> | |
| 560 | </Card> | |
| Billing answers every page in a few round trips, not forty: its reads run together, Stripe is asked at most hourly, and the ledger has a time index | 561 | ))} |
| 562 | </div> | |
| 563 | ); | |
| 564 | } | |
| 565 | ||
| 566 | export type ContextActionResult = Done; | |
| 567 | ||
| 568 | /** Opens a scorecard rule's fix as an issue and puts g1t's agent on it. */ | |
| 569 | export async function fixRule( | |
| 570 | deps: { | |
| 571 | scorecards: () => Promise<Result<Scorecard[]>>; | |
| 572 | openIssue: (repo: { namespace: string; name: string }, input: { title: string; body: string; checks: string[] }) => Promise<Result<{ number: number }>>; | |
| 573 | assign: (repo: { namespace: string; name: string }, number: number) => Promise<unknown>; | |
| 574 | }, | |
| 575 | project: string, | |
| 576 | rule: string, | |
| 577 | ): Promise<Done> { | |
| 578 | const cards = await deps.scorecards(); | |
| 579 | if (!cards.ok) return { ok: false, error: cards.error.message }; | |
| 580 | const card = cards.value.find((c) => c.project === project); | |
| 581 | const fix = card?.rules.find((r) => r.rule === rule && r.status === "fail")?.fix; | |
| 582 | if (!card || !fix) return { ok: false, error: "That rule passes now, or no longer applies." }; | |
| 583 | const opened = await deps.openIssue(card.repo, { | |
| 584 | title: fix.title, | |
| 585 | body: `${fix.body}\n\nOpened from ${card.name}'s scorecard (${rule}).`, | |
| 586 | checks: fix.checks, | |
| 587 | }); | |
| 588 | if (!opened.ok) return { ok: false, error: opened.error.message }; | |
| 589 | await deps.assign(card.repo, opened.value.number); | |
| 590 | return { ok: true, message: `Opened #${opened.value.number}; an agent is on it.` }; | |
| 591 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.