Mission control drops the composer for now
1 file+4−1840/1 viewed
| 6 | 6 | Box, | |
| 7 | 7 | CircleDot, | |
| 8 | 8 | Coins, | |
| 9 | − | Eye, | |
| 10 | − | FolderPlus, | |
| 11 | 9 | GitPullRequest, | |
| 12 | 10 | Hand, | |
| 13 | 11 | Lock, | |
| 14 | − | Map as MapIcon, | |
| 15 | 12 | Plus, | |
| 16 | 13 | Radio, | |
| 17 | − | Send, | |
| 18 | 14 | Settings, | |
| 19 | 15 | Sparkles, | |
| 20 | 16 | Users, | |
| 21 | 17 | } from "lucide-react"; | |
| 22 | 18 | import { env } from "cloudflare:workers"; | |
| 23 | − | import { type ReactNode, useEffect, useRef, useState } from "react"; | |
| 24 | − | import { Form, Link, data, redirect, useNavigation, useRouteLoaderData } from "react-router"; | |
| 19 | + | import { type ReactNode, useEffect, useState } from "react"; | |
| 20 | + | import { Link, data, useRouteLoaderData } from "react-router"; | |
| 25 | 21 | ||
| 26 | 22 | import { | |
| 27 | 23 | type AgentRun, | |
| 62 | 58 | nextSeen, | |
| 63 | 59 | rankNeeds, | |
| 64 | 60 | readCookie, | |
| 65 | − | splitRequest, | |
| 66 | 61 | stuckMinutes, | |
| 67 | 62 | } from "../lib/mission"; | |
| 68 | 63 | import { Landing } from "../components/landing"; | |
| 74 | 69 | import { ActivityFeed, Meter, NeedsList, Panel, PulseTile, Quiet, Unavailable, percent } from "../components/mission"; | |
| 75 | 70 | import { Avatar, CopyLine, TimeAgo } from "../components/ui"; | |
| 76 | 71 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../components/ui/select"; | |
| 77 | − | import { Switch } from "../components/ui/switch"; | |
| 78 | 72 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs"; | |
| 79 | 73 | import { IssueIcon, PullIcon } from "../components/work"; | |
| 80 | 74 | import { | |
| 87 | 81 | repos as reposApi, | |
| 88 | 82 | work, | |
| 89 | 83 | } from "../lib/services.server"; | |
| 90 | − | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../lib/session.server"; | |
| 84 | + | import { getViewer } from "../lib/session.server"; | |
| 91 | 85 | ||
| 92 | 86 | /** The viewer's time zone, set by the page itself, so the greeting fits their day. */ | |
| 93 | 87 | const TZ_COOKIE = "g1t_tz"; | |
| 502 | 496 | greeting: greetingFor(hourIn(now, tz)), | |
| 503 | 497 | seenBefore: seen.since, | |
| 504 | 498 | repos: repoList, | |
| 505 | − | // Projects the composer can open an issue in: those of the viewer's workspaces. | |
| 506 | − | targets: repoList | |
| 507 | − | .filter((repo) => roleIn(viewer, repo.namespace) != null && !repo.forkOf) | |
| 508 | − | .map((repo) => ({ namespace: repo.namespace, name: repo.name, isPrivate: repo.isPrivate })), | |
| 509 | 499 | canRunAgents: models_ == null || models_.hosted || models_.own != null, | |
| 510 | 500 | trial: models_?.own == null ? (models_?.trial ?? null) : null, | |
| 511 | 501 | active: activeList.map(({ pull, lifecycle, repo }) => ({ pull, lifecycle, repo })), | |
| 531 | 521 | ); | |
| 532 | 522 | } | |
| 533 | 523 | ||
| 534 | − | export async function action({ request, context }: Route.ActionArgs) { | |
| 535 | − | assertSameOrigin(request); | |
| 536 | − | const user = requireUser(context, request); | |
| 537 | − | const form = await request.formData(); | |
| 538 | − | const [namespace, name] = String(form.get("project") ?? "").split("/"); | |
| 539 | − | if (!namespace || !name) return { error: "Choose the project this is for." }; | |
| 540 | − | const { title, body } = splitRequest(String(form.get("request") ?? "")); | |
| 541 | − | if (!title) return { error: "Say what you want done first." }; | |
| 542 | − | const path = { namespace, name }; | |
| 543 | − | const opened = await work.openIssue(user, path, { title, body }); | |
| 544 | − | if (!opened.ok) return { error: opened.error.message }; | |
| 545 | − | const to = `/${namespace}/${name}/issues/${opened.value.number}`; | |
| 546 | − | if (form.get("assign") === "on") { | |
| 547 | − | // The issue exists either way; its page shows the agent's pull request, | |
| 548 | − | // or, if none could start, how to put one on it. | |
| 549 | − | await env.RUNNER.run(user, path, opened.value.number).catch(() => null); | |
| 550 | − | } | |
| 551 | − | throw redirect(to); | |
| 552 | − | } | |
| 553 | − | ||
| 554 | 524 | type Loaded = Extract<Route.ComponentProps["loaderData"], { signedIn: true }>; | |
| 555 | 525 | ||
| 556 | 526 | // --- Pieces --------------------------------------------------------------------- | |
| 557 | − | ||
| 558 | − | function Composer({ | |
| 559 | − | targets, | |
| 560 | − | canRunAgents, | |
| 561 | − | workspace, | |
| 562 | − | onReview, | |
| 563 | − | }: { | |
| 564 | − | targets: Loaded["targets"]; | |
| 565 | − | canRunAgents: boolean; | |
| 566 | − | workspace: string | null; | |
| 567 | − | onReview: () => void; | |
| 568 | − | }) { | |
| 569 | − | const navigation = useNavigation(); | |
| 570 | − | const busy = navigation.state === "submitting" && navigation.formAction === "/?index"; | |
| 571 | − | const [project, setProject] = useState(() => (targets[0] ? `${targets[0].namespace}/${targets[0].name}` : "")); | |
| 572 | − | const [assign, setAssign] = useState(canRunAgents); | |
| 573 | − | const text = useRef<HTMLTextAreaElement>(null); | |
| 574 | − | const base = project ? `/${project}` : null; | |
| 575 | − | const pill = | |
| 576 | − | "inline-flex items-center gap-1.5 rounded-full border border-line bg-bg px-3 py-1.5 text-xs font-medium text-fg/80 transition-colors hover:border-line-strong hover:bg-raised hover:text-fg"; | |
| 577 | − | if (targets.length === 0) { | |
| 578 | − | return ( | |
| 579 | − | <div className="rounded-2xl border border-line bg-surface p-5"> | |
| 580 | − | <p className="font-medium">Add a project to hand work to agents</p> | |
| 581 | − | <p className="mt-1 text-sm text-muted"> | |
| 582 | − | Create a repository or import one by its address. Then describe what you want done here, and an agent starts on it. | |
| 583 | − | </p> | |
| 584 | − | <Link to={workspace ? `/new?workspace=${workspace}` : "/new"} className={`${pill} mt-4`}> | |
| 585 | − | <FolderPlus size={13} /> New project | |
| 586 | − | </Link> | |
| 587 | − | </div> | |
| 588 | − | ); | |
| 589 | − | } | |
| 590 | − | return ( | |
| 591 | − | <div> | |
| 592 | − | <Form | |
| 593 | − | method="post" | |
| 594 | − | action="/?index" | |
| 595 | − | className="rounded-2xl border border-line bg-surface shadow-[0_1px_0_0_rgb(255_255_255/0.03)_inset] transition-colors focus-within:border-accent-dim/70" | |
| 596 | − | > | |
| 597 | − | <label htmlFor="composer" className="sr-only"> | |
| 598 | − | Describe what you want done | |
| 599 | − | </label> | |
| 600 | − | <textarea | |
| 601 | − | id="composer" | |
| 602 | − | ref={text} | |
| 603 | − | name="request" | |
| 604 | − | required | |
| 605 | − | rows={3} | |
| 606 | − | maxLength={20000} | |
| 607 | − | autoComplete="off" | |
| 608 | − | data-1p-ignore | |
| 609 | − | placeholder="Describe what you want done… The first line becomes the issue's title." | |
| 610 | − | onKeyDown={(event) => { | |
| 611 | − | if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) event.currentTarget.form?.requestSubmit(); | |
| 612 | − | }} | |
| 613 | − | className="block w-full resize-y rounded-t-2xl bg-transparent px-4 pt-4 pb-2 text-[0.9375rem] leading-6 outline-none placeholder:text-faint" | |
| 614 | − | /> | |
| 615 | − | <div className="flex flex-wrap items-center gap-2 border-t border-line/70 px-3 py-2.5"> | |
| 616 | − | <Select name="project" value={project} onValueChange={setProject}> | |
| 617 | − | <SelectTrigger size="sm" className="w-auto max-w-60" aria-label="Project"> | |
| 618 | − | <SelectValue placeholder="Project" /> | |
| 619 | − | </SelectTrigger> | |
| 620 | − | <SelectContent> | |
| 621 | − | {targets.map((target) => ( | |
| 622 | − | <SelectItem | |
| 623 | − | key={`${target.namespace}/${target.name}`} | |
| 624 | − | value={`${target.namespace}/${target.name}`} | |
| 625 | − | icon={<Avatar name={target.name} size={16} square />} | |
| 626 | − | description={target.namespace} | |
| 627 | − | > | |
| 628 | − | {target.name} | |
| 629 | − | </SelectItem> | |
| 630 | − | ))} | |
| 631 | − | </SelectContent> | |
| 632 | − | </Select> | |
| 633 | − | <label | |
| 634 | − | className={`inline-flex h-8 items-center gap-2 rounded-md px-2 text-xs ${canRunAgents ? "text-muted" : "text-faint"}`} | |
| 635 | − | title={canRunAgents ? "Open the issue and start g1t-agent on it" : "Connect a model under Integrations to run agents"} | |
| 636 | − | > | |
| 637 | − | <Switch checked={assign} onCheckedChange={setAssign} disabled={!canRunAgents} aria-label="Hand to an agent" /> | |
| 638 | − | <Bot size={13} className={assign ? "text-merged" : ""} /> | |
| 639 | − | Hand to g1t-agent | |
| 640 | − | </label> | |
| 641 | − | {assign && <input type="hidden" name="assign" value="on" />} | |
| 642 | − | <span className="grow" /> | |
| 643 | − | <span className="hidden text-[0.6875rem] text-faint sm:inline">Ctrl+Enter</span> | |
| 644 | − | <button | |
| 645 | − | type="submit" | |
| 646 | − | disabled={busy} | |
| 647 | − | className="inline-flex h-8 items-center gap-1.5 rounded-md bg-fg px-3 text-sm font-medium text-bg transition-colors hover:bg-white disabled:opacity-50" | |
| 648 | − | > | |
| 649 | − | <Send size={13} /> | |
| 650 | − | {busy ? (assign ? "Starting…" : "Opening…") : assign ? "Start" : "Open issue"} | |
| 651 | − | </button> | |
| 652 | − | </div> | |
| 653 | − | </Form> | |
| 654 | − | <div className="mt-3 flex flex-wrap gap-2"> | |
| 655 | − | {base && ( | |
| 656 | − | <Link to={`${base}/issues/new`} className={pill}> | |
| 657 | − | <CircleDot size={13} /> New issue | |
| 658 | − | </Link> | |
| 659 | − | )} | |
| 660 | − | <button | |
| 661 | − | type="button" | |
| 662 | − | className={pill} | |
| 663 | − | disabled={!canRunAgents} | |
| 664 | − | onClick={() => { | |
| 665 | − | setAssign(true); | |
| 666 | − | text.current?.focus(); | |
| 667 | − | }} | |
| 668 | − | > | |
| 669 | − | <Sparkles size={13} className="text-merged" /> Hand to an agent | |
| 670 | − | </button> | |
| 671 | − | {base && ( | |
| 672 | − | <Link to={`${base}/plans`} className={pill}> | |
| 673 | − | <MapIcon size={13} /> Plan an outcome | |
| 674 | − | </Link> | |
| 675 | − | )} | |
| 676 | − | <button type="button" className={pill} onClick={onReview}> | |
| 677 | − | <Eye size={13} /> Review queue | |
| 678 | − | </button> | |
| 679 | − | <Link to={workspace ? `/new?workspace=${workspace}` : "/new"} className={pill}> | |
| 680 | − | <FolderPlus size={13} /> New project | |
| 681 | − | </Link> | |
| 682 | − | </div> | |
| 683 | − | </div> | |
| 684 | − | ); | |
| 685 | − | } | |
| 686 | 527 | ||
| 687 | 528 | const TONE = { fg: "text-fg", warn: "text-warn", danger: "text-danger", accent: "text-accent" }; | |
| 688 | 529 | ||
| 1151 | 992 | }, | |
| 1152 | 993 | ]; | |
| 1153 | 994 | const starting = steps.some((step) => !step.done); | |
| 1154 | − | const showReview = () => { | |
| 1155 | − | setPullsTab("review"); | |
| 1156 | − | document.getElementById("your-pulls")?.scrollIntoView({ behavior: "smooth", block: "start" }); | |
| 1157 | − | }; | |
| 1158 | 995 | const counts = (n: number) => (n > 0 ? <span className="ml-1 tabular-nums text-faint">{n}</span> : null); | |
| 1159 | 996 | ||
| 1160 | 997 | return ( | |
| 1165 | 1002 | {greeting}, {loaded.name} | |
| 1166 | 1003 | </h1> | |
| 1167 | 1004 | <Digest parts={loaded.digest} seenBefore={loaded.seenBefore} /> | |
| 1168 | − | <div className="mt-5"> | |
| 1169 | − | <Composer targets={loaded.targets} canRunAgents={canRunAgents} workspace={workspace} onReview={showReview} /> | |
| 1170 | − | </div> | |
| 1171 | 1005 | </header> | |
| 1172 | 1006 | ||
| 1173 | 1007 | {starting && <GetStarted steps={steps} />} | |
| 1190 | 1024 | {!loaded.runsLoaded && workspace ? ( | |
| 1191 | 1025 | <Unavailable what="Agent runs" /> | |
| 1192 | 1026 | ) : live.length === 0 ? ( | |
| 1193 | − | <Quiet | |
| 1194 | − | action={ | |
| 1195 | − | canRunAgents && loaded.targets.length > 0 ? ( | |
| 1196 | − | <button | |
| 1197 | − | type="button" | |
| 1198 | − | onClick={() => document.getElementById("composer")?.focus()} | |
| 1199 | − | className="inline-flex items-center gap-1.5 rounded-md bg-fg px-3 py-1.5 text-xs font-medium text-bg hover:bg-white" | |
| 1200 | − | > | |
| 1201 | − | <Sparkles size={12} /> Hand something to an agent | |
| 1202 | − | </button> | |
| 1203 | − | ) : null | |
| 1204 | − | } | |
| 1205 | − | > | |
| 1206 | − | No agent is at work right now. Describe a change above and one starts on it in seconds. | |
| 1207 | − | </Quiet> | |
| 1027 | + | <Quiet>No agent is at work right now. Assign an issue to g1t-agent and one starts on it in seconds.</Quiet> | |
| 1208 | 1028 | ) : ( | |
| 1209 | 1029 | <ul className="space-y-3"> | |
| 1210 | 1030 | {live.map((run) => ( |