diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 4544c48b49..60bc955a1c 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -19,6 +19,7 @@ import { useBackForwardControls } from "@/app/navigation/useBackForwardControls" import { useLiveHomeFeedActions } from "@/app/useLiveHomeFeedActions"; import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts"; import { useSettingsShortcuts } from "@/app/useSettingsShortcuts"; +import { useAgentWindowAttachRequests } from "@/app/useAgentWindowAttachRequests"; import { useThreadActivityFeedItems } from "@/app/useThreadActivityFeedItems"; import { useWebviewZoomShortcuts } from "@/app/useWebviewZoomShortcuts"; import { @@ -535,9 +536,8 @@ export function AppShell() { }, ); - // Prevent webview file:/// navigation on file drop outside the composer. - // Scoped to file drags only (text drag-and-drop into inputs still works). - // Composer's onDrop fires first (React synthetic before window bubble). + useAgentWindowAttachRequests(goChannel); + React.useEffect(() => { function preventNavigation(e: DragEvent) { if (e.dataTransfer?.types.includes("Files")) { diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index 0788bea197..7b47f7515d 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -133,6 +133,7 @@ export function useAppNavigation() { ( channelId: string, options?: { + agentSession?: string; messageId?: string; replace?: boolean; threadRootId?: string | null; @@ -146,10 +147,15 @@ export function useAppNavigation() { }, search: options?.messageId ? { + agentSession: options.agentSession, messageId: options.messageId, threadRootId: options.threadRootId ?? undefined, } - : {}, + : options?.agentSession + ? { + agentSession: options.agentSession, + } + : {}, }, { replace: options?.replace, diff --git a/desktop/src/app/routes.ts b/desktop/src/app/routes.ts index 230efdddd2..5dc4789ae6 100644 --- a/desktop/src/app/routes.ts +++ b/desktop/src/app/routes.ts @@ -2,7 +2,6 @@ import { index, rootRoute, route } from "@tanstack/virtual-file-routes"; export const routes = rootRoute("root.tsx", [ index("index.tsx"), - route("/agent-window", "agent-window.tsx"), route("/agents", "agents.tsx"), route("/pulse", "pulse.tsx"), route("/reminders", "reminders.tsx"), diff --git a/desktop/src/app/routes/agent-window.tsx b/desktop/src/app/routes/agent-window.tsx index 7e090c5f9b..f7c68596e2 100644 --- a/desktop/src/app/routes/agent-window.tsx +++ b/desktop/src/app/routes/agent-window.tsx @@ -1,4 +1,4 @@ -import { createFileRoute } from "@tanstack/react-router"; +import { useRouterState } from "@tanstack/react-router"; import { AgentWindowScreen } from "@/features/agents/ui/AgentWindowScreen"; import type { ChannelType } from "@/shared/api/types"; @@ -32,14 +32,12 @@ function validateAgentWindowSearch( }; } -export const Route = createFileRoute("/agent-window")({ - validateSearch: validateAgentWindowSearch, - component: AgentWindowRouteComponent, -}); - -function AgentWindowRouteComponent() { +export function AgentWindowRouteComponent() { + const search = useRouterState({ + select: (state) => state.location.search as Record, + }); const { channelId, pubkey, name, channelName, channelType } = - Route.useSearch(); + validateAgentWindowSearch(search); if (!channelId || !pubkey) { return ( diff --git a/desktop/src/app/routes/root.tsx b/desktop/src/app/routes/root.tsx index 8f851e128a..e0cae277b6 100644 --- a/desktop/src/app/routes/root.tsx +++ b/desktop/src/app/routes/root.tsx @@ -1,10 +1,7 @@ -import { - Outlet, - createRootRoute, - useRouterState, -} from "@tanstack/react-router"; +import { createRootRoute, useRouterState } from "@tanstack/react-router"; import { AppShell } from "@/app/AppShell"; +import { AgentWindowRouteComponent } from "@/app/routes/agent-window"; /** * Popped-out agent conversation windows load the same frontend at @@ -20,7 +17,7 @@ function RootComponent() { if (pathname.startsWith("/agent-window")) { return (
- +
); } diff --git a/desktop/src/app/useAgentWindowAttachRequests.ts b/desktop/src/app/useAgentWindowAttachRequests.ts new file mode 100644 index 0000000000..3e10c95bea --- /dev/null +++ b/desktop/src/app/useAgentWindowAttachRequests.ts @@ -0,0 +1,119 @@ +import * as React from "react"; +import { listen } from "@tauri-apps/api/event"; + +import { + AGENT_WINDOW_ATTACH_EVENT, + AGENT_WINDOW_ATTACH_STORAGE_KEY, + type AgentWindowAttachRequest, +} from "@/features/agents/lib/openAgentConversationWindow"; +import { revealDesktopAppWindow } from "@/features/notifications/lib/desktop"; + +type GoChannelWithAgentSession = ( + channelId: string, + options?: { agentSession?: string }, +) => Promise; + +function isAgentWindowAttachRequest( + payload: unknown, +): payload is AgentWindowAttachRequest { + return ( + typeof payload === "object" && + payload !== null && + "agentPubkey" in payload && + "channelId" in payload && + typeof payload.agentPubkey === "string" && + payload.agentPubkey.length > 0 && + typeof payload.channelId === "string" && + payload.channelId.length > 0 + ); +} + +function parseAgentWindowAttachRequest( + raw: string | null, +): AgentWindowAttachRequest | null { + if (!raw) { + return null; + } + + try { + const payload = JSON.parse(raw) as unknown; + return isAgentWindowAttachRequest(payload) ? payload : null; + } catch { + return null; + } +} + +export function useAgentWindowAttachRequests( + goChannel: GoChannelWithAgentSession, +) { + const handleAgentWindowAttachRequest = React.useEffectEvent( + async (payload: unknown) => { + if (!isAgentWindowAttachRequest(payload)) { + return; + } + + window.localStorage.removeItem(AGENT_WINDOW_ATTACH_STORAGE_KEY); + await revealDesktopAppWindow(); + await goChannel(payload.channelId, { + agentSession: payload.agentPubkey, + }); + }, + ); + + const consumePendingAgentWindowAttachRequest = React.useEffectEvent(() => { + const payload = parseAgentWindowAttachRequest( + window.localStorage.getItem(AGENT_WINDOW_ATTACH_STORAGE_KEY), + ); + if (!payload) { + return; + } + + void handleAgentWindowAttachRequest(payload); + }); + + React.useEffect(() => { + let isCancelled = false; + let unlisten: (() => void) | null = null; + + consumePendingAgentWindowAttachRequest(); + + function handleStorage(event: StorageEvent) { + if ( + event.storageArea !== window.localStorage || + event.key !== AGENT_WINDOW_ATTACH_STORAGE_KEY + ) { + return; + } + + const payload = parseAgentWindowAttachRequest(event.newValue); + if (payload) { + void handleAgentWindowAttachRequest(payload); + } + } + + function handleFocus() { + consumePendingAgentWindowAttachRequest(); + } + + window.addEventListener("storage", handleStorage); + window.addEventListener("focus", handleFocus); + + void listen(AGENT_WINDOW_ATTACH_EVENT, (event) => { + void handleAgentWindowAttachRequest(event.payload); + }).then((dispose) => { + if (isCancelled) { + dispose(); + return; + } + + unlisten = dispose; + }); + + return () => { + isCancelled = true; + window.removeEventListener("storage", handleStorage); + window.removeEventListener("focus", handleFocus); + unlisten?.(); + }; + }, []); +} diff --git a/desktop/src/features/agents/lib/openAgentConversationWindow.ts b/desktop/src/features/agents/lib/openAgentConversationWindow.ts index 814fa0d6ec..4196e9c2e6 100644 --- a/desktop/src/features/agents/lib/openAgentConversationWindow.ts +++ b/desktop/src/features/agents/lib/openAgentConversationWindow.ts @@ -14,6 +14,14 @@ export type OpenAgentConversationWindowInput = { observerEvents?: readonly ObserverEvent[]; }; +export const AGENT_WINDOW_ATTACH_EVENT = "agent-window-attach-request"; +export const AGENT_WINDOW_ATTACH_STORAGE_KEY = "buzz:agent-window-attach"; + +export type AgentWindowAttachRequest = { + agentPubkey: string; + channelId: string; +}; + const DEFAULT_WIDTH = 460; const DEFAULT_HEIGHT = 680; const MIN_WIDTH = 380; diff --git a/desktop/src/features/agents/ui/AgentWindowScreen.tsx b/desktop/src/features/agents/ui/AgentWindowScreen.tsx index 3e76f5fd06..55a8fc4f3d 100644 --- a/desktop/src/features/agents/ui/AgentWindowScreen.tsx +++ b/desktop/src/features/agents/ui/AgentWindowScreen.tsx @@ -1,5 +1,9 @@ import * as React from "react"; -import { CircleDot, Octagon } from "lucide-react"; +import { isTauri } from "@tauri-apps/api/core"; +import { emit } from "@tauri-apps/api/event"; +import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { Window } from "@tauri-apps/api/window"; +import { CircleDot, Octagon, PanelRightOpen } from "lucide-react"; import { toast } from "sonner"; import { @@ -11,7 +15,12 @@ import { useActiveAgentTurnsBridge, } from "@/features/agents/activeAgentTurnsStore"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; -import { agentConversationSeedStorageKey } from "@/features/agents/lib/openAgentConversationWindow"; +import { + AGENT_WINDOW_ATTACH_EVENT, + AGENT_WINDOW_ATTACH_STORAGE_KEY, + agentConversationSeedStorageKey, + type AgentWindowAttachRequest, +} from "@/features/agents/lib/openAgentConversationWindow"; import { seedAgentObserverEvents, useManagedAgentObserverBridge, @@ -241,6 +250,37 @@ export function AgentWindowScreen({ } } + async function handleAttachToMainWindow() { + if (!isTauri()) { + window.location.hash = `/channels/${encodeURIComponent( + channelId, + )}?agentSession=${encodeURIComponent(agentPubkey)}`; + return; + } + + try { + const payload: AgentWindowAttachRequest = { agentPubkey, channelId }; + window.localStorage.setItem( + AGENT_WINDOW_ATTACH_STORAGE_KEY, + JSON.stringify({ ...payload, writtenAt: Date.now() }), + ); + await emit(AGENT_WINDOW_ATTACH_EVENT, payload); + + const mainWindow = await Window.getByLabel("main"); + await mainWindow?.unminimize().catch(() => {}); + await mainWindow?.show().catch(() => {}); + await mainWindow?.setFocus().catch(() => {}); + + await getCurrentWebviewWindow().close(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to attach this activity to the main window.", + ); + } + } + const isLive = isManagedAgentActive(sessionAgent); return ( @@ -297,6 +337,25 @@ export function AgentWindowScreen({ ) : null} + + + + + + Attach activity to the main window. + +
{ + onOpenAgentWindow(selectedAgent.pubkey); + onCloseAgentSession(); + } + : undefined + } widthPx={threadPanelWidthPx} /> );