flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/apps/web/app/components/agent-composer.tsx

169 lines7,454 bytesCodeBlame
1import { ArrowUpRight, ChevronDown, LoaderCircle, Sparkles } from "lucide-react";
2import { type ReactNode, useEffect, useRef, useState } from "react";
3import { Link, useFetcher } from "react-router";
4
5import type { RepoPath } from "@g1t/contracts";
6
7import { cn } from "../lib/cn";
8import type { NotStarted } from "../lib/delegate";
9import { Avatar } from "./ui";
10import { CONTROL } from "./ui/input";
11
12/** What the composer's action said back: why nothing opened, or the issue that opened without its agent. */
13export type ComposerResult = { error: string | null; notStarted: NotStarted | null } | null;
14
15/**
16 * "Put an agent on it": a compact composer that opens an issue in one of
17 * the viewer's projects and puts g1t-agent on it, in one step. Posts to
18 * Mission control's action, which lands on the issue with the agent
19 * running. A native `<details>`, so it opens and posts without script.
20 */
21export function AgentComposer({
22 repos,
23 open: openAtFirst,
24 result,
25 note,
26 action = "/?index",
27 children,
28}: {
29 repos: RepoPath[];
30 /** Open from the start: asked for by the address, or a post came back. */
31 open: boolean;
32 /** What a post without script came back with. */
33 result: ComposerResult;
34 /** Said under the form, such as that agents need a model first. */
35 note?: ReactNode;
36 action?: string;
37 /** The button that opens it. */
38 children: ReactNode;
39}) {
40 const fetcher = useFetcher<ComposerResult>();
41 const ref = useRef<HTMLDetailsElement>(null);
42 const [open, setOpen] = useState(openAtFirst);
43 const busy = fetcher.state !== "idle";
44 const said = fetcher.data ?? result;
45
46 // Closed by Escape or by a click outside it, as a menu is.
47 useEffect(() => {
48 if (!open) return;
49 const onKey = (event: KeyboardEvent) => event.key === "Escape" && setOpen(false);
50 const onClick = (event: MouseEvent) => {
51 if (ref.current && !ref.current.contains(event.target as Node)) setOpen(false);
52 };
53 document.addEventListener("keydown", onKey);
54 document.addEventListener("mousedown", onClick);
55 return () => {
56 document.removeEventListener("keydown", onKey);
57 document.removeEventListener("mousedown", onClick);
58 };
59 }, [open]);
60
61 return (
62 <details
63 ref={ref}
64 open={open}
65 onToggle={(event) => setOpen((event.currentTarget as HTMLDetailsElement).open)}
66 className="group/composer relative"
67 >
68 <summary className="list-none [&::-webkit-details-marker]:hidden">{children}</summary>
69 <div className="fixed inset-x-4 top-20 z-40 sm:absolute sm:inset-x-auto sm:top-full sm:left-0 sm:mt-2 sm:w-[28rem]">
70 <fetcher.Form
71 method="post"
72 action={action}
73 className="rounded-xl border border-line-strong bg-raised p-4 shadow-2xl shadow-black/50"
74 aria-label="Put an agent on it"
75 >
76 <input type="hidden" name="intent" value="delegate" />
77 <p className="flex items-center gap-2 text-sm font-semibold">
78 <Sparkles size={15} className="text-merged" />
79 Put an agent on it
80 </p>
81 <p className="mt-1 text-xs leading-5 text-muted">
82 Opens an issue and assigns g1t-agent at once. It makes the change in a sandbox and sees it through checks
83 and review.
84 </p>
85
86 <div className="mt-4 space-y-3">
87 <label className="block">
88 <span className="mb-1.5 block text-xs font-medium text-muted">Project</span>
89 <span className="relative block">
90 <select name="repo" required className={cn(CONTROL, "h-9 appearance-none pr-8")} defaultValue={repos[0] ? `${repos[0].namespace}/${repos[0].name}` : ""}>
91 {repos.map((repo) => (
92 <option key={`${repo.namespace}/${repo.name}`} value={`${repo.namespace}/${repo.name}`}>
93 {repo.name}
94 </option>
95 ))}
96 </select>
97 <ChevronDown size={14} className="pointer-events-none absolute top-1/2 right-2.5 -translate-y-1/2 text-faint" />
98 </span>
99 </label>
100 <label className="block">
101 <span className="mb-1.5 block text-xs font-medium text-muted">Title</span>
102 <input
103 name="title"
104 required
105 maxLength={200}
106 autoComplete="off"
107 data-1p-ignore
108 placeholder="Retry failed webhook deliveries"
109 className={cn(CONTROL, "h-9")}
110 />
111 </label>
112 <label className="block">
113 <span className="mb-1.5 block text-xs font-medium text-muted">What you want done</span>
114 <textarea
115 name="body"
116 rows={4}
117 placeholder="In plain words: what is wrong or wanted, and anything the agent cannot see for itself."
118 className={cn(CONTROL, "min-h-24 resize-y leading-relaxed")}
119 />
120 </label>
121 <p className="text-xs text-faint">
122 Its pull request runs the project's workflows as its checks, and merges once the ones the default branch
123 requires pass. Say what done looks like in your own words, if it helps.
124 </p>
125 </div>
126
127 {said?.error && <p className="mt-3 text-sm text-danger">{said.error}</p>}
128 {said?.notStarted && (
129 <div className="mt-3 rounded-lg border border-warn/30 bg-warn/[0.06] p-3 text-sm">
130 <p className="text-fg-soft">
131 Opened <Link to={said.notStarted.to} className="font-medium text-fg hover:underline">#{said.notStarted.number}</Link>, but
132 g1t-agent did not start. {said.notStarted.message}
133 </p>
134 <p className="mt-2 flex flex-wrap gap-2">
135 {said.notStarted.fix && (
136 <Link
137 to={said.notStarted.fix.to}
138 className="inline-flex items-center gap-1 rounded-md bg-fg px-2.5 py-1 text-xs font-medium text-bg hover:bg-white"
139 >
140 {said.notStarted.fix.label} <ArrowUpRight size={12} />
141 </Link>
142 )}
143 <Link to={said.notStarted.to} className="inline-flex items-center rounded-md border border-line-strong px-2.5 py-1 text-xs font-medium text-fg/90 hover:bg-bg">
144 Open the issue
145 </Link>
146 </p>
147 </div>
148 )}
149 {note && <p className="mt-3 text-xs leading-5 text-muted">{note}</p>}
150
151 <div className="mt-4 flex items-center justify-between gap-3">
152 <span className="flex min-w-0 items-center gap-1.5 text-xs text-faint">
153 <Avatar name="g1t-agent" size={16} />
154 <span className="truncate">g1t-agent · no model to choose</span>
155 </span>
156 <button
157 type="submit"
158 disabled={busy || repos.length === 0}
159 className="inline-flex shrink-0 items-center gap-1.5 rounded-md bg-merged px-3.5 py-2 text-sm font-semibold text-bg transition-colors hover:bg-[#c9bfff] disabled:opacity-60"
160 >
161 {busy ? <LoaderCircle size={14} className="animate-spin" /> : <Sparkles size={14} />}
162 {busy ? "Starting…" : "Put an agent on it"}
163 </button>
164 </div>
165 </fetcher.Form>
166 </div>
167 </details>
168 );
169}