From f2875258d3c53fed3ce4a9b94587b550b63e52b6 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Wed, 21 Oct 2026 15:08:41 -0400 Subject: [PATCH 1/9] feat(app): offer Get your team started or Join your team on first run --- apps/app/src/app.tsx | 21 ++ apps/app/src/directory-settings.tsx | 13 +- apps/app/src/team-setup.tsx | 535 ++++++++++++++++++++++++++++ 3 files changed, 561 insertions(+), 8 deletions(-) create mode 100644 apps/app/src/team-setup.tsx diff --git a/apps/app/src/app.tsx b/apps/app/src/app.tsx index 99a5f638..df2ede1c 100644 --- a/apps/app/src/app.tsx +++ b/apps/app/src/app.tsx @@ -55,6 +55,7 @@ import { type AppProject, label, projects, title } from "./projects"; import { type Section, Settings } from "./settings"; import { SetupProject } from "./setup-project"; import { ShareProject, useCanShare } from "./share-project"; +import { TeamSetup } from "./team-setup"; import { UpdateNotice } from "./updates"; import { useWindowStorage } from "./window-storage"; @@ -297,6 +298,9 @@ export function App() { const [left, setLeft] = useLocalStorage("panel:left", true); const [width, setWidth] = useLocalStorage("panel:left:width", 200); const [pins, setPins] = useLocalStorage("ace:pinned-channels", []); + // Offered once on first run; Settings → Team opens it again. + const [offered, setOffered] = useLocalStorage("ace:team-setup", false); + const [teaming, setTeaming] = useState(false); const [collapsed, setCollapsed] = useState>({ pinned: false, mine: false, @@ -316,6 +320,14 @@ export function App() { } return () => window.removeEventListener("ace:updates", open); }, []); + useEffect(() => { + const open = () => { + setSettings(false); + setTeaming(true); + }; + window.addEventListener("ace:team-setup", open); + return () => window.removeEventListener("ace:team-setup", open); + }, []); useEffect(() => { const timer = setInterval(() => setNow(Date.now()), 60_000); @@ -1027,6 +1039,15 @@ export function App() { onClose={() => setSettings(false)} /> )} + {(teaming || (!offered && connected && !current)) && ( + { + setOffered(true); + setTeaming(false); + }} + /> + )} {desktop && window.dispatchEvent(new Event("ace:updates"))} />} diff --git a/apps/app/src/directory-settings.tsx b/apps/app/src/directory-settings.tsx index 0b3aae6d..dd9e792c 100644 --- a/apps/app/src/directory-settings.tsx +++ b/apps/app/src/directory-settings.tsx @@ -7,8 +7,6 @@ import { host } from "./host"; import { useDiagnostics } from "./host-settings"; import { Confirm, Group, Page, Problem, Row, Status, Value } from "./settings-layout"; -const SETUP = "https://github.com/githubnext/ace/blob/main/docs/setup.md#team-directory"; - const secrets = { keychain: "Saved in Keychain", environment: "From the environment", @@ -128,14 +126,13 @@ function TeamDirectory( description={directory.url || ( <> Not joined.{" "} - window.dispatchEvent(new Event("ace:team-setup"))} className="rounded-sm underline outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/30" > - Set up a directory - + Set up your team + )} footer={ diff --git a/apps/app/src/team-setup.tsx b/apps/app/src/team-setup.tsx new file mode 100644 index 00000000..d54db353 --- /dev/null +++ b/apps/app/src/team-setup.tsx @@ -0,0 +1,535 @@ +import { type ReactNode, useId, useState } from "react"; + +import { + Button, + buttonVariants, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Switch, +} from "@ace/ui"; +import { IconArrowUpRight, IconCheck, IconCopy } from "@ace/ui/icons"; + +import { host } from "./host"; +import { useDiagnostics } from "./host-settings"; + +const DOCS = "https://github.com/githubnext/ace/blob/main/docs/setup.md"; +const PROVIDERS = { + cloudflare: { label: "Cloudflare", cli: "wrangler" }, + fly: { label: "Fly.io", cli: "fly" }, +}; + +type Provider = keyof typeof PROVIDERS; +type Step = "fork" | "join" | "directory" | "remote" | "setup" | "invite" | "solo"; +const STEPS: Partial> = { + directory: "Directory", + remote: "Remote channels", + setup: "Set up", + invite: "Invite", +}; + +/** + * First-run setup for the first person on a tailnet, or a way into the team for everyone after. + * Membership stays the tailnet's: this only decides where the team's channels are listed. + */ +export function TeamSetup({ github, onClose }: { github?: string; onClose: () => void }) { + const [step, setStep] = useState("fork"); + const [starting, setStarting] = useState(true); + const [listed, setListed] = useState<"hosted" | "peers">("hosted"); + const [provider, setProvider] = useState("cloudflare"); + const [hosted, setHosted] = useState(false); + const [joined, setJoined] = useState<{ url: string; secret: string }>(); + + return ( + next || onClose()}> + + {STEPS[step] && } + {step === "fork" && ( + <> + +
+
+ setStarting(true)} + title="Get your team started" + > + You're the first on this tailnet. Pick where the team's channels are listed. You can + change it later. + + setStarting(false)} + title="Join your team" + > + Someone already set Ace up. Use the directory URL and team secret they sent you. + +
+
Just me for now} + next={ + + } + /> + + )} + {step === "join" && ( + <> +
+ setStep("fork")} /> + + )} + {step === "directory" && ( + <> +
+
+ setListed("hosted")} + title={ + <> + Hosted directory Recommended + + } + > + Runs in your own cloud account. +
+ {(Object.keys(PROVIDERS) as Provider[]).map(id => ( + + ))} +
+
+ setListed("peers")} + title="Peer to peer only" + > + Nothing to deploy. Teammates on the tailnet see your channels only while your Mac is + awake, with no shared projects, presence, or remote channels. + +
+
+ + + Host myself + + + } + next={ + + } + /> + + )} + {step === "remote" && ( + <> +
+
+ + + Cloud workspaces Soon + + } + description="Chat and tools run in containers from a fresh clone, billed per container minute." + /> +
+
setStep("directory")}>Back} + next={} + /> + + )} + {step === "setup" && ( + setStep("remote")} + onJoined={(url, secret) => { + setJoined({ url, secret }); + setStep("invite"); + }} + /> + )} + {step === "invite" && joined && ( + <> +
+ +
  • + Download Ace from ace.githubnext.com +
  • +
  • + Get on your tailnet:{" "} + invite them in Tailscale +
  • +
  • + Join with the directory URL and team secret +
    + + +
    +
  • +
    +

    + Send them privately.{" "} + Anyone with both can read every channel's name and summary and post in hosted + channels. +

    +
    Done} /> + + )} + {step === "solo" && ( + <> +
    + +
  • + Download Ace from ace.githubnext.com +
  • +
  • + Get on your tailnet:{" "} + invite them in Tailscale +
  • +
    +
    setStep("directory")}> + Add a directory + + } + next={} + /> + + )} + +
    + ); +} + +function Setup({ provider, hosted, back, onJoined }: { + provider: Provider; + hosted: boolean; + back: () => void; + onJoined: (url: string, secret: string) => void; +}) { + const [team, setTeam] = useState(""); + const name = team.trim().toLowerCase().replace(/[^a-z0-9-]+/g, "-") || ""; + const command = `bun services/deploy.ts ${provider} ${name}${hosted ? " --hosted" : ""}`; + return ( + <> +
    + From an Ace checkout, signed in to{" "} + {PROVIDERS[provider].cli}, run this script. It makes a new team secret and + prints it with the directory's URL. + + } + /> + setTeam(event.target.value)} + /> + + + + ); +} + +/** Today's Settings → Team join: the directory checks the secret before either is saved. */ +function Join( + { onJoined, back }: { onJoined: (url: string, secret: string) => void; back: () => void }, +) { + const id = useId(); + const [url, setUrl] = useState(""); + const [secret, setSecret] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + + async function join() { + setBusy(true); + setError(""); + try { + await host.request({ op: "directory-set", url, secret }); + onJoined(url.trim(), secret.trim()); + } catch (error) { + setError((error as Error).message); + } finally { + setBusy(false); + } + } + + return ( + <> +
    { + event.preventDefault(); + void join(); + }} + > + setUrl(event.target.value)} + /> + setSecret(event.target.value)} + /> + {error &&

    {error}

    } +
    +