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: de60f6b6b28752c6
raw_hash: a19d4ffff16a8faf
vouched: 2026-10-22T21:17:34Z
body_hash: cbba38a628c7d858
raw_hash: a8b8547e68eff2dc
vouched: 2026-10-22T21:25:00Z
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: de60f6b6b28752c6
raw_hash: a19d4ffff16a8faf
vouched: 2026-10-22T21:17:34Z
body_hash: cbba38a628c7d858
raw_hash: a8b8547e68eff2dc
vouched: 2026-10-22T21:25:00Z
lines: 330-1237
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: function greet
created: 2026-10-22T17:44:22Z
norm: '1'
sig: 583ffcbd3b791ed2
body_hash: f003a7d2404a7483
raw_hash: 18edf8ce741d1310
body_hash: b38e7bb2f4ff3a8c
raw_hash: d2d3c4587a57e408
vouched: 2026-10-22T21:25:00Z
lines: 76-86
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ anchor: function discover
created: 2026-10-22T17:44:31Z
norm: '1'
sig: ab69f97386d0db50
body_hash: 687af47f626b9aa1
raw_hash: ce225aafa3f68775
body_hash: 9b0a75f289475140
raw_hash: 4713ceb74c23d1f3
vouched: 2026-10-22T21:25:00Z
lines: 37-74
supersedes: 01M4YX4FKJR9W30M9Y5HVTKBW4
---
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5JR1DMRY4D6Q3Z6KTX7JCG5
anchor: function discover
created: 2026-10-22T21:24:56Z
norm: '1'
sig: ab69f97386d0db50
body_hash: 9b0a75f289475140
raw_hash: 4713ceb74c23d1f3
lines: 37-75
---

a host with no directory keeps greeting peers so it learns which directory they name; adopt still never joins after a choice
8 changes: 7 additions & 1 deletion apps/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1010,7 +1010,13 @@ export function App() {
onClone={cloneUrl}
/>
)}
{card && <DirectoryCard onDismiss={() => setDismissed(true)} />}
{card && (
<DirectoryCard
offered={directory.offered}
onDismiss={() => setDismissed(true)}
onChange={() => setSettings("team")}
/>
)}
<TeamSetupItem />
</Sidebar>
)}
Expand Down
87 changes: 73 additions & 14 deletions apps/app/src/onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,33 @@ export function Welcome({ github, onClose }: { github?: string; onClose: () => v
);
}

/** Sidebar footer upsell while the team has no directory. */
export function DirectoryCard({ onDismiss }: { onDismiss: () => void }) {
/**
* Sidebar footer while this host has no directory: join the one a teammate's host names, or
* create one when nobody has.
*/
export function DirectoryCard(
{ offered, onDismiss, onChange }: {
offered?: { url: string; host: string };
onDismiss: () => void;
onChange: () => void;
},
) {
const [joining, setJoining] = useState(false);
const [error, setError] = useState("");

async function join(url: string) {
setJoining(true);
setError("");
try {
await host.request({ op: "directory-set", url });
window.dispatchEvent(new Event(DIRECTORY_CHANGED));
} catch (error) {
setError((error as Error).message);
} finally {
setJoining(false);
}
}

return (
<div className="m-2 flex shrink-0 flex-col gap-2 rounded-lg border bg-card p-3">
<div className="flex items-center gap-2">
Expand All @@ -261,17 +286,51 @@ export function DirectoryCard({ onDismiss }: { onDismiss: () => void }) {
<IconX className="size-3.5" aria-hidden />
</button>
</div>
<p className="text-xs leading-5 text-muted-foreground">
Keep channels listed while Macs are offline, and share projects.
</p>
<Button
size="sm"
variant="outline"
className="border"
onClick={() => window.dispatchEvent(new Event(CREATE_DIRECTORY))}
>
Create directory
</Button>
{offered
? (
<>
<p className="text-xs leading-5 text-muted-foreground">
{offered.host}{" "}
serves your team's directory. This Mac isn't in it, so its channels aren't listed
while it's offline.
</p>
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
<div className="flex gap-2">
<Button
size="sm"
className="flex-1"
disabled={joining}
onClick={() => void join(offered.url)}
>
{joining ? "Joining…" : "Join"}
</Button>
<Button
size="sm"
variant="outline"
className="flex-1 border"
disabled={joining}
onClick={onChange}
>
Change…
</Button>
</div>
</>
)
: (
<>
<p className="text-xs leading-5 text-muted-foreground">
Keep channels listed while Macs are offline, and share projects.
</p>
<Button
size="sm"
variant="outline"
className="border"
onClick={() => window.dispatchEvent(new Event(CREATE_DIRECTORY))}
>
Create directory
</Button>
</>
)}
</div>
);
}
Expand All @@ -291,7 +350,7 @@ export function OfflineHost({ name, host: machine }: { name: string; host: strin
{name} runs on that Mac. Its chat opens here when the Mac is back.
</p>
</div>
{directory && !directory.url && (
{directory && !directory.url && !directory.offered && (
<div className="flex max-w-sm flex-col gap-3 rounded-lg border bg-card p-4 text-left">
<div className="flex items-start gap-2.5">
<IconDatabase className="mt-0.5 size-4 shrink-0 text-primary" aria-hidden />
Expand Down
1 change: 1 addition & 0 deletions apps/host/src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export async function diagnostics(sync?: boolean): Promise<Diagnostics> {
serving: served.isServing(),
shares: served.shares(),
hosts: hosts.map(({ address: _, ...host }) => host),
offered: directory.offered(),
},
shell: { ...environment },
web: web.address,
Expand Down
15 changes: 15 additions & 0 deletions apps/host/src/directory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,21 @@ export function isChosen(): boolean {
return existsSync(config) || !!process.env.ACE_DIRECTORY;
}

/** The directory each peer's host names, so a host without one can offer to join it. */
const offers = new Map<string, string>();

export function offer(peer: string, value: string | undefined): void {
if (value) offers.set(peer, value);
else offers.delete(peer);
}

/** A directory a peer's host names while this host has none, as after leaving one. */
export function offered(): { url: string; host: string } | undefined {
if (url()) return;
const [host, value] = offers.entries().next().value ?? [];
return host && value ? { url: value, host } : undefined;
}

/**
* When each URL a peer named last failed without a secret, so discovery doesn't ask every 15
* seconds; its host may only be offline, so it's asked again later.
Expand Down
10 changes: 6 additions & 4 deletions apps/host/src/peers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ async function discover(signal: AbortSignal) {
if (online.get(name)?.address === host.machine.address) continue;
host.client.close();
hosts.delete(name);
directory.offer(name, undefined);
log("info", "peer.gone", { peer: name });
emit();
}
Expand All @@ -65,16 +66,17 @@ async function discover(signal: AbortSignal) {
});
hosts.set(name, host);
}
// A teammate can start a directory after this host first greeted them, and a peer that answered
// before `gh` resolved has a login to give.
const chosen = directory.isChosen();
// A teammate can start a directory after this host first greeted them, a host that left one
// offers a teammate's instead, and a peer that answered before `gh` resolved has a login to give.
const listening = !directory.isChosen() || !directory.url();
for (const [name, { client, machine }] of hosts) {
if (client.status === "open" && (!chosen || unnamed.has(name))) greet(client, machine);
if (client.status === "open" && (listening || unnamed.has(name))) greet(client, machine);
}
}

function greet(client: GatewayClient, machine: Machine) {
client.request<Hello>({ op: "hello" }).then((hello) => {
directory.offer(machine.name, hello.directory);
if (hello.directory) void directory.adopt(hello.directory);
// A freshly started peer answers before `gh` has told it its owner's login, which may have changed.
if (!hello.github) return void unnamed.add(machine.name);
Expand Down
2 changes: 2 additions & 0 deletions apps/host/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export type Diagnostics = {
shares: Share[];
/** Hosts in the directory's last answer, and when each last published to it; older hosts omit it. */
hosts?: DirectoryHost[];
/** A directory a teammate's host names while this host has none, such as after leaving it. */
offered?: { url: string; host: string };
};
/** Whether the helper loaded the login shell's environment; `pending` while it retries. */
shell: { state: "adopted" | "pending" | "failed"; error?: string };
Expand Down