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.
| Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now. | 1 | import { AtSign, Mail, UserPlus } from "lucide-react"; |
| 2 | import { type ReactNode, useEffect, useId, useRef, useState } from "react"; | |
| 3 | import { useFetcher } from "react-router"; | |
| 4 | ||
| 5 | import { MAX_INVITE_MESSAGE, type PersonMatch } from "@g1t/contracts"; | |
| 6 | ||
| 7 | import { ErrorText, SubmitButton } from "./ui"; | |
| 8 | import { Avatar } from "./ui/avatar"; | |
| 9 | import { Button } from "./ui/button"; | |
| 10 | import { Command, CommandInput, CommandItem, CommandList } from "./ui/command"; | |
| 11 | import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 12 | import { Field, FieldDescription, FieldLabel } from "./ui/field"; | |
| 13 | import { SelectField } from "./ui/select"; | |
| 14 | import { Textarea } from "./ui/textarea"; | |
| 15 | import { UserCard } from "./user-card"; | |
| 16 | import { type InviteResult, moreInvitesMailto, workspaceInviteCopy } from "../lib/invites"; | |
| 17 | import { PEOPLE_SEARCH_PATH, looksLikeEmail, peopleQuery } from "../lib/people-search"; | |
| 18 | ||
| 19 | /** | |
| 20 | * Inviting someone to the workspace, in a dialog opened from Members and | |
| 21 | * from Invitations: who (a username, a name to search by, or an email | |
| 22 | * address), the role they join with, and a note for the email. Posted to | |
| 23 | * `action` as `action=add` (lib/member-invitations.server.ts); on success the | |
| 24 | * dialog closes and `onInvited` is told who, so the page can say so. | |
| 25 | */ | |
| 26 | export function InviteDialog({ | |
| 27 | slug, | |
| 28 | name, | |
| 29 | inviteOnly, | |
| 30 | action, | |
| 31 | onInvited, | |
| 32 | trigger, | |
| 33 | }: { | |
| 34 | slug: string; | |
| 35 | /** The workspace's display name. */ | |
| 36 | name: string; | |
| 37 | /** Whether sign-up takes an invite: then an invitation to a new address costs one. */ | |
| 38 | inviteOnly: boolean; | |
| 39 | /** The page's address, whose action handles `add`. */ | |
| 40 | action: string; | |
| 41 | onInvited?: (who: string) => void; | |
| 42 | /** The button that opens it; the Invite button when absent. */ | |
| 43 | trigger?: ReactNode; | |
| 44 | }) { | |
| 45 | const [open, setOpen] = useState(false); | |
| 46 | const fetcher = useFetcher<InviteResult>(); | |
| 47 | const copy = workspaceInviteCopy(name, inviteOnly); | |
| 48 | const result = fetcher.state === "idle" ? fetcher.data : undefined; | |
| 49 | // Sent: the dialog closes and the page says so. Each opening starts clean. | |
| 50 | const told = useRef<InviteResult | null>(null); | |
| 51 | useEffect(() => { | |
| 52 | if (!result || told.current === result) return; | |
| 53 | told.current = result; | |
| 54 | if ("invited" in result) { | |
| 55 | setOpen(false); | |
| 56 | onInvited?.(result.invited); | |
| 57 | } | |
| 58 | }, [result, onInvited]); | |
| 59 | const error = result && "error" in result ? result : null; | |
| 60 | const messageId = useId(); | |
| 61 | return ( | |
| 62 | <Dialog open={open} onOpenChange={setOpen}> | |
| 63 | <DialogTrigger asChild> | |
| 64 | {trigger ?? ( | |
| 65 | <Button type="button"> | |
| 66 | <UserPlus size={15} /> | |
| 67 | Invite | |
| 68 | </Button> | |
| 69 | )} | |
| 70 | </DialogTrigger> | |
| 71 | <DialogContent> | |
| 72 | {open && ( | |
| 73 | <fetcher.Form method="post" action={action} className="grid gap-5"> | |
| 74 | <input type="hidden" name="action" value="add" /> | |
| 75 | <DialogHeader> | |
| 76 | <DialogTitle>{copy.heading}</DialogTitle> | |
| 77 | <DialogDescription>{copy.hint}</DialogDescription> | |
| 78 | </DialogHeader> | |
| 79 | <WhoField /> | |
| 80 | <Field> | |
| 81 | <FieldLabel htmlFor={`${messageId}-role`}>Role</FieldLabel> | |
| 82 | <SelectField | |
| 83 | id={`${messageId}-role`} | |
| 84 | name="role" | |
| 85 | defaultValue="member" | |
| 86 | options={[ | |
| 87 | { value: "member", label: "Member", description: `Works in ${name} with the base permission on its repositories.` }, | |
| 88 | { value: "owner", label: "Owner", description: "Everything a member can, and manages the workspace itself." }, | |
| 89 | ]} | |
| 90 | /> | |
| 91 | </Field> | |
| 92 | <Field> | |
| 93 | <FieldLabel htmlFor={messageId}> | |
| 94 | Message <span className="font-normal text-faint">(optional)</span> | |
| 95 | </FieldLabel> | |
| 96 | <Textarea id={messageId} name="message" maxLength={MAX_INVITE_MESSAGE} rows={3} placeholder={`A line for the email: why you'd like them in ${name}.`} /> | |
| 97 | <FieldDescription>Quoted in the invitation email, under your name.</FieldDescription> | |
| 98 | </Field> | |
| 99 | {error && ( | |
| 100 | <div> | |
| 101 | <ErrorText>{error.error}</ErrorText> | |
| 102 | {error.outOfInvites && ( | |
| 103 | <p className="mt-1 text-sm text-muted"> | |
| 104 | <a href={moreInvitesMailto(slug)} className="text-accent underline underline-offset-4"> | |
| 105 | Ask for more invites | |
| 106 | </a>{" "} | |
| 107 | for {slug}. | |
| 108 | </p> | |
| 109 | )} | |
| 110 | </div> | |
| 111 | )} | |
| 112 | <DialogFooter> | |
| 113 | <DialogClose asChild> | |
| 114 | <Button type="button" variant="outline"> | |
| 115 | Cancel | |
| 116 | </Button> | |
| 117 | </DialogClose> | |
| 118 | <SubmitButton fetcher={fetcher} match={{ action: "add" }} pending="Inviting…"> | |
| 119 | <UserPlus size={15} /> | |
| 120 | Invite | |
| 121 | </SubmitButton> | |
| 122 | </DialogFooter> | |
| 123 | </fetcher.Form> | |
| 124 | )} | |
| 125 | </DialogContent> | |
| 126 | </Dialog> | |
| 127 | ); | |
| 128 | } | |
| 129 | ||
| 130 | /** | |
| 131 | * Who to invite: a search box that offers people on g1t by username and | |
| 132 | * name as you type, each with their picture and card, and takes an email | |
| 133 | * address as written. Only what a profile shows is offered, never anyone's | |
| 134 | * address. Posts what is in the box as `member`. | |
| 135 | */ | |
| 136 | function WhoField() { | |
| 137 | const [text, setText] = useState(""); | |
| 138 | const [people, setPeople] = useState<PersonMatch[]>([]); | |
| 139 | const [chosen, setChosen] = useState<string | null>(null); | |
| 140 | const asked = useRef(0); | |
| 141 | const id = useId(); | |
| 142 | ||
| 143 | useEffect(() => { | |
| 144 | const query = chosen === text.trim().replace(/^@+/, "") ? null : peopleQuery(text); | |
| 145 | if (!query) { | |
| 146 | setPeople([]); | |
| 147 | return; | |
| 148 | } | |
| 149 | const ask = ++asked.current; | |
| 150 | const timer = setTimeout(() => { | |
| 151 | fetch(`${PEOPLE_SEARCH_PATH}?q=${encodeURIComponent(query)}`, { headers: { accept: "application/json" } }) | |
| 152 | .then((response) => (response.ok ? (response.json() as Promise<{ people?: PersonMatch[] }>) : { people: [] })) | |
| 153 | .then((found) => { | |
| 154 | if (ask === asked.current) setPeople(found.people ?? []); | |
| 155 | }) | |
| 156 | .catch(() => { | |
| 157 | if (ask === asked.current) setPeople([]); | |
| 158 | }); | |
| 159 | }, 150); | |
| 160 | return () => clearTimeout(timer); | |
| 161 | }, [text, chosen]); | |
| 162 | ||
| 163 | const email = looksLikeEmail(text); | |
| 164 | const listed = people.length > 0 && !email; | |
| 165 | return ( | |
| 166 | <Field> | |
| 167 | <FieldLabel htmlFor={id}>Who</FieldLabel> | |
| 168 | <Command shouldFilter={false} className="rounded-md border border-line bg-bg has-[input:focus-visible]:border-accent-dim has-[input:focus-visible]:ring-2 has-[input:focus-visible]:ring-accent/25"> | |
| 169 | <CommandInput | |
| 170 | id={id} | |
| 171 | name="member" | |
| 172 | required | |
| 173 | maxLength={254} | |
| 174 | autoComplete="off" | |
| 175 | placeholder="username, name or name@example.com" | |
| 176 | value={text} | |
| 177 | onValueChange={(value) => { | |
| 178 | setText(value); | |
| 179 | setChosen(null); | |
| 180 | }} | |
| 181 | className="h-9" | |
| 182 | /> | |
| 183 | {listed && ( | |
| 184 | <CommandList aria-label="People on g1t" className="max-h-56 border-t border-line"> | |
| 185 | {people.map((person) => ( | |
| 186 | <CommandItem | |
| 187 | key={person.username} | |
| 188 | value={person.username} | |
| 189 | onSelect={() => { | |
| 190 | setText(person.username); | |
| 191 | setChosen(person.username); | |
| 192 | setPeople([]); | |
| 193 | }} | |
| 194 | > | |
| 195 | <Avatar name={person.username} image={person.avatar} size={20} /> | |
| 196 | <UserCard username={person.username}> | |
| 197 | <span className="font-mono">{person.username}</span> | |
| 198 | </UserCard> | |
| 199 | {person.name && <span className="min-w-0 truncate text-muted">{person.name}</span>} | |
| 200 | </CommandItem> | |
| 201 | ))} | |
| 202 | </CommandList> | |
| 203 | )} | |
| 204 | </Command> | |
| 205 | <FieldDescription className="flex items-center gap-1.5"> | |
| 206 | {email ? ( | |
| 207 | <> | |
| 208 | <Mail size={12} className="shrink-0" /> | |
| 209 | Invited by email: {text.trim()} gets the invitation, and signs up first if it has no account. | |
| 210 | </> | |
| 211 | ) : chosen ? ( | |
| 212 | <> | |
| 213 | <AtSign size={12} className="shrink-0" /> | |
| 214 | @{chosen} gets the invitation in their notifications and by email. | |
| 215 | </> | |
| 216 | ) : ( | |
| 217 | "People on g1t are offered as you type; an email address is sent as written." | |
| 218 | )} | |
| 219 | </FieldDescription> | |
| 220 | </Field> | |
| 221 | ); | |
| 222 | } |