diff --git a/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md b/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md index f3e95a44..89df60b2 100644 --- a/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md +++ b/.lane/memory/apps/app/src/app.tsx/01M4YT6XB7NQ89E6F4P375REE7-all-projects-and-project-vie.md @@ -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 --- diff --git a/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md b/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md index 44f83b6f..4d860aff 100644 --- a/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md +++ b/.lane/memory/apps/app/src/app.tsx/01M5JMVG9G87F31FGVV571Y6BJ-the-project-list-is-built-on.md @@ -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 --- diff --git a/.lane/memory/apps/host/src/peers.ts/01M5JBDJ536ASQ9VKEE9T4WW7T-a-hello-without-github-leave.md b/.lane/memory/apps/host/src/peers.ts/01M5JBDJ536ASQ9VKEE9T4WW7T-a-hello-without-github-leave.md index 1ae1c2bc..07968feb 100644 --- a/.lane/memory/apps/host/src/peers.ts/01M5JBDJ536ASQ9VKEE9T4WW7T-a-hello-without-github-leave.md +++ b/.lane/memory/apps/host/src/peers.ts/01M5JBDJ536ASQ9VKEE9T4WW7T-a-hello-without-github-leave.md @@ -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 --- diff --git a/.lane/memory/apps/host/src/peers.ts/01M5JBDT9ZCB6X9ANFR2K7NJBE-a-peer-s-hello-can-arrive-be.md b/.lane/memory/apps/host/src/peers.ts/01M5JBDT9ZCB6X9ANFR2K7NJBE-a-peer-s-hello-can-arrive-be.md index cc008feb..e37e86d5 100644 --- a/.lane/memory/apps/host/src/peers.ts/01M5JBDT9ZCB6X9ANFR2K7NJBE-a-peer-s-hello-can-arrive-be.md +++ b/.lane/memory/apps/host/src/peers.ts/01M5JBDT9ZCB6X9ANFR2K7NJBE-a-peer-s-hello-can-arrive-be.md @@ -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 --- diff --git a/.lane/memory/apps/host/src/peers.ts/01M5JR1DMRY4D6Q3Z6KTX7JCG5-a-host-with-no-directory-kee.md b/.lane/memory/apps/host/src/peers.ts/01M5JR1DMRY4D6Q3Z6KTX7JCG5-a-host-with-no-directory-kee.md new file mode 100644 index 00000000..9f177b65 --- /dev/null +++ b/.lane/memory/apps/host/src/peers.ts/01M5JR1DMRY4D6Q3Z6KTX7JCG5-a-host-with-no-directory-kee.md @@ -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 diff --git a/apps/app/src/app.tsx b/apps/app/src/app.tsx index be54a558..38a27735 100644 --- a/apps/app/src/app.tsx +++ b/apps/app/src/app.tsx @@ -1010,7 +1010,13 @@ export function App() { onClone={cloneUrl} /> )} - {card && setDismissed(true)} />} + {card && ( + setDismissed(true)} + onChange={() => setSettings("team")} + /> + )} )} diff --git a/apps/app/src/onboarding.tsx b/apps/app/src/onboarding.tsx index 2cfd092d..5fea3ff4 100644 --- a/apps/app/src/onboarding.tsx +++ b/apps/app/src/onboarding.tsx @@ -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 (
@@ -261,17 +286,51 @@ export function DirectoryCard({ onDismiss }: { onDismiss: () => void }) {
-

- Keep channels listed while Macs are offline, and share projects. -

- + {offered + ? ( + <> +

+ {offered.host}{" "} + serves your team's directory. This Mac isn't in it, so its channels aren't listed + while it's offline. +

+ {error &&

{error}

} +
+ + +
+ + ) + : ( + <> +

+ Keep channels listed while Macs are offline, and share projects. +

+ + + )}
); } @@ -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.

- {directory && !directory.url && ( + {directory && !directory.url && !directory.offered && (
diff --git a/apps/host/src/diagnostics.ts b/apps/host/src/diagnostics.ts index 1cab67ff..ac997826 100644 --- a/apps/host/src/diagnostics.ts +++ b/apps/host/src/diagnostics.ts @@ -63,6 +63,7 @@ export async function diagnostics(sync?: boolean): Promise { serving: served.isServing(), shares: served.shares(), hosts: hosts.map(({ address: _, ...host }) => host), + offered: directory.offered(), }, shell: { ...environment }, web: web.address, diff --git a/apps/host/src/directory.ts b/apps/host/src/directory.ts index 100e6f2f..425d50c3 100644 --- a/apps/host/src/directory.ts +++ b/apps/host/src/directory.ts @@ -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(); + +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. diff --git a/apps/host/src/peers.ts b/apps/host/src/peers.ts index 5a80fe4d..3b7096ef 100644 --- a/apps/host/src/peers.ts +++ b/apps/host/src/peers.ts @@ -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(); } @@ -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({ 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); diff --git a/apps/host/src/protocol.ts b/apps/host/src/protocol.ts index cf6ad01b..5d72279b 100644 --- a/apps/host/src/protocol.ts +++ b/apps/host/src/protocol.ts @@ -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 };