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

179 lines8,060 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 <details className="group/checks">
122 <summary className="cursor-pointer list-none text-xs font-medium text-muted hover:text-fg [&::-webkit-details-marker]:hidden">
123 <ChevronDown size={13} className="mr-1 inline -rotate-90 transition-transform group-open/checks:rotate-0" />
124 Acceptance checks <span className="font-normal text-faint">(optional)</span>
125 </summary>
126 <textarea
127 name="checks"
128 rows={2}
129 placeholder="npm test"
130 aria-label="Acceptance checks, one command per line"
131 className={cn(CONTROL, "mt-2 min-h-16 resize-y font-mono text-xs")}
132 />
133 <span className="mt-1 block text-xs text-faint">One command per line. Its pull request must make them all pass.</span>
134 </details>
135 </div>
136
137 {said?.error && <p className="mt-3 text-sm text-danger">{said.error}</p>}
138 {said?.notStarted && (
139 <div className="mt-3 rounded-lg border border-warn/30 bg-warn/[0.06] p-3 text-sm">
140 <p className="text-fg-soft">
141 Opened <Link to={said.notStarted.to} className="font-medium text-fg hover:underline">#{said.notStarted.number}</Link>, but
142 g1t-agent did not start. {said.notStarted.message}
143 </p>
144 <p className="mt-2 flex flex-wrap gap-2">
145 {said.notStarted.fix && (
146 <Link
147 to={said.notStarted.fix.to}
148 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"
149 >
150 {said.notStarted.fix.label} <ArrowUpRight size={12} />
151 </Link>
152 )}
153 <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">
154 Open the issue
155 </Link>
156 </p>
157 </div>
158 )}
159 {note && <p className="mt-3 text-xs leading-5 text-muted">{note}</p>}
160
161 <div className="mt-4 flex items-center justify-between gap-3">
162 <span className="flex min-w-0 items-center gap-1.5 text-xs text-faint">
163 <Avatar name="g1t-agent" size={16} />
164 <span className="truncate">g1t-agent · no model to choose</span>
165 </span>
166 <button
167 type="submit"
168 disabled={busy || repos.length === 0}
169 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"
170 >
171 {busy ? <LoaderCircle size={14} className="animate-spin" /> : <Sparkles size={14} />}
172 {busy ? "Starting…" : "Put an agent on it"}
173 </button>
174 </div>
175 </fetcher.Form>
176 </div>
177 </details>
178 );
179}