From 501985837eadd23948134c0a7f0b4474cc6215f5 Mon Sep 17 00:00:00 2001 From: adam Date: Sun, 26 Jul 2026 00:56:32 +0100 Subject: [PATCH] feat(deep-link): add buzz://install-agent prefill arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new `buzz://install-agent?relay=` deep-link arm that opens Desktop with the create-agent form PREFILLED, so an external service (e.g. Figura) can offer a one-click "install this agent into your community" flow. Today community-add is deep-linkable but agent-install is not. Mirrors the existing community-add prefill machinery exactly: - Rust `parse_install_agent_deep_link` + dedicated `PendingAgentInstallDeepLinks` queue + `take_/acknowledge_pending_agent_install_deep_link` commands, wired into `handle_deep_link_url` and the Tauri invoke handler / managed state. `relay` is required and validated as ws(s) like connect/join/add-community; `npub`/`name`/`system_prompt`/`channel` are optional and mirror `buzz agents draft-create` (channel / display-name / system-prompt). - Frontend `agentInstallPrefill` store + `AgentInstallDeepLinkPayload` type + coalescing drain (`listenForAgentInstallDeepLinks`) that routes the queued intent into the existing create-agent form (`RequestedAgentCreateDialogs`) seeded with display name / system prompt / target channel. Security: the link ONLY prefills the owner's create-agent form. It never auto-admits an agent or bypasses owner review — the owner still reviews and saves the form in Desktop. Stated in code comments and docs (AGENTS.md). Tests: 6 new Rust unit tests (happy path, relay-only, empty-optionals, every relay rejection case, queue FIFO + dedupe). Full deep_link suite 44 passed. Frontend typecheck + biome clean; desktop clippy clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01KpZ2N5oAADahc69R9656pe Signed-off-by: adam --- AGENTS.md | 8 + desktop/src-tauri/src/deep_link.rs | 230 +++++++++++++++++- desktop/src-tauri/src/lib.rs | 8 +- desktop/src/app/App.tsx | 19 ++ .../features/agents/agentInstallPrefill.ts | 55 +++++ .../agents/ui/RequestedAgentCreateDialogs.tsx | 64 ++++- desktop/src/shared/deep-link.ts | 102 ++++++++ desktop/src/testing/e2eBridge.ts | 6 + 8 files changed, 484 insertions(+), 8 deletions(-) create mode 100644 desktop/src/features/agents/agentInstallPrefill.ts diff --git a/AGENTS.md b/AGENTS.md index 3edfb34f60..7fa099eb14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,6 +185,14 @@ Extract `channel` and `id` from the URL query parameters. The optional `thread` parameter (root event ID) can be ignored — `messages thread` resolves the full thread from the event ID alone. +`buzz://install-agent?relay=[&npub=…&name=…&system_prompt=…&channel=]` +opens Desktop with the create-agent form **prefilled** so an external service +(e.g. Figura) can offer a one-click "install this agent into your community" +flow. `relay` is required (validated as ws/wss, like `connect`/`join`); the +agent fields mirror `buzz agents draft-create` (channel / display-name / +system-prompt) and are optional. This only prefills — the owner still reviews +and saves the form; nothing auto-admits an agent or bypasses owner review. + All reads return sig-stripped JSON arrays; all writes return `{event_id, accepted, message}`; creates add the entity ID. Exit codes: 0=ok, 1=input error, 2=network/relay, 3=auth, 4=other, 5=write conflict (NIP-33 LWW). diff --git a/desktop/src-tauri/src/deep_link.rs b/desktop/src-tauri/src/deep_link.rs index ffe951dc36..a7845dbcf2 100644 --- a/desktop/src-tauri/src/deep_link.rs +++ b/desktop/src-tauri/src/deep_link.rs @@ -69,6 +69,96 @@ pub(crate) fn acknowledge_pending_community_deep_link( pending.acknowledge(&id) } +/// Queued `buzz://install-agent?…` intent, mirroring the community queue so an +/// agent-install link opened on a cold launch survives until the create-agent +/// form is mounted and can drain it. The community queue's fixed field set +/// (`relay_url`/`code`/`policy_receipt`/`name`) can't carry an agent's +/// `npub`/`system_prompt`/`channel`, so agent installs get a dedicated queue. +/// +/// Security: this only PREFILLS the owner's create-agent form — it never +/// auto-admits an agent or bypasses owner review. The owner still reviews and +/// saves the form in Desktop. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PendingAgentInstallDeepLink { + id: String, + relay_url: String, + npub: Option, + name: Option, + system_prompt: Option, + channel: Option, +} + +#[derive(Default)] +pub(crate) struct PendingAgentInstallDeepLinks(Mutex>); + +impl PendingAgentInstallDeepLinks { + fn enqueue(&self, pending: PendingAgentInstallDeepLink) { + let mut queue = self + .0 + .lock() + .expect("pending agent-install deep-link queue poisoned"); + if queue.iter().any(|item| { + item.relay_url == pending.relay_url + && item.npub == pending.npub + && item.name == pending.name + && item.system_prompt == pending.system_prompt + && item.channel == pending.channel + }) { + return; + } + queue.push_back(pending); + } + + fn first(&self) -> Option { + self.0 + .lock() + .expect("pending agent-install deep-link queue poisoned") + .front() + .cloned() + } + + fn acknowledge(&self, id: &str) -> bool { + let mut queue = self + .0 + .lock() + .expect("pending agent-install deep-link queue poisoned"); + if queue.front().is_some_and(|item| item.id == id) { + queue.pop_front(); + true + } else { + false + } + } +} + +#[tauri::command] +pub(crate) fn take_pending_agent_install_deep_link( + pending: State<'_, PendingAgentInstallDeepLinks>, +) -> Option { + pending.first() +} + +#[tauri::command] +pub(crate) fn acknowledge_pending_agent_install_deep_link( + id: String, + pending: State<'_, PendingAgentInstallDeepLinks>, +) -> bool { + pending.acknowledge(&id) +} + +fn queue_agent_install_deep_link(app: &tauri::AppHandle, payload: &AgentInstallDeepLinkPayload) { + app.state::() + .enqueue(PendingAgentInstallDeepLink { + id: uuid::Uuid::new_v4().to_string(), + relay_url: payload.relay_url.clone(), + npub: payload.npub.clone(), + name: payload.name.clone(), + system_prompt: payload.system_prompt.clone(), + channel: payload.channel.clone(), + }); +} + fn queue_community_deep_link( app: &tauri::AppHandle, kind: &str, @@ -190,6 +280,34 @@ fn parse_add_community_deep_link(url: &Url) -> Option, + name: Option, + system_prompt: Option, + channel: Option, +} + +fn parse_install_agent_deep_link(url: &Url) -> Option { + Some(AgentInstallDeepLinkPayload { + relay_url: parse_websocket_relay_param(url)?, + npub: optional_non_empty_param(url, "npub"), + name: optional_non_empty_param(url, "name"), + system_prompt: optional_non_empty_param(url, "system_prompt"), + channel: optional_non_empty_param(url, "channel"), + }) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] struct NostrBindDeepLinkPayload { @@ -350,6 +468,27 @@ pub(crate) fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { ); let _ = app.emit("deep-link-add-community", payload); } + Some("install-agent") => { + // `buzz://install-agent?relay=[&npub=][&name=] + // [&system_prompt=][&channel=]` — lets an external service + // (e.g. Figura) offer a one-click "install this agent into your + // community" flow. The params mirror `buzz agents draft-create` + // (channel / display-name / system-prompt) so the opened form + // matches what a draft-create would submit. + // + // Security: this ONLY prefills the owner's create-agent form. It + // never auto-admits an agent or bypasses owner review — the owner + // still reviews and saves the form in Desktop. + let Some(payload) = parse_install_agent_deep_link(&url) else { + eprintln!( + "buzz-desktop: install-agent deep link missing/invalid relay: {url_str}" + ); + return; + }; + activate_main_window(app); + queue_agent_install_deep_link(app, &payload); + let _ = app.emit("deep-link-install-agent", payload); + } Some("message") => { // `buzz://message?channel=&id=[&thread=]` // @@ -389,8 +528,9 @@ mod tests { use url::Url; use super::{ - parse_add_community_deep_link, parse_join_deep_link, parse_message_deep_link, - parse_nostr_bind_deep_link, PendingCommunityDeepLink, PendingCommunityDeepLinks, + parse_add_community_deep_link, parse_install_agent_deep_link, parse_join_deep_link, + parse_message_deep_link, parse_nostr_bind_deep_link, PendingAgentInstallDeepLink, + PendingAgentInstallDeepLinks, PendingCommunityDeepLink, PendingCommunityDeepLinks, }; fn pending(id: &str, relay_url: &str, code: Option<&str>) -> PendingCommunityDeepLink { @@ -708,4 +848,90 @@ mod tests { let payload = parse_nostr_bind_deep_link(&url).unwrap(); assert_eq!(payload.expires_at, "2000-01-01T00:00:00Z"); } + + fn pending_agent_install(id: &str, relay_url: &str) -> PendingAgentInstallDeepLink { + PendingAgentInstallDeepLink { + id: id.to_owned(), + relay_url: relay_url.to_owned(), + npub: None, + name: None, + system_prompt: None, + channel: None, + } + } + + #[test] + fn parse_install_agent_deep_link_extracts_all_params() { + let url = Url::parse( + "buzz://install-agent?relay=wss%3A%2F%2Facme.communities.buzz.xyz&npub=npub1abc&name=Support%20Bot&system_prompt=You%20are%20helpful&channel=550e8400-e29b-41d4-a716-446655440000&ignored=value", + ) + .unwrap(); + let payload = parse_install_agent_deep_link(&url).unwrap(); + assert_eq!(payload.relay_url, "wss://acme.communities.buzz.xyz"); + assert_eq!(payload.npub.as_deref(), Some("npub1abc")); + assert_eq!(payload.name.as_deref(), Some("Support Bot")); + assert_eq!(payload.system_prompt.as_deref(), Some("You are helpful")); + assert_eq!( + payload.channel.as_deref(), + Some("550e8400-e29b-41d4-a716-446655440000") + ); + } + + #[test] + fn parse_install_agent_deep_link_accepts_relay_only() { + let url = Url::parse("buzz://install-agent?relay=wss%3A%2F%2Facme.example").unwrap(); + let payload = parse_install_agent_deep_link(&url).unwrap(); + assert_eq!(payload.relay_url, "wss://acme.example"); + assert!(payload.npub.is_none()); + assert!(payload.name.is_none()); + assert!(payload.system_prompt.is_none()); + assert!(payload.channel.is_none()); + } + + #[test] + fn parse_install_agent_deep_link_treats_empty_optionals_as_absent() { + let url = Url::parse( + "buzz://install-agent?relay=wss%3A%2F%2Facme.example&npub=&name=&system_prompt=&channel=", + ) + .unwrap(); + let payload = parse_install_agent_deep_link(&url).unwrap(); + assert!(payload.npub.is_none()); + assert!(payload.name.is_none()); + assert!(payload.system_prompt.is_none()); + assert!(payload.channel.is_none()); + } + + #[test] + fn parse_install_agent_deep_link_rejects_invalid_relays() { + for raw in [ + "buzz://install-agent", + "buzz://install-agent?relay=", + "buzz://install-agent?relay=not-a-url", + "buzz://install-agent?relay=https%3A%2F%2Facme.example", + "buzz://install-agent?relay=wss%3A%2F%2F", + "buzz://install-agent?name=Support%20Bot&channel=abc", + ] { + assert!(parse_install_agent_deep_link(&Url::parse(raw).unwrap()).is_none()); + } + } + + #[test] + fn pending_agent_install_links_are_fifo_and_acknowledged_in_order() { + let queue = PendingAgentInstallDeepLinks::default(); + queue.enqueue(pending_agent_install("first", "wss://one.example")); + queue.enqueue(pending_agent_install("second", "wss://two.example")); + assert_eq!(queue.first().unwrap().id, "first"); + assert!(!queue.acknowledge("second")); + assert!(queue.acknowledge("first")); + assert_eq!(queue.first().unwrap().id, "second"); + } + + #[test] + fn pending_agent_install_links_dedupe_exact_intents() { + let queue = PendingAgentInstallDeepLinks::default(); + queue.enqueue(pending_agent_install("first", "wss://one.example")); + queue.enqueue(pending_agent_install("duplicate", "wss://one.example")); + assert!(queue.acknowledge("first")); + assert!(queue.first().is_none()); + } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index c5b71987ce..762b35ec19 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -33,8 +33,9 @@ use app_state::{build_app_state, resolve_persisted_identity, AppState}; use builderlab::*; use commands::*; use deep_link::{ - acknowledge_pending_community_deep_link, handle_deep_link_url, - take_pending_community_deep_link, PendingCommunityDeepLinks, + acknowledge_pending_agent_install_deep_link, acknowledge_pending_community_deep_link, + handle_deep_link_url, take_pending_agent_install_deep_link, take_pending_community_deep_link, + PendingAgentInstallDeepLinks, PendingCommunityDeepLinks, }; use huddle::audio_output::{ get_audio_output_device, list_audio_output_devices, set_audio_output_device, @@ -353,6 +354,7 @@ pub fn run() { .manage(build_app_state()) .manage(ClipboardState::new()) .manage(PendingCommunityDeepLinks::default()) + .manage(PendingAgentInstallDeepLinks::default()) .manage(BuilderlabSession::default()) .manage(BuilderlabLogin::default()) .manage(commands::pairing::PairingHandle::new()) @@ -647,6 +649,8 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ take_pending_community_deep_link, acknowledge_pending_community_deep_link, + take_pending_agent_install_deep_link, + acknowledge_pending_agent_install_deep_link, start_builderlab_login, cancel_builderlab_login, get_builderlab_auth, diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 9561b2138c..569cf2b067 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -60,8 +60,13 @@ import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri"; import { getProfile } from "@/shared/api/tauriProfiles"; import { type AddCommunityDeepLinkPayload, + listenForAgentInstallDeepLinks, listenForDeepLinks, } from "@/shared/deep-link"; +import { + onAgentInstallPrefillAvailable, + requestAgentInstallPrefill, +} from "@/features/agents/agentInstallPrefill"; import { cn } from "@/shared/lib/cn"; import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark"; import { FlappingBee } from "@/shared/ui/buzz-logo/FlappingBee"; @@ -634,6 +639,20 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { }; }, [communityOnboarding.start, openAddCommunity]); + // `buzz://install-agent?…` prefills the owner's create-agent form. It only + // prefills — the owner still reviews and saves; nothing auto-admits an agent. + // The intent is queued Rust-side and drained into the create surface once it + // mounts, so a link opened on a cold launch survives until it can be shown. + useEffect(() => { + const unlisten = listenForAgentInstallDeepLinks({ + openInstallAgent: requestAgentInstallPrefill, + onInstallAgentAvailable: onAgentInstallPrefillAvailable, + }); + return () => { + void unlisten.then((fn) => fn()); + }; + }, []); + if (machine.stage === "reset-failed") return ; if (machine.stage === "keyring-locked") return ; if (machine.stage === "relaunch-required") return ; diff --git a/desktop/src/features/agents/agentInstallPrefill.ts b/desktop/src/features/agents/agentInstallPrefill.ts new file mode 100644 index 0000000000..36755b8e52 --- /dev/null +++ b/desktop/src/features/agents/agentInstallPrefill.ts @@ -0,0 +1,55 @@ +import * as React from "react"; + +import type { AgentInstallDeepLinkPayload } from "@/shared/deep-link"; + +/** + * Prefill bridge for `buzz://install-agent?…` deep links, mirroring + * `addCommunityPrefill`. The Rust handler queues the intent; the drain routes + * it here, and the create-agent surface (`RequestedAgentCreateDialogs`) reads + * the current request and opens its form PREFILLED. + * + * Security: this only prefills the owner's create-agent form — it never + * auto-admits an agent or bypasses owner review. The owner still reviews and + * saves the form in Desktop. + */ +export type AgentInstallPrefillRequest = AgentInstallDeepLinkPayload & { + requestId: string; +}; + +let currentRequest: AgentInstallPrefillRequest | null = null; +const listeners = new Set<() => void>(); +const availableListeners = new Set<() => void>(); + +export function requestAgentInstallPrefill( + request: AgentInstallPrefillRequest, +): boolean { + if (currentRequest) return false; + currentRequest = request; + for (const listener of listeners) listener(); + return true; +} + +export function clearAgentInstallPrefill(requestId: string): void { + if (!currentRequest || currentRequest.requestId !== requestId) return; + currentRequest = null; + for (const listener of listeners) listener(); + for (const listener of availableListeners) listener(); +} + +export function onAgentInstallPrefillAvailable( + listener: () => void, +): () => void { + availableListeners.add(listener); + return () => availableListeners.delete(listener); +} + +export function useAgentInstallPrefill(): AgentInstallPrefillRequest | null { + return React.useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + () => currentRequest, + () => null, + ); +} diff --git a/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx b/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx index ee800b5cb5..3f44509d1b 100644 --- a/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx +++ b/desktop/src/features/agents/ui/RequestedAgentCreateDialogs.tsx @@ -5,7 +5,14 @@ import { subscribeOpenCreateAgent, type OpenCreateAgentOptions, } from "@/features/agents/openCreateAgentEvent"; +import { + clearAgentInstallPrefill, + useAgentInstallPrefill, +} from "@/features/agents/agentInstallPrefill"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import type { CreatePersonaInput } from "@/shared/api/types"; import { AgentDialog } from "./AgentDialog"; +import { createPersonaDialogState } from "./personaDialogState"; import { SecretRevealDialog } from "./SecretRevealDialog"; import { usePersonaActions } from "./usePersonaActions"; @@ -16,10 +23,17 @@ export function RequestedAgentCreateDialogs() { id: string; name: string; } | null>(null); + const [initialValues, setInitialValues] = + React.useState(null); + const [installRequestId, setInstallRequestId] = React.useState( + null, + ); const [isOpen, setIsOpen] = React.useState(false); const openCreate = React.useEffectEvent((options: OpenCreateAgentOptions) => { personas.prepareCreate(); + setInitialValues(null); + setInstallRequestId(null); setTargetChannel( options.channelId && options.channelName ? { id: options.channelId, name: options.channelName } @@ -34,6 +48,50 @@ export function RequestedAgentCreateDialogs() { return subscribeOpenCreateAgent(openCreate); }, []); + // `buzz://install-agent?…` prefill: open the create form seeded with the + // agent's identity (display name / system prompt) so an external service can + // offer a one-click install. This ONLY prefills — the owner still reviews and + // saves the form here; nothing auto-admits the agent. + const installPrefill = useAgentInstallPrefill(); + const channels = useChannelsQuery({ enabled: installPrefill != null }); + const openInstall = React.useEffectEvent(() => { + if (!installPrefill) return; + personas.prepareCreate(); + const base = createPersonaDialogState().initialValues; + setInitialValues({ + ...base, + displayName: installPrefill.name ?? base.displayName, + systemPrompt: installPrefill.systemPrompt ?? base.systemPrompt, + }); + const channel = installPrefill.channel + ? (channels.data?.find((c) => c.id === installPrefill.channel) ?? { + id: installPrefill.channel, + name: installPrefill.channel, + }) + : null; + setTargetChannel(channel ? { id: channel.id, name: channel.name } : null); + setInstallRequestId(installPrefill.requestId); + setIsOpen(true); + }); + + React.useEffect(() => { + if (installPrefill) openInstall(); + // `channels` is read via the useEffectEvent above (latest cache), not a + // reactive dep: re-firing on channel load would reset an in-progress form. + // The channels query shares its cache with the sidebar, so the name is + // normally already present; a not-yet-loaded channel falls back to its id. + }, [installPrefill]); + + const handleClose = () => { + setIsOpen(false); + setTargetChannel(null); + setInitialValues(null); + if (installRequestId) { + clearAgentInstallPrefill(installRequestId); + setInstallRequestId(null); + } + }; + return ( <> {isOpen ? ( @@ -43,13 +101,11 @@ export function RequestedAgentCreateDialogs() { ? personas.createPersonaMutation.error : null } + initialValues={initialValues} isDefinitionPending={personas.isPending} mode="definition" onOpenChange={(open) => { - if (!open) { - setIsOpen(false); - setTargetChannel(null); - } + if (!open) handleClose(); }} onSubmitDefinition={(input, intent, backendIntent) => personas.handleSubmit(input, intent, backendIntent, targetChannel) diff --git a/desktop/src/shared/deep-link.ts b/desktop/src/shared/deep-link.ts index c62a8bec3b..bdfca0c6e2 100644 --- a/desktop/src/shared/deep-link.ts +++ b/desktop/src/shared/deep-link.ts @@ -15,6 +15,30 @@ export interface DeepLinkDeps { onAddCommunityAvailable: (listener: () => void) => () => void; } +/** + * Payload emitted by the Rust deep-link handler for `buzz://install-agent?…`. + * Field names match the JSON shape produced in + * `desktop/src-tauri/src/deep_link.rs`. The relay is required; the agent fields + * mirror `buzz agents draft-create` (channel / display-name / system-prompt). + * + * This only PREFILLS the owner's create-agent form — it never auto-admits an + * agent or bypasses owner review. + */ +export type AgentInstallDeepLinkPayload = { + relayUrl: string; + npub?: string; + name?: string; + systemPrompt?: string; + channel?: string; +}; + +export interface AgentInstallDeepLinkDeps { + openInstallAgent: ( + payload: AgentInstallDeepLinkPayload & { requestId: string }, + ) => boolean; + onInstallAgentAvailable: (listener: () => void) => () => void; +} + /** * Payload emitted by the Rust deep-link handler for `buzz://message?…`. * Field names match the JSON shape produced in `desktop/src-tauri/src/lib.rs`. @@ -172,3 +196,81 @@ export function listenForNostrBindDeepLinks( onOpen(event.payload); }); } + +/** Rust-side queued shape for a `buzz://install-agent?…` intent. */ +type PendingAgentInstallDeepLink = { + id: string; + relayUrl: string; + npub: string | null; + name: string | null; + systemPrompt: string | null; + channel: string | null; +}; + +async function drainPendingAgentInstallDeepLinks( + deps: AgentInstallDeepLinkDeps, +) { + const pending = await invoke( + "take_pending_agent_install_deep_link", + ); + if (!pending) return; + const accepted = deps.openInstallAgent({ + requestId: pending.id, + relayUrl: pending.relayUrl, + npub: pending.npub ?? undefined, + name: pending.name ?? undefined, + systemPrompt: pending.systemPrompt ?? undefined, + channel: pending.channel ?? undefined, + }); + // Acknowledge (drop from the Rust queue) only once the form owns the intent. + // If the create surface isn't ready yet, leave it queued; the availability + // listener re-drains when the prefill slot frees up. A single create form is + // shown at a time, so we never drain more than one intent per pass. + if (accepted) { + await invoke("acknowledge_pending_agent_install_deep_link", { + id: pending.id, + }); + } +} + +/** + * Register listeners for `buzz://install-agent?…` deep links. Mirrors + * `listenForDeepLinks`: the Rust side queues the intent (surviving a cold + * launch), and this coalescing drain routes it into the create-agent form + * PREFILLED once that surface can accept it. Owner review is preserved — the + * link never auto-admits an agent. Returns an unlisten function. + */ +export async function listenForAgentInstallDeepLinks( + deps: AgentInstallDeepLinkDeps, +): Promise { + let drainRunning = false; + let drainRequested = false; + const drain = () => { + drainRequested = true; + if (drainRunning) return; + drainRunning = true; + void (async () => { + try { + while (drainRequested) { + drainRequested = false; + await drainPendingAgentInstallDeepLinks(deps); + } + } catch (error: unknown) { + console.warn("Failed to drain pending agent-install deep links", error); + } finally { + drainRunning = false; + if (drainRequested) drain(); + } + })(); + }; + const stopAvailabilityListener = deps.onInstallAgentAvailable(drain); + const unlisten = await listen( + "deep-link-install-agent", + drain, + ); + drain(); + return () => { + stopAvailabilityListener(); + unlisten(); + }; +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index da398ca4ba..4a5850d7f7 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -9998,6 +9998,12 @@ export function maybeInstallE2eTauriMocks() { mockPendingCommunityDeepLinks.splice(index, 1); return true; } + case "take_pending_agent_install_deep_link": + // No e2e fixture seeds agent-install deep links yet; the drain runs at + // app boot, so return an empty queue head rather than throwing. + return null; + case "acknowledge_pending_agent_install_deep_link": + return false; case "get_relay_http_url": return getRelayHttpUrl(activeConfig); case "relay_requires_membership":