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.
| Artifacts is a mode in the rail, the shell and the phone's tab bar in place of Docs: its home lists everything you can open by day or as cards under All, Yours and Shared with you, with search, filters and tiles to start a doc (slides, designs and dashboards say they are coming soon), and its sidebar holds favorites, spaces, Private, Shared, projects' docs and the trash. | 1 | /** |
| The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were. | 2 | * Share an artifact (docs.g1t.sh/guides/artifacts/, "Sharing"): invite people, |
| Artifacts is a mode in the rail, the shell and the phone's tab bar in place of Docs: its home lists everything you can open by day or as cards under All, Yours and Shared with you, with search, filters and tiles to start a doc (slides, designs and dashboards say they are coming soon), and its sidebar holds favorites, spaces, Private, Shared, projects' docs and the trash. | 3 | * agents and teams with a role, see everyone who has access and where it |
| 4 | * comes from, choose whether it follows its space or parent or only | |
| 5 | * people invited, open it to the workspace or to anyone in it with the | |
| 6 | * link, and say how agents may change it. People who can't share see it | |
| 7 | * read-only. | |
| 8 | */ | |
| 9 | import { DOC_AGENT_MODE_LABELS, DOC_ROLE_LABELS, FOLIO_GENERAL_ROLES, type DocRole, type Folio, type FolioAccessChange, type FolioAccessList, type FolioGeneralAccess, type FolioGeneralRole } from "@g1t/contracts"; | |
| 10 | import { Check, Globe, Link2, Lock, Users } from "lucide-react"; | |
| 11 | import { type ReactNode, useEffect, useMemo, useState } from "react"; | |
| 12 | import { Link } from "react-router"; | |
| 13 | ||
| 14 | import { spacePath } from "../../lib/folios"; | |
| 15 | import { Button, ErrorText } from "../ui"; | |
| 16 | import { Combobox } from "../ui/combobox"; | |
| 17 | import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog"; | |
| 18 | import { Hint } from "../ui/hint"; | |
| 19 | import { SelectField } from "../ui/select"; | |
| 20 | import { Skeleton } from "../ui/skeleton"; | |
| 21 | import { foliosQuery, foliosRequest, useFoliosData } from "./actions"; | |
| 22 | import { Face } from "./parts"; | |
| 23 | ||
| 24 | const ROLES: DocRole[] = ["view", "comment", "edit", "manage"]; | |
| 25 | ||
| 26 | type Candidate = { value: string; label: string; kind: "user" | "agent" | "team"; name: string }; | |
| 27 | ||
| 28 | export function ShareDialog({ slug, folio, open, onOpenChange, onChanged }: { slug: string; folio: Pick<Folio, "id" | "title" | "path" | "space" | "parent_id" | "viewer_role">; open: boolean; onOpenChange: (o: boolean) => void; onChanged?: () => void }) { | |
| 29 | const layout = useFoliosData(); | |
| 30 | const [access, setAccess] = useState<FolioAccessList | null>(null); | |
| 31 | const [teams, setTeams] = useState<{ slug: string; name: string }[]>([]); | |
| 32 | const [error, setError] = useState<string | null>(null); | |
| 33 | const [saved, setSaved] = useState(false); | |
| 34 | const [busy, setBusy] = useState(false); | |
| 35 | const [who, setWho] = useState(""); | |
| 36 | const [role, setRole] = useState<DocRole>("edit"); | |
| 37 | const [note, setNote] = useState(""); | |
| 38 | const [copied, setCopied] = useState(false); | |
| 39 | ||
| 40 | useEffect(() => { | |
| 41 | if (!open) return; | |
| 42 | setAccess(null); | |
| 43 | setError(null); | |
| 44 | setSaved(false); | |
| 45 | void foliosQuery<FolioAccessList>(slug, { access: folio.id }).then((r) => (r.ok ? setAccess(r.value) : setError(r.error.message))); | |
| 46 | void foliosQuery<{ slug: string; name: string }[]>(slug, { teams: "1" }).then((r) => r.ok && setTeams(r.value)); | |
| 47 | }, [open, slug, folio.id]); | |
| 48 | ||
| 49 | const change = async (body: FolioAccessChange) => { | |
| 50 | setBusy(true); | |
| 51 | setError(null); | |
| 52 | setSaved(false); | |
| 53 | const done = await foliosRequest<FolioAccessList>(slug, "access", { folio_id: folio.id, access: body }); | |
| 54 | setBusy(false); | |
| 55 | if (!done.ok) { | |
| 56 | setError(done.error.message); | |
| 57 | return false; | |
| 58 | } | |
| 59 | setAccess(done.value); | |
| 60 | setSaved(true); | |
| 61 | onChanged?.(); | |
| 62 | return true; | |
| 63 | }; | |
| 64 | ||
| 65 | const manager = folio.viewer_role === "manage"; | |
| 66 | const canShare = !!access?.can_share; | |
| 67 | const listed = new Set([...(access?.rows ?? []).map((r) => r.principal), access ? `user:${access.owner.id}` : ""]); | |
| 68 | const candidates: Candidate[] = useMemo( | |
| 69 | () => [ | |
| 70 | ...(layout?.mentionables ?? []).map((m): Candidate => (m.kind === "agent" ? { value: `agent:${m.id}`, label: `${m.display_name} (agent)`, kind: "agent", name: m.name } : { value: `username:${m.name}`, label: `${m.display_name} (@${m.name})`, kind: "user", name: m.name })), | |
| 71 | ...teams.map((t): Candidate => ({ value: `team:${t.slug}`, label: `${t.name} (team)`, kind: "team", name: t.slug })), | |
| 72 | ], | |
| 73 | [layout?.mentionables, teams], | |
| 74 | ); | |
| 75 | const usernameOf = new Map((access?.rows ?? []).filter((r) => r.profile.kind === "user").map((r) => [r.profile.name, r.principal])); | |
| 76 | const offered = candidates.filter((c) => !listed.has(c.value) && !(c.kind === "user" && (usernameOf.has(c.name) || access?.owner.name === c.name))); | |
| 77 | ||
| 78 | const invite = async () => { | |
| 79 | if (!who) return; | |
| 80 | const principal = who.startsWith("username:") ? await memberKey(slug, who.slice(9)) : who; | |
| 81 | if (!principal) return setError("That person isn't in the workspace."); | |
| 82 | if (await change({ op: "grant", principal, role, notify: note.trim() || null })) { | |
| 83 | setWho(""); | |
| 84 | setNote(""); | |
| 85 | } | |
| 86 | }; | |
| 87 | ||
| 88 | const root = !folio.parent_id || access?.inherit === false; | |
| 89 | const general: FolioGeneralAccess = access?.general_access ?? "none"; | |
| 90 | const generalRole: FolioGeneralRole = access?.general_role ?? "view"; | |
| 91 | const roleOptions = ROLES.filter((r) => manager || r !== "manage").map((r) => ({ value: r, label: DOC_ROLE_LABELS[r] })); | |
| 92 | ||
| 93 | return ( | |
| 94 | <Dialog open={open} onOpenChange={onOpenChange}> | |
| 95 | <DialogContent className="max-w-xl"> | |
| 96 | <DialogHeader> | |
| 97 | <DialogTitle className="truncate pr-6">Share “{folio.title || "Untitled"}”</DialogTitle> | |
| g1t is described as the product it is in alpha, and its shell takes the new shape: the README, the docs home and docs/PLAN.md label every feature Live, Preview or Coming; a floating dock with the g1t mark, Today, Chat, Notifications, Agents, Code, Artifacts, your pinned apps and the Apps launcher, with People, Workspace and the account menu (now holding help) at its foot; the workspace's logo and switcher heading a flat sidebar that folds away with Ctrl B, or leading the header's breadcrumbs where there is none; the page in a rounded panel with a full-width header; Today as the front page, with first-time acceptance of agents' pull requests, what needs you and what's at stake, spend today and one item to start with, every number from a service; Notifications in place of the Inbox at /notifications; a bottom bar and More sheet on phones; and sign-in, sign-up, two-factor, reset, invites and choosing a workspace on standalone pages with no app around them. | 98 | <DialogDescription>{canShare ? "People you add get a notification with a link." : `Only people with full access can change who can open it. Ask ${access?.owner.display_name ?? "its owner"} to share it.`}</DialogDescription> |
| Artifacts is a mode in the rail, the shell and the phone's tab bar in place of Docs: its home lists everything you can open by day or as cards under All, Yours and Shared with you, with search, filters and tiles to start a doc (slides, designs and dashboards say they are coming soon), and its sidebar holds favorites, spaces, Private, Shared, projects' docs and the trash. | 99 | </DialogHeader> |
| 100 | ||
| 101 | {canShare && ( | |
| 102 | <div className="space-y-2"> | |
| 103 | <div className="flex flex-wrap gap-2"> | |
| 104 | <Combobox options={offered.map((c) => ({ value: c.value, label: c.label }))} value={who} onValueChange={setWho} placeholder="Add people, agents or teams" searchPlaceholder="Search the workspace" emptyText="Nobody else to add." className="h-9 min-w-0 grow basis-56" /> | |
| 105 | <SelectField aria-label="Their role" value={role} onValueChange={(r) => setRole(r as DocRole)} options={roleOptions} className="h-9 w-36" /> | |
| 106 | </div> | |
| 107 | {who && ( | |
| 108 | <div className="flex gap-2"> | |
| 109 | <input aria-label="Message" placeholder="Add a message (optional)" value={note} onChange={(e) => setNote(e.target.value)} maxLength={2000} className="h-9 min-w-0 grow rounded-md border border-line bg-bg px-3 text-sm outline-none focus:border-accent/60" /> | |
| 110 | <Button type="button" variant="accent" disabled={busy} onClick={invite}> | |
| 111 | Invite | |
| 112 | </Button> | |
| 113 | </div> | |
| 114 | )} | |
| 115 | </div> | |
| 116 | )} | |
| 117 | ||
| 118 | <section className="mt-2"> | |
| 119 | <h3 className="mb-1.5 flex items-center gap-2 text-xs font-medium text-faint"> | |
| 120 | Who has access | |
| 121 | {saved && ( | |
| 122 | <span className="inline-flex items-center gap-1 text-success"> | |
| 123 | <Check size={12} /> Updated | |
| 124 | </span> | |
| 125 | )} | |
| 126 | </h3> | |
| 127 | {!access ? ( | |
| 128 | <div className="space-y-2 py-1" aria-busy="true"> | |
| 129 | <Skeleton className="h-6 w-56" /> | |
| 130 | <Skeleton className="h-6 w-44" /> | |
| 131 | </div> | |
| 132 | ) : ( | |
| 133 | <ul className="max-h-64 space-y-0.5 overflow-y-auto [scrollbar-width:thin]"> | |
| 134 | <Row face={<Face who={access.owner} size={26} />} name={access.owner.display_name} sub={`@${access.owner.name}`} end={<span className="text-xs text-faint">Owner</span>} /> | |
| 135 | {access.rows.map((r) => { | |
| 136 | const face = r.profile.kind === "team" ? <TeamFace /> : <Face who={r.profile} size={26} />; | |
| 137 | const sub = r.profile.kind === "team" ? "Team" : r.profile.kind === "agent" ? "Agent" : `@${r.profile.name}`; | |
| 138 | if (r.source.kind === "grant") { | |
| 139 | const editable = canShare && (manager || r.role !== "manage"); | |
| 140 | return ( | |
| 141 | <Row | |
| 142 | key={r.principal} | |
| 143 | face={face} | |
| 144 | name={r.profile.display_name} | |
| 145 | sub={sub} | |
| 146 | end={ | |
| 147 | editable ? ( | |
| 148 | <SelectField | |
| 149 | aria-label={`${r.profile.display_name}'s access`} | |
| 150 | value={r.role} | |
| 151 | onValueChange={(v) => (v === "remove" ? change({ op: "revoke", principal: r.principal }) : change({ op: "grant", principal: r.principal, role: v as DocRole }))} | |
| 152 | options={[...roleOptions, { value: "remove", label: "Remove access" }]} | |
| 153 | className="h-8 w-36" | |
| 154 | /> | |
| 155 | ) : ( | |
| 156 | <span className="text-xs text-muted">{DOC_ROLE_LABELS[r.role]}</span> | |
| 157 | ) | |
| 158 | } | |
| 159 | /> | |
| 160 | ); | |
| 161 | } | |
| 162 | const from = r.source; | |
| 163 | return ( | |
| 164 | <Row | |
| 165 | key={r.principal} | |
| 166 | face={face} | |
| 167 | name={r.profile.display_name} | |
| 168 | sub={ | |
| 169 | <> | |
| 170 | From{" "} | |
| 171 | <Link to={from.kind === "folio" ? from.path : `/${slug}/-/artifacts`} onClick={() => onOpenChange(false)} className="text-muted hover:text-fg hover:underline"> | |
| 172 | {from.kind === "folio" ? from.title : from.kind === "space" ? from.name : "its owner"} | |
| 173 | </Link> | |
| 174 | </> | |
| 175 | } | |
| 176 | end={<span className="text-xs text-muted">{DOC_ROLE_LABELS[r.role]}</span>} | |
| 177 | /> | |
| 178 | ); | |
| 179 | })} | |
| 180 | </ul> | |
| 181 | )} | |
| 182 | </section> | |
| 183 | ||
| 184 | {access && ( | |
| 185 | <section className="mt-2 space-y-3 border-t border-line pt-4"> | |
| 186 | <h3 className="text-xs font-medium text-faint">General access</h3> | |
| 187 | {(folio.space || folio.parent_id) && ( | |
| 188 | <div className="flex items-start gap-3"> | |
| 189 | <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-raised text-muted">{access.inherit ? <Users size={15} /> : <Lock size={15} />}</span> | |
| 190 | <div className="min-w-0 grow"> | |
| 191 | <SelectField | |
| 192 | aria-label="Follows its space or parent" | |
| 193 | disabled={!manager || busy} | |
| 194 | value={access.inherit ? "inherit" : "restrict"} | |
| 195 | onValueChange={(v) => change({ op: "inherit", inherit: v === "inherit" })} | |
| 196 | options={[ | |
| 197 | { value: "inherit", label: access.inherited_from ? `Everyone in ${access.inherited_from.name}` : folio.space ? `Everyone in ${folio.space.name}` : "Follows the doc it's in" }, | |
| 198 | { value: "restrict", label: "Only people invited" }, | |
| 199 | ]} | |
| 200 | className="h-8 w-full max-w-xs" | |
| 201 | /> | |
| 202 | <p className="mt-1 text-xs text-faint"> | |
| 203 | {access.inherit ? "Whoever can open " : "Access stops here: whoever can open "} | |
| 204 | {folio.parent_id ? "the doc it's in" : folio.space ? <Link to={spacePath(slug, folio.space.slug)} className="hover:text-fg hover:underline">{folio.space.name}</Link> : "its space"} | |
| 205 | {access.inherit ? " can open this, with the same role." : " can't open this unless invited."} | |
| 206 | </p> | |
| 207 | </div> | |
| 208 | </div> | |
| 209 | )} | |
| 210 | {root ? ( | |
| 211 | <div className="flex items-start gap-3"> | |
| 212 | <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full bg-raised text-muted">{general === "workspace" ? <Globe size={15} /> : general === "link" ? <Link2 size={15} /> : <Lock size={15} />}</span> | |
| 213 | <div className="flex min-w-0 grow flex-wrap items-center gap-2"> | |
| 214 | <SelectField | |
| 215 | aria-label="General access" | |
| 216 | disabled={!manager || busy} | |
| 217 | value={general} | |
| 218 | onValueChange={(v) => change(v === "none" ? { op: "general", access: "none", role: null } : { op: "general", access: v as FolioGeneralAccess, role: generalRole })} | |
| 219 | options={[ | |
| 220 | { value: "none", label: "Restricted" }, | |
| 221 | { value: "workspace", label: `Everyone in ${layout?.slug ?? "the workspace"}` }, | |
| 222 | { value: "link", label: "Anyone in the workspace with the link" }, | |
| 223 | ]} | |
| 224 | className="h-8 min-w-0 grow basis-56" | |
| 225 | /> | |
| 226 | {general !== "none" && ( | |
| 227 | <SelectField | |
| 228 | aria-label="Their role" | |
| 229 | disabled={!manager || busy} | |
| 230 | value={generalRole} | |
| 231 | onValueChange={(v) => change({ op: "general", access: general, role: v as FolioGeneralRole })} | |
| 232 | options={FOLIO_GENERAL_ROLES.map((r) => ({ value: r, label: DOC_ROLE_LABELS[r] }))} | |
| 233 | className="h-8 w-36" | |
| 234 | /> | |
| 235 | )} | |
| 236 | <p className="w-full text-xs text-faint"> | |
| 237 | {general === "none" ? "Only the people above can open it." : general === "workspace" ? "Every member can find and open it." : "Members who open the link can. It isn't listed or searchable until they do."} | |
| 238 | </p> | |
| 239 | </div> | |
| 240 | </div> | |
| 241 | ) : ( | |
| 242 | <p className="text-xs text-faint">It follows the doc it's in. Change who else can open it there, or choose “Only people invited”.</p> | |
| 243 | )} | |
| 244 | <div className="flex items-center gap-3"> | |
| 245 | <span className="flex size-8 shrink-0 items-center justify-center rounded-full bg-raised text-faint"> | |
| 246 | <Globe size={15} /> | |
| 247 | </span> | |
| 248 | <Hint label="Links for people outside the workspace aren't available yet."> | |
| 249 | <span className="text-sm text-faint" tabIndex={0}> | |
| 250 | Public link: off for this workspace | |
| 251 | </span> | |
| 252 | </Hint> | |
| 253 | </div> | |
| 254 | <div className="flex flex-wrap items-center gap-2"> | |
| 255 | <span className="text-sm text-muted">Agents may</span> | |
| 256 | <SelectField | |
| 257 | aria-label="How agents change it" | |
| 258 | disabled={!manager || busy} | |
| 259 | value={access.agent_mode ?? "default"} | |
| 260 | onValueChange={(v) => change({ op: "agent_mode", agent_mode: v === "default" ? null : (v as "suggest" | "edit") })} | |
| 261 | options={[ | |
| 262 | { value: "default", label: folio.space ? `As ${folio.space.name} says` : "Suggest changes (default)" }, | |
| 263 | { value: "suggest", label: DOC_AGENT_MODE_LABELS.suggest }, | |
| 264 | { value: "edit", label: DOC_AGENT_MODE_LABELS.edit }, | |
| 265 | ]} | |
| 266 | className="h-8 w-52" | |
| 267 | /> | |
| 268 | </div> | |
| 269 | </section> | |
| 270 | )} | |
| 271 | ||
| 272 | {error && <ErrorText>{error}</ErrorText>} | |
| 273 | ||
| 274 | <div className="mt-2 flex items-center justify-between gap-2 border-t border-line pt-4"> | |
| 275 | <Button | |
| 276 | type="button" | |
| 277 | variant="quiet" | |
| 278 | onClick={async () => { | |
| 279 | try { | |
| 280 | await navigator.clipboard.writeText(`${window.location.origin}${folio.path}`); | |
| 281 | setCopied(true); | |
| 282 | setTimeout(() => setCopied(false), 1600); | |
| 283 | } catch { | |
| 284 | setError("Your browser didn't allow copying."); | |
| 285 | } | |
| 286 | }} | |
| 287 | > | |
| 288 | {copied ? <Check size={14} /> : <Link2 size={14} />} {copied ? "Copied" : "Copy link"} | |
| 289 | </Button> | |
| 290 | <Button type="button" variant="accent" onClick={() => onOpenChange(false)}> | |
| 291 | Done | |
| 292 | </Button> | |
| 293 | </div> | |
| 294 | </DialogContent> | |
| 295 | </Dialog> | |
| 296 | ); | |
| 297 | } | |
| 298 | ||
| 299 | function Row({ face, name, sub, end }: { face: ReactNode; name: string; sub: ReactNode; end: ReactNode }) { | |
| 300 | return ( | |
| 301 | <li className="flex items-center gap-3 rounded-md px-1 py-1.5"> | |
| 302 | {face} | |
| 303 | <span className="min-w-0 grow"> | |
| 304 | <span className="block truncate text-sm text-fg">{name}</span> | |
| 305 | <span className="block truncate text-xs text-faint">{sub}</span> | |
| 306 | </span> | |
| 307 | {end} | |
| 308 | </li> | |
| 309 | ); | |
| 310 | } | |
| 311 | ||
| 312 | function TeamFace() { | |
| 313 | return ( | |
| 314 | <span className="flex size-[26px] shrink-0 items-center justify-center rounded-full bg-raised text-muted"> | |
| 315 | <Users size={13} /> | |
| 316 | </span> | |
| 317 | ); | |
| 318 | } | |
| 319 | ||
| 320 | /** A person's member key (`user:<id>`), from their username, as the site resolves it. */ | |
| 321 | export async function memberKey(slug: string, username: string): Promise<string | null> { | |
| 322 | const found = await foliosQuery<string>(slug, { person: username }); | |
| 323 | return found.ok ? found.value : null; | |
| 324 | } |