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:
Thomas Petersen
2026-06-23 14:17:11 -04:00
parent fe0eea7e53
commit 76f4671903
9 changed files with 215 additions and 21 deletions

View File

@@ -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")) {

View File

@@ -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,

View File

@@ -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"),

View File

@@ -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 (

View File

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

View 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?.();
};
}, []);
}

View File

@@ -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;

View File

@@ -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

View File

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