Skip to content

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

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