mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
feat(desktop): reattach popped-out agent activity
Allow detached agent activity windows to hand their session back to the main window through the URL-backed agent session panel state, with a durable localStorage fallback for cross-window delivery.
This commit is contained in:
@@ -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")) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
});
|
||||
const { channelId, pubkey, name, channelName, channelType } =
|
||||
Route.useSearch();
|
||||
validateAgentWindowSearch(search);
|
||||
|
||||
if (!channelId || !pubkey) {
|
||||
return (
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
|
||||
<Outlet />
|
||||
<AgentWindowRouteComponent />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
119
desktop/src/app/useAgentWindowAttachRequests.ts
Normal file
119
desktop/src/app/useAgentWindowAttachRequests.ts
Normal file
@@ -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<unknown>;
|
||||
|
||||
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<unknown>(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?.();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
aria-label="Attach activity to main window"
|
||||
data-testid="agent-window-attach"
|
||||
onClick={() => {
|
||||
void handleAttachToMainWindow();
|
||||
}}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="text-xs">
|
||||
Attach activity to the main window.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</header>
|
||||
|
||||
<div
|
||||
|
||||
@@ -924,6 +924,14 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
onOpenProfilePanel(selectedAgent.pubkey)
|
||||
}
|
||||
onClose={onCloseAgentSession}
|
||||
onOpenWindow={
|
||||
onOpenAgentWindow
|
||||
? () => {
|
||||
onOpenAgentWindow(selectedAgent.pubkey);
|
||||
onCloseAgentSession();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
widthPx={threadPanelWidthPx}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user