From 1d08d628a4c8414c9c647f6ab53ac77082d872db Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Fri, 23 Oct 2026 10:00:23 -0400 Subject: [PATCH 1/3] fix: keep team state current on every Mac: People turns Offline with the sidebar, a served directory's Settings shows joins, Team setup defaults to joining an existing team, and a section's Offline isn't truncated Why: apps/host/src/directory-server.ts#function handle | a host's first publish to the served directory calls joined, so the serving host re-reads its own directory now instead of on its heartbeat Why: apps/app/src/onboarding.tsx#function useRoster | People re-asks the roster on every listings and settings frame, since the host sends one when a peer comes or goes or the directory changes; the 15 s poll only finds new peers --- apps/app/src/app.tsx | 11 +++++------ apps/app/src/onboarding.tsx | 6 +++++- apps/app/src/team-setup.tsx | 19 +++++++++++++------ apps/host/src/directory-server.ts | 11 +++++++++-- apps/host/src/gateway.ts | 10 +++++++++- .../session-sidebar/session-sidebar.tsx | 11 +++++++++-- 6 files changed, 50 insertions(+), 18 deletions(-) diff --git a/apps/app/src/app.tsx b/apps/app/src/app.tsx index a45f2c43..1ed99bea 100644 --- a/apps/app/src/app.tsx +++ b/apps/app/src/app.tsx @@ -240,10 +240,8 @@ function row( const person = (people: People, owner: string) => people[owner] || owner; /** A teammate's section says when none of its channels can open because their hosts are unreachable. */ -function heading(label: string, rows: SidebarRow[]) { - return rows.length > 0 && rows.every((value) => value.connection === "offline") - ? `${label} · Offline` - : label; +function offline(rows: SidebarRow[]): string | undefined { + if (rows.length > 0 && rows.every((value) => value.connection === "offline")) return "Offline"; } /** Pins belong to the person, not the channel, so teammates never see them. */ @@ -317,7 +315,7 @@ function projectGroups( const id: SessionSidebarGroupId = `team:${owner}`; const stored: SessionSidebarGroupId = `team:${owner}:archived`; return [ - { id, label: heading(name, rows), rows, collapsed: folded(collapsed, id) }, + { id, label: name, note: offline(rows), rows, collapsed: folded(collapsed, id) }, { id: stored, label: `${name} · Archived`, @@ -602,7 +600,8 @@ export function App() { const group: SessionSidebarGroupId = `${id}:team:${owner}`; return { id: group, - label: heading(`${name} · ${teammate}`, rows), + label: `${name} · ${teammate}`, + note: offline(rows), rows, sort: "none", collapsed: collapsed[group], diff --git a/apps/app/src/onboarding.tsx b/apps/app/src/onboarding.tsx index 3e8423cc..7f9392f1 100644 --- a/apps/app/src/onboarding.tsx +++ b/apps/app/src/onboarding.tsx @@ -37,6 +37,10 @@ export function useDirectory(): Diagnostics["directory"] | undefined { /** This host and everyone else running Ace on the tailnet, asked again while shown. */ export function useRoster(): Member[] | undefined { + // The host sends listings when a peer comes or goes and settings when the directory changes, so + // People turns Offline with the sidebar and lists a host that joins the directory this one serves. + const channels = useSyncExternalStore(host.subscribe, () => host.channels); + const version = useSyncExternalStore(host.subscribe, () => host.settingsVersion); const [roster, setRoster] = useState(); useEffect(() => { let live = true; @@ -52,7 +56,7 @@ export function useRoster(): Member[] | undefined { live = false; clearInterval(timer); }; - }, []); + }, [channels, version]); return roster; } diff --git a/apps/app/src/team-setup.tsx b/apps/app/src/team-setup.tsx index 931d2daf..70aa6746 100644 --- a/apps/app/src/team-setup.tsx +++ b/apps/app/src/team-setup.tsx @@ -64,7 +64,8 @@ export function TeamSetup({ github, invite, inviting, onClose }: { onClose: () => void; }) { const [step, setStep] = useState(invite ? "join" : inviting ? "invite" : "fork"); - const [starting, setStarting] = useState(true); + // Unset until chosen: a team already on the tailnet makes joining it the default. + const [chosen, setStarting] = useState(); const [listed, setListed] = useState<"hosted" | "peers">("hosted"); const [provider, setProvider] = useState("cloudflare"); const [joined, setJoined] = useState(); @@ -74,6 +75,8 @@ export function TeamSetup({ github, invite, inviting, onClose }: { const directory = checked?.directory.url ? checked.directory : undefined; // Until the host answers, the fork can't tell a first person from one already in a team. const pending = checks.open && !checked && !checks.error; + const peers = useTeamPeers(); + const starting = chosen ?? (!checked?.directory.offered && !peers.length); useEffect(() => { if (!inviting) return; @@ -99,7 +102,7 @@ export function TeamSetup({ github, invite, inviting, onClose }: {
- +