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