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