From d658c627c8c7cd452403d51c55afbfa66c534c43 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Fri, 23 Oct 2026 08:24:19 -0400 Subject: [PATCH 1/2] fix: update team state live after joining or leaving the directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host now sends its apps a settings frame whenever it joins, changes, or leaves the directory, by any route: the app, another window, `ace directory`, an approved ask, or adopting a peer's. The sidebar card and Settings → Team read the directory again on it, replacing the per-window event that missed the CLI and Ask to join. The asker's "let you in" toast stays until dismissed, since they are often at their teammate's screen during approval. Why: apps/host/src/directory.ts#function configure | every join or leave tells the running host's apps (chose), so their directory views never need a reload --- ...1XBYTBZAFX-a-directory-change-resets-th.md | 6 +++--- ...WE1KVXT9Q1-the-poll-may-slow-only-while.md | 8 ++++---- ...CNZM2E28MS-every-join-or-leave-tells-th.md | 8 ++++++++ ...VB752WDCFF-workers-and-agent-shells-fin.md | 6 +++--- ...970R3AJ1X1-a-loopback-host-must-not-cal.md | 6 +++--- ...3EYAFM3YC4-worker-requests-worker-never.md | 6 +++--- ...XJSWZK588G-every-project-op-that-change.md | 6 +++--- ...3FQD7PF3RP-only-a-command-line-host-pri.md | 6 +++--- ...BQVSA9XQZG-ace-helper-s-tailnet-self-re.md | 5 +++-- apps/app/src/directory-settings.tsx | 8 +++++--- apps/app/src/host-settings.tsx | 4 +++- apps/app/src/onboarding.tsx | 19 +++++++------------ apps/app/src/team-setup.tsx | 4 +++- apps/host/src/directory.ts | 11 ++++++++--- apps/host/src/gateway.ts | 1 + apps/host/src/protocol.ts | 5 ++++- 16 files changed, 64 insertions(+), 45 deletions(-) create mode 100644 .lane/memory/apps/host/src/directory.ts/01M5MBG92EP0H76ECNZM2E28MS-every-join-or-leave-tells-th.md diff --git a/.lane/memory/apps/host/src/directory.ts/01M4YVZAF65FJNKV1XBYTBZAFX-a-directory-change-resets-th.md b/.lane/memory/apps/host/src/directory.ts/01M4YVZAF65FJNKV1XBYTBZAFX-a-directory-change-resets-th.md index 32e2574f..702e25d3 100644 --- a/.lane/memory/apps/host/src/directory.ts/01M4YVZAF65FJNKV1XBYTBZAFX-a-directory-change-resets-th.md +++ b/.lane/memory/apps/host/src/directory.ts/01M4YVZAF65FJNKV1XBYTBZAFX-a-directory-change-resets-th.md @@ -4,9 +4,9 @@ anchor: function configure created: 2026-10-15T04:08:53Z norm: '1' sig: f3e1bf286b59976d -body_hash: 6b5edb5d1c039626 -raw_hash: 5154b99e7146ea7b -vouched: 2026-10-22T14:33:26Z +body_hash: 8ac9e99449a990ef +raw_hash: 5b6fd56a9863703b +vouched: 2026-10-23T12:24:44Z lines: 46-54 --- diff --git a/.lane/memory/apps/host/src/directory.ts/01M4YWKAGD3FTMWYWE1KVXT9Q1-the-poll-may-slow-only-while.md b/.lane/memory/apps/host/src/directory.ts/01M4YWKAGD3FTMWYWE1KVXT9Q1-the-poll-may-slow-only-while.md index c9120bd5..c611e3aa 100644 --- a/.lane/memory/apps/host/src/directory.ts/01M4YWKAGD3FTMWYWE1KVXT9Q1-the-poll-may-slow-only-while.md +++ b/.lane/memory/apps/host/src/directory.ts/01M4YWKAGD3FTMWYWE1KVXT9Q1-the-poll-may-slow-only-while.md @@ -3,10 +3,10 @@ id: 01M4YWKAGD3FTMWYWE1KVXT9Q1 anchor: function watch created: 2026-10-15T04:19:48Z norm: '1' -sig: 01895e7ff9a47d37 -body_hash: fe03a4474929e917 -raw_hash: c6d495788240ddb4 -vouched: 2026-10-21T21:55:17Z +sig: 576e815f30f4d9cc +body_hash: c1335e94c837e607 +raw_hash: a182e4b2f0856f99 +vouched: 2026-10-23T12:24:51Z lines: 89-149 --- diff --git a/.lane/memory/apps/host/src/directory.ts/01M5MBG92EP0H76ECNZM2E28MS-every-join-or-leave-tells-th.md b/.lane/memory/apps/host/src/directory.ts/01M5MBG92EP0H76ECNZM2E28MS-every-join-or-leave-tells-th.md new file mode 100644 index 00000000..591781f9 --- /dev/null +++ b/.lane/memory/apps/host/src/directory.ts/01M5MBG92EP0H76ECNZM2E28MS-every-join-or-leave-tells-th.md @@ -0,0 +1,8 @@ +--- +id: 01M5MBG92EP0H76ECNZM2E28MS +anchor: function configure +created: 2026-10-23T12:24:20Z +norm: '1' +--- + +every join or leave tells the running host's apps (chose), so their directory views never need a reload diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4YT9Z8YJ2ZWZCVB752WDCFF-workers-and-agent-shells-fin.md b/.lane/memory/apps/host/src/gateway.ts/01M4YT9Z8YJ2ZWZCVB752WDCFF-workers-and-agent-shells-fin.md index 9c27ae90..7f0965e7 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M4YT9Z8YJ2ZWZCVB752WDCFF-workers-and-agent-shells-fin.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M4YT9Z8YJ2ZWZCVB752WDCFF-workers-and-agent-shells-fin.md @@ -4,9 +4,9 @@ anchor: function serve created: 2026-10-15T03:39:45Z norm: '1' sig: 171797818ef4d836 -body_hash: f0a0ef2c205726cc -raw_hash: a98db292f7a9e13d -vouched: 2026-10-22T21:42:37Z +body_hash: bbdacdd140d7e7dd +raw_hash: a8ce4182bc2233cf +vouched: 2026-10-23T12:24:45Z lines: 668-751 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M4ZH1NMBK54TCB970R3AJ1X1-a-loopback-host-must-not-cal.md b/.lane/memory/apps/host/src/gateway.ts/01M4ZH1NMBK54TCB970R3AJ1X1-a-loopback-host-must-not-cal.md index 4d43e9c9..59d1c983 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M4ZH1NMBK54TCB970R3AJ1X1-a-loopback-host-must-not-cal.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M4ZH1NMBK54TCB970R3AJ1X1-a-loopback-host-must-not-cal.md @@ -4,9 +4,9 @@ anchor: function serve created: 2026-10-15T10:17:10Z norm: '1' sig: 171797818ef4d836 -body_hash: f0a0ef2c205726cc -raw_hash: a98db292f7a9e13d -vouched: 2026-10-22T21:42:37Z +body_hash: bbdacdd140d7e7dd +raw_hash: a8ce4182bc2233cf +vouched: 2026-10-23T12:24:46Z lines: 830-948 supersedes: 01M4Z2R1HEVDKEA8VEJ7XRJQ8F --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md b/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md index 0d2795bf..69b2d085 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5D4BXMERGZ1EZ3EYAFM3YC4-worker-requests-worker-never.md @@ -4,9 +4,9 @@ anchor: '@file' created: 2026-10-20T17:04:56Z norm: '1' sig: 1acdc0f6a0895ac5 -body_hash: d40dd86c1ca9274b -raw_hash: b0a5d34f855ac98b -vouched: 2026-10-22T22:21:16Z +body_hash: 07a0161028eb0d88 +raw_hash: 893db37db5fb95f0 +vouched: 2026-10-23T12:24:46Z lines: 1-1153 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md b/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md index 5757e5ef..8d2f846e 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5HYM54QCSF4ESXJSWZK588G-every-project-op-that-change.md @@ -4,9 +4,9 @@ anchor: '@file' created: 2026-10-22T14:00:47Z norm: '1' sig: 1acdc0f6a0895ac5 -body_hash: d40dd86c1ca9274b -raw_hash: b0a5d34f855ac98b -vouched: 2026-10-22T22:21:16Z +body_hash: 07a0161028eb0d88 +raw_hash: 893db37db5fb95f0 +vouched: 2026-10-23T12:24:47Z lines: 1-1309 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5JQ79TQ8E8R693FQD7PF3RP-only-a-command-line-host-pri.md b/.lane/memory/apps/host/src/gateway.ts/01M5JQ79TQ8E8R693FQD7PF3RP-only-a-command-line-host-pri.md index 4d238023..b4e83300 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5JQ79TQ8E8R693FQD7PF3RP-only-a-command-line-host-pri.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5JQ79TQ8E8R693FQD7PF3RP-only-a-command-line-host-pri.md @@ -4,9 +4,9 @@ anchor: function serve created: 2026-10-22T21:10:40Z norm: '1' sig: 171797818ef4d836 -body_hash: f0a0ef2c205726cc -raw_hash: a98db292f7a9e13d -vouched: 2026-10-22T21:42:37Z +body_hash: bbdacdd140d7e7dd +raw_hash: a8ce4182bc2233cf +vouched: 2026-10-23T12:24:49Z lines: 1191-1341 --- diff --git a/.lane/memory/apps/host/src/gateway.ts/01M5JS1HEWX8RK77BQVSA9XQZG-ace-helper-s-tailnet-self-re.md b/.lane/memory/apps/host/src/gateway.ts/01M5JS1HEWX8RK77BQVSA9XQZG-ace-helper-s-tailnet-self-re.md index 838821cb..06cf8d22 100644 --- a/.lane/memory/apps/host/src/gateway.ts/01M5JS1HEWX8RK77BQVSA9XQZG-ace-helper-s-tailnet-self-re.md +++ b/.lane/memory/apps/host/src/gateway.ts/01M5JS1HEWX8RK77BQVSA9XQZG-ace-helper-s-tailnet-self-re.md @@ -4,8 +4,9 @@ anchor: function serve created: 2026-10-22T21:42:29Z norm: '1' sig: 171797818ef4d836 -body_hash: f0a0ef2c205726cc -raw_hash: a98db292f7a9e13d +body_hash: bbdacdd140d7e7dd +raw_hash: a8ce4182bc2233cf +vouched: 2026-10-23T12:24:50Z lines: 1200-1357 --- diff --git a/apps/app/src/directory-settings.tsx b/apps/app/src/directory-settings.tsx index 9c0f4920..88412612 100644 --- a/apps/app/src/directory-settings.tsx +++ b/apps/app/src/directory-settings.tsx @@ -7,7 +7,7 @@ import { desktop } from "./desktop"; import { host } from "./host"; import { useDiagnostics } from "./host-settings"; import { parse } from "./invite"; -import { CREATE_DIRECTORY, DIRECTORY_CHANGED, People, useRoster } from "./onboarding"; +import { CREATE_DIRECTORY, People, useRoster } from "./onboarding"; import { Confirm, Group, Page, Problem, Row, Status, Value, when } from "./settings-layout"; import { DirectoryHosts, TEAM_SETUP } from "./team-setup"; @@ -233,7 +233,6 @@ function TeamDirectory( } catch (error) { setAction({ row, error: (error as Error).message }); } - window.dispatchEvent(new Event(DIRECTORY_CHANGED)); onChange(); } @@ -259,7 +258,10 @@ function TeamDirectory( id="directory-label" label="Directory" description={directory.serving - ? `Served by this Mac at ${directory.url}` + // `ace directory off` leaves the directory this Mac still serves. + ? directory.url + ? `Served by this Mac at ${directory.url}` + : "Served by this Mac, which isn't in it" : directory.url || ( <> None yet. Teammates see channels only while their Mac is online.{" "} diff --git a/apps/app/src/host-settings.tsx b/apps/app/src/host-settings.tsx index b639547f..7d2ca46d 100644 --- a/apps/app/src/host-settings.tsx +++ b/apps/app/src/host-settings.tsx @@ -14,6 +14,8 @@ import { Confirm, Group, Page, Problem, Row, Status, Value } from "./settings-la /** The host's tool and team checks, re-read while `active` and whenever `refresh` is called. */ export function useDiagnostics(active: boolean) { const status = useSyncExternalStore(host.subscribe, () => host.status); + // Joining or leaving the directory from anywhere, such as the CLI, changes what this shows. + const version = useSyncExternalStore(host.subscribe, () => host.settingsVersion); const [retry, setRetry] = useState(0); /** A refresh can also sync the team directory, so its row shows that attempt rather than a stale one. */ const syncing = useRef(false); @@ -35,7 +37,7 @@ export function useDiagnostics(active: boolean) { return () => { live = false; }; - }, [active, status, retry]); + }, [active, status, version, retry]); // The shell environment, the first directory sync, and a keychain read waiting on a prompt all // land in the background; show their results without a manual refresh. diff --git a/apps/app/src/onboarding.tsx b/apps/app/src/onboarding.tsx index 5fea3ff4..f191e910 100644 --- a/apps/app/src/onboarding.tsx +++ b/apps/app/src/onboarding.tsx @@ -9,21 +9,18 @@ import { Checks, Choice, Copyable, Footer, Header, Link, Pill } from "./team-set const DOWNLOAD = "https://ace.githubnext.com"; const TAILSCALE_USERS = "https://login.tailscale.com/admin/users"; -/** Sent after the directory is created, joined, or left, so every view of it reads it again. */ -export const DIRECTORY_CHANGED = "ace:directory-changed"; /** Opens Create directory from anywhere: the sidebar card, an offline channel, or Settings. */ export const CREATE_DIRECTORY = "ace:create-directory"; -/** The directory as this host last reported it; undefined until the host answers. */ +/** + * The directory as this host last reported it; undefined until the host answers. The host tells + * its apps when it joins or leaves one, whether from this app, another window, the CLI, or an + * approved ask. + */ export function useDirectory(): Diagnostics["directory"] | undefined { const status = useSyncExternalStore(host.subscribe, () => host.status); + const version = useSyncExternalStore(host.subscribe, () => host.settingsVersion); const [directory, setDirectory] = useState(); - const [changed, setChanged] = useState(0); - useEffect(() => { - const bump = () => setChanged(value => value + 1); - window.addEventListener(DIRECTORY_CHANGED, bump); - return () => window.removeEventListener(DIRECTORY_CHANGED, bump); - }, []); useEffect(() => { if (status !== "open") return; let live = true; @@ -34,7 +31,7 @@ export function useDirectory(): Diagnostics["directory"] | undefined { return () => { live = false; }; - }, [status, changed]); + }, [status, version]); return directory; } @@ -264,7 +261,6 @@ export function DirectoryCard( setError(""); try { await host.request({ op: "directory-set", url }); - window.dispatchEvent(new Event(DIRECTORY_CHANGED)); } catch (error) { setError((error as Error).message); } finally { @@ -383,7 +379,6 @@ export function CreateDirectory({ onAdd, onClose }: { onAdd: () => void; onClose setError(""); try { setUrl(await host.request({ op: "directory-serve", serve: true })); - window.dispatchEvent(new Event(DIRECTORY_CHANGED)); setStep("done"); } catch (error) { setError((error as Error).message); diff --git a/apps/app/src/team-setup.tsx b/apps/app/src/team-setup.tsx index 13b87632..931d2daf 100644 --- a/apps/app/src/team-setup.tsx +++ b/apps/app/src/team-setup.tsx @@ -478,9 +478,11 @@ function Nearby({ onJoined, team }: { onJoined: () => void; team?: string }) { setError(""); try { await host.request({ op: "team-ask", host: peer.host }); - // Joining closes Team setup, so the asker may have looked away; say it outside it. + // Joining closes Team setup, and the asker is often at their teammate's screen while they + // approve, so the news waits outside it until dismissed. toast.success(`${who} let you in`, { description: "You're in your team's directory now.", + duration: Infinity, }); onJoined(); } catch (error) { diff --git a/apps/host/src/directory.ts b/apps/host/src/directory.ts index 425d50c3..5daa18cb 100644 --- a/apps/host/src/directory.ts +++ b/apps/host/src/directory.ts @@ -60,7 +60,9 @@ let synced: number | undefined; let error: string | undefined; let failed: number | undefined; /** The running host's watch, so a change made through it takes effect without waiting for a heartbeat. */ -let active: { sync(): Promise; again(): Promise; changed(): void } | undefined; +let active: + | { sync(): Promise; again(): Promise; changed(): void; chose(): void } + | undefined; /** A directory base URL without a trailing slash. */ export function canonical(value: string): string { @@ -86,6 +88,7 @@ export function configure(value: string | undefined): Promise | undefined synced = undefined; error = undefined; failed = undefined; + active?.chose(); active?.changed(); return active?.sync(); } @@ -262,6 +265,8 @@ type Options = { channels(): Promise; /** After each refresh. */ changed(): void; + /** After joining, changing, or leaving the directory, by any route: the app, the CLI, or a peer's hello. */ + chose(): void; /** After each publish; `before` is this process's previous publication, if any. */ published(before: Listing[] | undefined, after: Listing[]): void; /** Every project this host knows has a live lobby pushing its changes. */ @@ -272,7 +277,7 @@ type Options = { * Publish this host's channels, then refresh what the directory knows. Failures keep the last * answer, so an unreachable directory never hides channels. */ -export function watch({ self, channels, changed, published, covered }: Options) { +export function watch({ self, channels, changed, chose, published, covered }: Options) { const controller = new AbortController(); let running: { base: string; done: Promise; again?: Promise } | undefined; let before: Listing[] | undefined; @@ -332,7 +337,7 @@ export function watch({ self, channels, changed, published, covered }: Options) if (!running || running.base !== url()) return join(); return running.again ??= running.done.then(join); }; - active = { sync: join, again: sync, changed }; + active = { sync: join, again: sync, changed, chose }; void join(); const interval = setInterval(() => { // A failed publish keeps retrying every heartbeat; lobbies only carry what was published. diff --git a/apps/host/src/gateway.ts b/apps/host/src/gateway.ts index b6e1f260..fab4e061 100644 --- a/apps/host/src/gateway.ts +++ b/apps/host/src/gateway.ts @@ -1279,6 +1279,7 @@ export async function serve(port: number, loopback = false, script?: string): Pr self: () => ({ name, login: catalog.user, github: github.login(loginChanged), ...address }), channels: local, changed: () => void sharesChanged(), + chose: settingsChanged, published: (before, after) => before && lobby.published(before, after), covered: lobby.isCovered, }); diff --git a/apps/host/src/protocol.ts b/apps/host/src/protocol.ts index 0fb699ad..5410cf93 100644 --- a/apps/host/src/protocol.ts +++ b/apps/host/src/protocol.ts @@ -462,7 +462,10 @@ export type HostFrame = /** What a pending request waits on, such as keychain access; null once it no longer waits. */ | { id: number; waiting: string | null } | { rename: TabRename } - /** Local settings changed; clients reload model availability without receiving secrets. */ + /** + * Local settings or the joined directory changed; clients reload model availability and the + * directory's state without receiving secrets. + */ | { settings: true } /** Opened projects belong to the local owner and are never sent to tailnet peers. */ | { projects: Project[] } From 21873bdb896df8280cc6be9bb731993d188a10e0 Mon Sep 17 00:00:00 2001 From: iamnbutler Date: Fri, 23 Oct 2026 08:25:00 -0400 Subject: [PATCH 2/2] lane: sync fix-team-live memory --- ...01M5MBG92EP0H76ECNZM2E28MS-every-join-or-leave-tells-th.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.lane/memory/apps/host/src/directory.ts/01M5MBG92EP0H76ECNZM2E28MS-every-join-or-leave-tells-th.md b/.lane/memory/apps/host/src/directory.ts/01M5MBG92EP0H76ECNZM2E28MS-every-join-or-leave-tells-th.md index 591781f9..7bf4ae3e 100644 --- a/.lane/memory/apps/host/src/directory.ts/01M5MBG92EP0H76ECNZM2E28MS-every-join-or-leave-tells-th.md +++ b/.lane/memory/apps/host/src/directory.ts/01M5MBG92EP0H76ECNZM2E28MS-every-join-or-leave-tells-th.md @@ -3,6 +3,10 @@ id: 01M5MBG92EP0H76ECNZM2E28MS anchor: function configure created: 2026-10-23T12:24:20Z norm: '1' +sig: f3e1bf286b59976d +body_hash: 8ac9e99449a990ef +raw_hash: 5b6fd56a9863703b +lines: 85-94 --- every join or leave tells the running host's apps (chose), so their directory views never need a reload