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