Skip to content
591 linesCodeBlameRaw
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 { Button } from "./ui/button";
50import { Card } from "./ui/card";
51import { Hint } from "./ui/hint";
52import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
53import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
54
55type Done = { ok: boolean; error?: string; message?: string } | undefined;
56
57export 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
69const 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
84const 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. */
90function 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
124function 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
141function 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} />
164 <SubmitButton fetcher={fetcher} pending="Keeping…">
165 Keep
166 </SubmitButton>
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
175function 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>
188 <p className="mt-1.5 text-sm whitespace-pre-wrap wrap-break-word">{memory.text}</p>
189 {memory.source.evidence && (
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>
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">
201 <Button
202 type="button"
203 onClick={() => fetcher.submit({ intent: "keep", id: memory.id }, { method: "post", action })}
204 size="inline"
205 className="gap-1 px-2 py-1 text-xs"
206 >
207 <Check size={12} />
208 Keep
209 </Button>
210 <EditAndKeep memory={memory} action={action} />
211 <Hint label="Dismiss: never suggested again in these words">
212 <Button
213 type="button"
214 aria-label="Dismiss"
215 onClick={() => fetcher.submit({ intent: "dismiss", id: memory.id }, { method: "post", action })}
216 variant="outline"
217 size="inline"
218 className="gap-1 px-2 py-1 text-xs text-muted hover:text-danger font-normal"
219 >
220 <X size={12} />
221 Dismiss
222 </Button>
223 </Hint>
224 </div>
225 </li>
226 );
227}
228
229/** Memory candidates waiting for a person: keep, edit and keep, or dismiss. */
230export 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 (
235 <Card asChild divided>
236 <ul>
237 {candidates.map((memory) => (
238 <Candidate key={memory.id} memory={memory} action={action} />
239 ))}
240 </ul>
241 </Card>
242 );
243}
244
245/** The intents of the Review queue: keep (with an edit, or as it is) and dismiss. Null for other intents. */
246export 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 */
265export 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 (
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>
322 );
323}
324
325function 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>
347 {entity.summary && <p className="mt-0.5 text-xs text-muted"><InlineMarkdown text={entity.summary} /></p>}
348 </div>
349 </li>
350 );
351}
352
353/** The catalog: filters by kind and project, the graph, and the entries. */
354export 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 ? (
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>
400 ) : (
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>
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
420function 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>
437 {hit.snippet && <p className="mt-0.5 text-xs text-muted"><InlineMarkdown text={hit.snippet} /></p>}
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. */
455export 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>
473 <Button type="submit">
474 {searching ? "Searching…" : "Search"}
475 </Button>
476 </Form>
477 {result &&
478 (result.hits.length === 0 ? (
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>
482 ) : (
483 <>
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>
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
502function Rule({ rule, project, action }: { rule: RuleResult; project: string; action: string }) {
503 const fetcher = useFetcher<Done>();
504 const icon =
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" />;
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>
512 {fetcher.data?.message && <p className="mt-1 text-xs text-success">{fetcher.data.message}</p>}
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} />
520 <Hint label={`Opens “${rule.fix.title}” and puts an agent on it`}>
521 <SubmitButton
522 fetcher={fetcher}
523 pending="Opening…"
524 variant="outline"
525 size="inline"
526 className="gap-1 px-2 py-1 text-xs text-muted hover:border-accent-dim font-normal"
527 >
528 <Bot size={12} />
529 Fix with an agent
530 </SubmitButton>
531 </Hint>
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. */
539export 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) => (
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>
561 ))}
562 </div>
563 );
564}
565
566export type ContextActionResult = Done;
567
568/** Opens a scorecard rule's fix as an issue and puts g1t's agent on it. */
569export 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}