flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/web/app/components/context.tsx

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