Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function App
created: 2026-10-15T03:38:04Z
norm: '1'
sig: 063d0f00fea02511
body_hash: 0c4a35fb396f0dd1
raw_hash: 95e503cbbd9a6cab
vouched: 2026-10-23T12:46:13Z
body_hash: fe9a115489fd2e3a
raw_hash: 4c1a930753d49cc2
vouched: 2026-10-23T14:00:31Z
lines: 235-824
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function App
created: 2026-10-22T20:29:16Z
norm: '1'
sig: 063d0f00fea02511
body_hash: 0c4a35fb396f0dd1
raw_hash: 95e503cbbd9a6cab
vouched: 2026-10-23T12:46:13Z
body_hash: fe9a115489fd2e3a
raw_hash: 4c1a930753d49cc2
vouched: 2026-10-23T14:00:32Z
lines: 330-1237
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
id: 01M5MH0DNYMV7X11XCSTT06FY3
anchor: function offline
created: 2026-10-23T14:00:32Z
norm: '1'
sig: b254d5cd06084c74
body_hash: 2e2346b1929d9895
raw_hash: 185a27b0644079d6
lines: 243-245
supersedes: 01M4YXNCW4KGWAFS79MPNY1WCR
---

a section reads Offline only when every row is offline; hosted rows never are, so a section with one stays unmarked. Offline is the group's note, which the sidebar never truncates with the label
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5MH04Z2H3DMN17PJ11TSE77
anchor: function useRoster
created: 2026-10-23T14:00:23Z
norm: '1'
sig: 8b4626d24a0f073f
body_hash: c5f2b6c96f923a02
raw_hash: ccf5b36e7fabf6a1
lines: 39-61
---

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5MH04X7KJRTPMZRD3355B0H
anchor: function handle
created: 2026-10-23T14:00:23Z
norm: '1'
sig: 4bdf740420c97daf
body_hash: 4d527d67d6667ace
raw_hash: 69fe0886bd43b8aa
lines: 106-145
---

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
id: 01M5MH0DD8WZ8X1APBFA87YYPN
anchor: function handle
created: 2026-10-23T14:00:32Z
norm: '1'
sig: 4bdf740420c97daf
body_hash: 4d527d67d6667ace
raw_hash: 69fe0886bd43b8aa
lines: 106-145
supersedes: 01M5GQBZBTZSAMHT4E82KNJ0KV
---

a host publishes only under its whois machine name and login; shares only as the caller's login; a host's first publish calls joined so the serving host re-reads its directory now
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-20T17:04:56Z
norm: '1'
sig: 1acdc0f6a0895ac5
body_hash: 05e5d9affea62a1b
raw_hash: b285921bed152d03
vouched: 2026-10-23T12:46:13Z
body_hash: c240b7c2f39b2623
raw_hash: 3915b3b7c7b5bb0f
vouched: 2026-10-23T14:00:32Z
lines: 1-1153
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: '@file'
created: 2026-10-22T14:00:47Z
norm: '1'
sig: 1acdc0f6a0895ac5
body_hash: 05e5d9affea62a1b
raw_hash: b285921bed152d03
vouched: 2026-10-23T12:46:13Z
body_hash: c240b7c2f39b2623
raw_hash: 3915b3b7c7b5bb0f
vouched: 2026-10-23T14:00:32Z
lines: 1-1309
---

Expand Down
11 changes: 5 additions & 6 deletions apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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],
Expand Down
6 changes: 5 additions & 1 deletion apps/app/src/onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Member[]>();
useEffect(() => {
let live = true;
Expand All @@ -52,7 +56,7 @@ export function useRoster(): Member[] | undefined {
live = false;
clearInterval(timer);
};
}, []);
}, [channels, version]);
return roster;
}

Expand Down
19 changes: 13 additions & 6 deletions apps/app/src/team-setup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ export function TeamSetup({ github, invite, inviting, onClose }: {
onClose: () => void;
}) {
const [step, setStep] = useState<Step>(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<boolean>();
const [listed, setListed] = useState<"hosted" | "peers">("hosted");
const [provider, setProvider] = useState<Provider>("cloudflare");
const [joined, setJoined] = useState<Team>();
Expand All @@ -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;
Expand All @@ -99,7 +102,7 @@ export function TeamSetup({ github, invite, inviting, onClose }: {
<div className="flex flex-col divide-y divide-border/60 rounded-md border border-border/70 px-3">
<DirectoryHosts directory={directory} self={checked?.tailscale.name} />
</div>
<Nearby onJoined={onClose} team={new URL(directory.url!).host} />
<Nearby peers={peers} onJoined={onClose} team={new URL(directory.url!).host} />
<Footer
back={
<Button variant="ghost" onClick={() => setStep("directory")}>
Expand All @@ -118,7 +121,7 @@ export function TeamSetup({ github, invite, inviting, onClose }: {
title="Set up Ace for your team"
description="Everyone on your tailnet is your team. Start with how they'll find each other's channels."
/>
<Nearby onJoined={onClose} />
<Nearby peers={peers} onJoined={onClose} />
<div role="radiogroup" className="flex flex-col gap-2">
<Choice
selected={starting}
Expand Down Expand Up @@ -153,7 +156,7 @@ export function TeamSetup({ github, invite, inviting, onClose }: {
? "Check the directory before you join: it lists your channels for everyone who has this link."
: "Ask a teammate on your tailnet to let you in, or enter their directory URL. A deployed directory also needs the team secret, which its invite link carries."}
/>
{!invite && <Nearby onJoined={onClose} />}
{!invite && <Nearby peers={peers} onJoined={onClose} />}
<Join initial={invite} onJoined={onClose} back={() => setStep("fork")} />
</>
)}
Expand Down Expand Up @@ -464,9 +467,13 @@ function useTeamPeers(): TeamPeer[] {
const CLOCK = new Intl.DateTimeFormat(undefined, { timeStyle: "short" });

/** Ask a teammate's host to let this one in; their owner approves in their Ace. */
function Nearby({ onJoined, team }: { onJoined: () => void; team?: string }) {
function Nearby({ peers: all, onJoined, team }: {
peers: TeamPeer[];
onJoined: () => void;
team?: string;
}) {
// A teammate already in this host's directory has no other team to let it into.
const peers = useTeamPeers().filter((peer) => peer.team !== team);
const peers = all.filter((peer) => peer.team !== team);
// By host: the peer list is read again every few seconds, which replaces its objects.
const [asking, setAsking] = useState<{ host: string; who: string; lapses: number }>();
const [error, setError] = useState("");
Expand Down
11 changes: 9 additions & 2 deletions apps/host/src/directory-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,13 @@ export function shares(): Share[] {
return open().query<Share, []>('SELECT project, person, shared_by AS "by", at FROM shares').all();
}

/** Requests under `/directory`, with `path` the rest. */
export async function handle(path: string, request: Request, caller: Caller): Promise<Response> {
/** Requests under `/directory`, with `path` the rest; `joined` runs when a host publishes for the first time. */
export async function handle(
path: string,
request: Request,
caller: Caller,
joined: () => void,
): Promise<Response> {
if (!isServing()) return new Response("This host serves no directory", { status: 404 });
const sql = open();
if (path === "/" && request.method === "GET") return Response.json(read(sql));
Expand All @@ -114,7 +119,9 @@ export async function handle(path: string, request: Request, caller: Caller): Pr
if (host !== caller.host || publication.login !== caller.login) {
return new Response("Hosts publish only as themselves", { status: 403 });
}
const known = sql.query("SELECT 1 FROM hosts WHERE name = ?").get(host);
publish(sql, host, publication);
if (!known) joined();
return new Response(null, { status: 204 });
}
if (path === "/shares" && request.method === "POST") {
Expand Down
10 changes: 9 additions & 1 deletion apps/host/src/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,14 @@ function asksChanged(): void {
for (const socket of sockets) if (socket.data.local) socket.send(frame);
}

/**
* A host joined the directory this one serves. This host reads it only on its heartbeat, so it
* reads it now for its apps' People and In the directory.
*/
function directoryJoined(): void {
void directory.refresh().then(settingsChanged);
}

function settingsChanged(): void {
for (const socket of sockets) {
if (!socket.data.peer) socket.send(JSON.stringify({ settings: true } satisfies HostFrame));
Expand Down Expand Up @@ -1145,7 +1153,7 @@ async function directoryRequest(
return new Response("Forbidden", { status: 403 });
}
const project = served.lobbyOf(path);
if (project === undefined) return served.handle(path, request, caller);
if (project === undefined) return served.handle(path, request, caller, directoryJoined);
if (!served.isServing()) return new Response("This host serves no directory", { status: 404 });
const data = {
...client(caller.login, true, false, caller.host),
Expand Down
11 changes: 9 additions & 2 deletions packages/ui/src/components/session-sidebar/session-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export type SessionSidebarGroupId =
export type SessionSidebarGroup = {
id: SessionSidebarGroupId;
label: string;
/** Follows the label and is never truncated with it, such as Offline. */
note?: string;
rows: SidebarRow[];
/** Preserve caller ordering for ranked or hierarchical groups. */
sort?: "creation" | "none";
Expand Down Expand Up @@ -388,7 +390,10 @@ function GroupHeaderContent({ group, collapsed }: {
className="size-3.5 shrink-0"
/>
)}
<span className="min-w-0 truncate">{group.label}</span>
<span className="flex min-w-0">
<span className="truncate">{group.label}</span>
{group.note && <span className="shrink-0 whitespace-pre">{` · ${group.note}`}</span>}
</span>
<motion.span
aria-hidden
initial={false}
Expand Down Expand Up @@ -638,7 +643,9 @@ function Group({
>
<button
type="button"
aria-label={`${collapsed ? "Expand" : "Collapse"} ${group.label}`}
aria-label={`${collapsed ? "Expand" : "Collapse"} ${group.label}${
group.note ? `, ${group.note}` : ""
}`}
aria-expanded={!collapsed}
aria-controls={content}
onClick={() => onToggleGroup?.(group.id)}
Expand Down