Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge branch 'customize' into main: Customize replaces the Marketplace, the Artifacts trash and spaces changes, the Security-free cleanup, with the desktop app's own title bar kept in the shell. | 1 | /** |
| 2 | * A connector's pieces, shared by Customize → Connectors and a connector's | |
| 3 | * own page: its mark, the menu of what an owner does with it (sign in or | |
| 4 | * out, a key, listing its tools again, removing it), the dialog that takes | |
| 5 | * a key, and the control that says which agents it reaches. Every form | |
| 6 | * posts to the page it is on; lib/customize.server.ts answers them. | |
| 7 | */ | |
| 8 | import { EllipsisVertical, KeyRound, LogIn, LogOut, RefreshCw, Trash2 } from "lucide-react"; | |
| 9 | import { type ReactNode, useEffect, useId, useState } from "react"; | |
| 10 | import { useFetcher } from "react-router"; | |
| 11 | ||
| 12 | import type { WorkspaceConnector } from "@g1t/contracts"; | |
| 13 | import { connectorById } from "@g1t/contracts/connectors"; | |
| 14 | ||
| 15 | import { type ActionResult, Confirm } from "./agents/dialogs"; | |
| 16 | import { ConnectorMark } from "./connectors"; | |
| 17 | import { AvailableTo, ListingMark } from "./customize"; | |
| 18 | import { SubmitButton } from "./ui"; | |
| 19 | import { Button } from "./ui/button"; | |
| 20 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 21 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu"; | |
| 22 | import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field"; | |
| 23 | import { Input } from "./ui/input"; | |
| 24 | import { type Reach, type ReachOption, type ReachTargets } from "../lib/customize"; | |
| 25 | ||
| 26 | /** A connector's mark: the directory entry's when it came from one, its own letters otherwise. */ | |
| 27 | export function WorkspaceConnectorMark({ connector, size = 40 }: { connector: Pick<WorkspaceConnector, "id" | "title" | "from">; size?: number }) { | |
| 28 | const entry = connector.from.kind === "directory" ? connectorById(connector.from.id) : null; | |
| 29 | if (entry) return <ConnectorMark view={{ id: entry.id, name: entry.name, provider: entry.provider ?? null }} size={size} />; | |
| 30 | return <ListingMark id={connector.id} name={connector.title} size={size} />; | |
| 31 | } | |
| 32 | ||
| 33 | /** Where a connector reaches, as the control reads it. */ | |
| 34 | export function connectorReach(connector: Pick<WorkspaceConnector, "grants">): Reach[] { | |
| 35 | return connector.grants.map((grant) => ({ id: grant.id, scope: grant.scope, target: grant.target, label: grant.label })); | |
| 36 | } | |
| 37 | ||
| 38 | /** Which agents a connector is available to, with the control that changes it for owners. */ | |
| 39 | export function ConnectorReach({ connector, targets, size }: { connector: Pick<WorkspaceConnector, "id" | "grants">; targets: ReachTargets | null; size?: "xs" | "sm" }) { | |
| 40 | const fetcher = useFetcher<ActionResult>({ key: `connector-reach-${connector.id}` }); | |
| 41 | const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null; | |
| 42 | const change = (option: ReachOption, checked: boolean) => { | |
| 43 | if (checked) fetcher.submit({ intent: "grant", connector: connector.id, where: option.value }, { method: "post" }); | |
| 44 | else if (option.reach) fetcher.submit({ intent: "revoke", connector: connector.id, grant: option.reach.id }, { method: "post" }); | |
| 45 | }; | |
| 46 | return ( | |
| 47 | <> | |
| 48 | <AvailableTo reach={connectorReach(connector)} targets={targets} busy={fetcher.state !== "idle"} onChange={change} size={size} /> | |
| 49 | {error && <p className="text-xs text-danger">{error}</p>} | |
| 50 | </> | |
| 51 | ); | |
| 52 | } | |
| 53 | ||
| 54 | /** | |
| 55 | * A key for a connector that takes one: the header's name (Authorization, | |
| 56 | * usually) and its value, which is sealed and never shown again. `caveat` | |
| 57 | * is what the directory knows about the key the server wants. With | |
| 58 | * `connect` (a directory entry's id) it connects that entry with the key | |
| 59 | * in one step, instead of changing a connector's. | |
| 60 | */ | |
| 61 | export function SetKeyDialog({ | |
| 62 | connector, | |
| 63 | caveat, | |
| 64 | connect, | |
| 65 | trigger, | |
| 66 | open, | |
| 67 | onOpenChange, | |
| 68 | }: { | |
| 69 | connector: Pick<WorkspaceConnector, "id" | "title" | "header_name">; | |
| 70 | caveat?: string | null; | |
| 71 | connect?: string; | |
| 72 | trigger?: ReactNode; | |
| 73 | open?: boolean; | |
| 74 | onOpenChange?: (open: boolean) => void; | |
| 75 | }) { | |
| 76 | const fetcher = useFetcher<ActionResult>({ key: `connector-key-${connector.id}` }); | |
| 77 | const [own, setOwn] = useState(false); | |
| 78 | const isOpen = open ?? own; | |
| 79 | const setOpen = onOpenChange ?? setOwn; | |
| 80 | const id = useId(); | |
| 81 | useEffect(() => { | |
| 82 | if (fetcher.state === "idle" && fetcher.data?.ok) setOpen(false); | |
| 83 | }, [fetcher.state, fetcher.data, setOpen]); | |
| 84 | const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null; | |
| 85 | const fields = connect ? { intent: "connect", directory: connect } : { intent: "key", connector: connector.id }; | |
| 86 | return ( | |
| 87 | <Dialog open={isOpen} onOpenChange={setOpen}> | |
| 88 | {trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>} | |
| 89 | <DialogContent> | |
| 90 | <DialogHeader> | |
| 91 | <DialogTitle>{connect ? `Connect ${connector.title}` : `A key for ${connector.title}`}</DialogTitle> | |
| 92 | <DialogDescription>Sent with every call as a header. It is sealed when saved and never shown again; paste a new one to replace it.</DialogDescription> | |
| 93 | </DialogHeader> | |
| 94 | {caveat && <p className="text-sm text-fg-soft">{caveat}</p>} | |
| 95 | <fetcher.Form method="post" className="grid gap-5"> | |
| 96 | {Object.entries(fields).map(([name, value]) => ( | |
| 97 | <input key={name} type="hidden" name={name} value={value} /> | |
| 98 | ))} | |
| 99 | <Field> | |
| 100 | <FieldLabel htmlFor={`${id}-header`}>Header</FieldLabel> | |
| 101 | <Input id={`${id}-header`} name="header_name" defaultValue={connector.header_name ?? "Authorization"} required autoComplete="off" spellCheck={false} className="font-mono" /> | |
| 102 | </Field> | |
| 103 | <Field> | |
| 104 | <FieldLabel htmlFor={`${id}-value`}>Value</FieldLabel> | |
| 105 | <Input id={`${id}-value`} name="value" type="password" placeholder="Bearer …" required autoComplete="off" spellCheck={false} className="font-mono" /> | |
| 106 | <FieldDescription>The whole value, scheme included, such as Bearer and the token.</FieldDescription> | |
| 107 | </Field> | |
| 108 | <FieldError>{error}</FieldError> | |
| 109 | <DialogFooter> | |
| 110 | <Button type="button" variant="outline" onClick={() => setOpen(false)}> | |
| 111 | Cancel | |
| 112 | </Button> | |
| 113 | <SubmitButton variant="accent" fetcher={fetcher} match={fields} pending={connect ? "Connecting…" : "Saving and listing tools…"}> | |
| 114 | <KeyRound size={14} /> | |
| 115 | {connect ? "Connect" : "Save key"} | |
| 116 | </SubmitButton> | |
| 117 | </DialogFooter> | |
| 118 | </fetcher.Form> | |
| 119 | </DialogContent> | |
| 120 | </Dialog> | |
| 121 | ); | |
| 122 | } | |
| 123 | ||
| 124 | /** | |
| 125 | * What an owner does with a connector: sign it in (the browser leaves for | |
| 126 | * the server) or out, set a key, list its tools again, or remove it, which | |
| 127 | * is asked twice. | |
| 128 | */ | |
| 129 | export function ConnectorMenu({ connector, caveat }: { connector: WorkspaceConnector; caveat?: string | null }) { | |
| 130 | const fetcher = useFetcher<ActionResult>({ key: `connector-menu-${connector.id}` }); | |
| 131 | const [key, setKey] = useState(false); | |
| 132 | const post = (intent: string) => fetcher.submit({ intent, connector: connector.id }, { method: "post" }); | |
| 133 | const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null; | |
| 134 | return ( | |
| 135 | <> | |
| 136 | <DropdownMenu> | |
| 137 | <DropdownMenuTrigger asChild> | |
| 138 | <Button type="button" variant="ghost" size="icon-sm" aria-label={`What to do with ${connector.title}`} disabled={fetcher.state !== "idle"}> | |
| 139 | <EllipsisVertical size={15} /> | |
| 140 | </Button> | |
| 141 | </DropdownMenuTrigger> | |
| 142 | <DropdownMenuContent align="end"> | |
| 143 | {connector.auth === "oauth" && ( | |
| 144 | <DropdownMenuItem onSelect={() => post(connector.state === "needs_sign_in" ? "sign_in" : "sign_out")}> | |
| 145 | {connector.state === "needs_sign_in" ? <LogIn /> : <LogOut />} | |
| 146 | {connector.state === "needs_sign_in" ? "Sign in" : "Sign out"} | |
| 147 | </DropdownMenuItem> | |
| 148 | )} | |
| 149 | {connector.auth === "oauth" && connector.state !== "needs_sign_in" && ( | |
| 150 | <DropdownMenuItem onSelect={() => post("sign_in")}> | |
| 151 | <LogIn /> | |
| 152 | Sign in again | |
| 153 | </DropdownMenuItem> | |
| 154 | )} | |
| 155 | {connector.auth === "header" && ( | |
| 156 | <DropdownMenuItem onSelect={() => setKey(true)}> | |
| 157 | <KeyRound /> | |
| 158 | {connector.state === "needs_key" ? "Set the key" : "Replace the key"} | |
| 159 | </DropdownMenuItem> | |
| 160 | )} | |
| 161 | <DropdownMenuItem onSelect={() => post("refresh")}> | |
| 162 | <RefreshCw /> | |
| 163 | List tools again | |
| 164 | </DropdownMenuItem> | |
| 165 | <DropdownMenuSeparator /> | |
| 166 | <Confirm | |
| 167 | title={`Remove ${connector.title}?`} | |
| 168 | confirm="Remove connector" | |
| 169 | fields={{ intent: "remove", connector: connector.id }} | |
| 170 | fetcherKey={`connector-remove-${connector.id}`} | |
| 171 | trigger={ | |
| 172 | <DropdownMenuItem onSelect={(event) => event.preventDefault()} className="text-danger data-highlighted:text-danger [&_svg]:text-danger"> | |
| 173 | <Trash2 /> | |
| 174 | Remove | |
| 175 | </DropdownMenuItem> | |
| 176 | } | |
| 177 | > | |
| 178 | Its tools stop being offered to every agent it reaches, at once, and its sign-in or key is forgotten. | |
| 179 | </Confirm> | |
| 180 | </DropdownMenuContent> | |
| 181 | </DropdownMenu> | |
| 182 | {connector.auth === "header" && <SetKeyDialog connector={connector} caveat={caveat} open={key} onOpenChange={setKey} />} | |
| 183 | {error && <p className="text-xs text-danger">{error}</p>} | |
| 184 | </> | |
| 185 | ); | |
| 186 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.