Skip to content
157 linesCodeBlameRaw
1import { env } from "cloudflare:workers";
2import { Brain, RefreshCw } from "lucide-react";
3import { Link, data, useFetcher, useNavigate } from "react-router";
4
5import { ENTITY_KINDS, type EntityKind } from "@g1t/contracts";
6
7import type { Route } from "./+types/context";
8import { page } from "../../lib/meta";
9import { useRefreshWhile } from "../../lib/refresh";
10import { CatalogView, ReviewQueue, ScorecardsView, SearchView, fixRule, reviewAction } from "../../components/context";
11import { SubmitButton } from "../../components/ui";
12import { Card } from "../../components/ui/card";
13import { Tabs, TabsList, TabsTrigger } from "../../components/ui/tabs";
14import { context as hub, memoryReview, work } from "../../lib/services.server";
15import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
16
17const TABS = ["catalog", "memory", "search", "scorecards"] as const;
18type Tab = (typeof TABS)[number];
19
20export function meta({ params, ...args }: Route.MetaArgs) {
21 return page(args, { title: `Context · ${params.owner} · g1t` });
22}
23
24export async function loader({ params, context, request }: Route.LoaderArgs) {
25 const viewer = getViewer(context);
26 // What the workspace knows can be internal: members only.
27 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
28 const url = new URL(request.url);
29 const tab: Tab = (TABS as readonly string[]).includes(url.searchParams.get("tab") ?? "") ? (url.searchParams.get("tab") as Tab) : "catalog";
30 const kindParam = url.searchParams.get("kind");
31 const kind = ENTITY_KINDS.includes(kindParam as EntityKind) ? (kindParam as EntityKind) : null;
32 const project = url.searchParams.get("project");
33 const query = url.searchParams.get("q") ?? "";
34 // The first look at a workspace's context starts building it.
35 const status = unwrap(await hub.status(params.owner, viewer));
36 const [catalog, candidates, search, cards] = await Promise.all([
37 tab === "catalog" ? hub.catalog(params.owner, viewer).then(unwrap) : null,
38 tab === "memory" ? memoryReview.listCandidates(viewer, params.owner).then(unwrap) : null,
39 tab === "search" && query.trim() ? hub.search(params.owner, viewer, query).then(unwrap) : null,
40 tab === "scorecards" ? hub.scorecards(params.owner, viewer).then(unwrap) : null,
41 ]);
42 return { tab, kind, project, query, status, catalog, candidates, search, cards };
43}
44
45export async function action({ params, context, request }: Route.ActionArgs) {
46 assertSameOrigin(request);
47 const user = requireUser(context, request);
48 const form = await request.formData();
49 const reviewed = await reviewAction(memoryReview, user, params.owner, form);
50 if (reviewed) return reviewed;
51 const intent = String(form.get("intent") ?? "");
52 if (intent === "backfill") {
53 const started = await hub.backfill(user, params.owner);
54 return started.ok ? { ok: true, message: "Rebuilding the catalog and the search index." } : { ok: false, error: started.error.message };
55 }
56 if (intent === "fix") {
57 return fixRule(
58 {
59 scorecards: () => hub.scorecards(params.owner, user),
60 openIssue: (repo, input) => work.openIssue(user, repo, input),
61 // The issue exists either way; its page shows how to put an agent on it if none could start.
62 assign: (repo, number) => env.RUNNER.run(user, repo, number).catch(() => null),
63 },
64 String(form.get("project") ?? ""),
65 String(form.get("rule") ?? ""),
66 );
67 }
68 return { ok: false, error: "Unknown action." };
69}
70
71function Rebuild({ action, running }: { action: string; running: boolean }) {
72 const fetcher = useFetcher<{ ok: boolean; error?: string; message?: string }>();
73 return (
74 <fetcher.Form method="post" action={action} className="flex items-center gap-2">
75 <input type="hidden" name="intent" value="backfill" />
76 <SubmitButton
77 fetcher={fetcher}
78 match={{ intent: "backfill" }}
79 pending="Starting…"
80 disabled={running}
81 variant="outline"
82 size="xs"
83 className="gap-1.5 px-2.5 text-muted font-normal"
84 >
85 <RefreshCw size={12} className={running ? "animate-spin" : ""} />
86 {running ? "Building…" : "Rebuild"}
87 </SubmitButton>
88 {fetcher.data?.error && <span className="text-xs text-danger">{fetcher.data.error}</span>}
89 </fetcher.Form>
90 );
91}
92
93export default function WorkspaceContext({ loaderData, params }: Route.ComponentProps) {
94 const { tab, kind, project, query, status, catalog, candidates, search, cards } = loaderData;
95 const base = `/${params.owner}/-/context`;
96 const navigate = useNavigate();
97 const backfill = status.backfill;
98 const running = backfill?.status === "running";
99 // While it builds, the count of projects done keeps up on its own.
100 useRefreshWhile(running);
101 const total = Object.values(status.counts).reduce((sum, n) => sum + (n ?? 0), 0);
102 return (
103 <div className="space-y-6">
104 <div className="flex flex-wrap items-center justify-between gap-3 text-xs text-muted">
105 <p>
106 {running
107 ? `Building from ${backfill.projects} project${backfill.projects === 1 ? "" : "s"}: ${backfill.done} done.`
108 : `${total} entries in the catalog${backfill?.finishedAt ? `; ${backfill.candidates} memory candidates and ${backfill.kept} kept at the last rebuild` : ""}.`}
109 {!status.semantic && " Search matches words only here."}
110 </p>
111 <Rebuild action={base} running={running} />
112 </div>
113
114 <Tabs value={tab} onValueChange={(value) => navigate(`${base}?tab=${value}`)}>
115 <TabsList>
116 <TabsTrigger value="catalog">Catalog</TabsTrigger>
117 <TabsTrigger value="memory">Memory{candidates && candidates.length ? ` (${candidates.length})` : ""}</TabsTrigger>
118 <TabsTrigger value="search">Search</TabsTrigger>
119 <TabsTrigger value="scorecards">Scorecards</TabsTrigger>
120 </TabsList>
121 </Tabs>
122
123 {tab === "catalog" && catalog && <CatalogView catalog={catalog} kind={kind} project={project} base={base} />}
124
125 {tab === "memory" && candidates && (
126 <div className="space-y-6">
127 <Card className="p-4 text-sm">
128 <p className="flex items-center gap-2 font-medium">
129 <Brain size={15} className="text-accent" />
130 Memory fills itself
131 </p>
132 <p className="mt-1 text-muted">
133 Agents say what they learned at the end of every run; corrections in reviews, merged pull requests and each
134 project's docs add more. What two independent sources say, or a project's AGENTS.md and manifests state, is
135 kept at once. The rest waits here: keep it, edit it, or dismiss it so it is never suggested again.
136 </p>
137 </Card>
138 <section>
139 <div className="flex items-baseline justify-between">
140 <h2 className="text-sm font-medium">Review queue</h2>
141 <Link to={`/${params.owner}/-/memory`} className="text-xs text-muted hover:text-fg">
142 Kept workspace memory
143 </Link>
144 </div>
145 <div className="mt-3">
146 <ReviewQueue candidates={candidates} action={base} empty="Nothing waiting for review." />
147 </div>
148 </section>
149 </div>
150 )}
151
152 {tab === "search" && <SearchView result={search} query={query} base={base} />}
153
154 {tab === "scorecards" && cards && <ScorecardsView cards={cards} action={base} />}
155 </div>
156 );
157}