g1t/apps/web/app/routes/workspace/context.tsx

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