diff --git a/desktop/src-tauri/src/agent_activity_window.rs b/desktop/src-tauri/src/agent_activity_window.rs new file mode 100644 index 0000000000..234c52db93 --- /dev/null +++ b/desktop/src-tauri/src/agent_activity_window.rs @@ -0,0 +1,79 @@ +//! Native companion-window lifecycle for an agent activity feed. + +use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; +use uuid::Uuid; + +const PUBKEY_HEX_LENGTH: usize = 64; + +fn normalized_pubkey(pubkey: &str) -> Result { + let normalized = pubkey.trim().to_ascii_lowercase(); + if normalized.len() != PUBKEY_HEX_LENGTH + || !normalized.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err("agent pubkey must be 64 hexadecimal characters".to_string()); + } + Ok(normalized) +} + +fn window_label(channel_id: &Uuid, pubkey: &str) -> String { + format!("agent-activity-{pubkey}-{channel_id}") +} + +/// Open an agent's channel-scoped activity feed without replacing the main +/// window's thread panel. Each agent/channel pair owns one reusable window. +#[tauri::command] +pub fn open_agent_activity_window( + app: tauri::AppHandle, + channel_id: String, + pubkey: String, +) -> Result { + let channel_id = + Uuid::parse_str(channel_id.trim()).map_err(|_| "channel id must be a UUID".to_string())?; + let pubkey = normalized_pubkey(&pubkey)?; + let label = window_label(&channel_id, &pubkey); + + if let Some(window) = app.get_webview_window(&label) { + window.show().map_err(|error| error.to_string())?; + window.set_focus().map_err(|error| error.to_string())?; + return Ok(true); + } + + let route = format!("index.html#/channels/{channel_id}?agentSession={pubkey}"); + WebviewWindowBuilder::new(&app, label, WebviewUrl::App(route.into())) + .title("Agent activity") + .inner_size(560.0, 760.0) + .min_inner_size(420.0, 520.0) + .build() + .map_err(|error| error.to_string())?; + Ok(true) +} + +#[cfg(test)] +mod tests { + use super::{normalized_pubkey, window_label}; + use uuid::Uuid; + + #[test] + fn normalizes_valid_pubkeys() { + let uppercase = "AB".repeat(32); + assert_eq!(normalized_pubkey(&uppercase), Ok("ab".repeat(32))); + } + + #[test] + fn labels_distinguish_pubkeys_with_the_same_prefix() { + let channel_id = Uuid::nil(); + let first = format!("{}{}", "ab".repeat(6), "cd".repeat(26)); + let second = format!("{}{}", "ab".repeat(6), "ef".repeat(26)); + + assert_ne!( + window_label(&channel_id, &first), + window_label(&channel_id, &second) + ); + } + + #[test] + fn rejects_invalid_pubkeys() { + assert!(normalized_pubkey("abc").is_err()); + assert!(normalized_pubkey(&"zz".repeat(32)).is_err()); + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 71a5eb3806..160083adf1 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ #![recursion_limit = "256"] // Deep Tauri command futures exceed the default layout query depth. +mod agent_activity_window; mod app_menu; mod app_state; mod archive; @@ -50,6 +51,7 @@ mod unread_catch_up; mod util; #[cfg(target_os = "linux")] pub mod webkit_rendering; +use agent_activity_window::open_agent_activity_window; use app_state::{build_app_state, resolve_persisted_identity, AppState}; use builderlab::*; #[doc(hidden)] @@ -781,6 +783,7 @@ pub fn run() { get_huddle_state, close_huddle_companion, open_huddle_window, + open_agent_activity_window, push_audio_pcm, reconnect_huddle_audio, start_stt_pipeline, diff --git a/desktop/src/app/AppHuddleShell.tsx b/desktop/src/app/AppHuddleShell.tsx index 3e32697e9e..a28b1d4195 100644 --- a/desktop/src/app/AppHuddleShell.tsx +++ b/desktop/src/app/AppHuddleShell.tsx @@ -12,6 +12,7 @@ type AppHuddleShellProps = { isCompanionOpen: boolean; isDrawerOpen: boolean; isRoom: boolean; + isPassiveWindow?: boolean; onCompanionOpen: () => void; onHuddleStartPendingChange: (pending: boolean) => void; onHuddleStarted: (ephemeralChannelId: string) => void | Promise; @@ -48,6 +49,7 @@ export function AppHuddleShell({ isCompanionOpen, isDrawerOpen, isRoom, + isPassiveWindow = false, onCompanionOpen, onHuddleStartPendingChange, onHuddleStarted, @@ -57,7 +59,7 @@ export function AppHuddleShell({ }: AppHuddleShellProps) { return ( {children} - {isRoom || !isCompanionOpen ? ( + {!isPassiveWindow && (isRoom || !isCompanionOpen) ? (
1; + const isActivityWindow = isAgentActivityWindow(); + const isCompanionWindow = isHuddleRoom || isActivityWindow; + const hasCommunityRail = + communitiesHook.communities.length > 1 && !isCompanionWindow; const addCommunityDialog = useAddCommunityDialogState(); const [isChannelManagementOpen, setIsChannelManagementOpen] = React.useState(false); @@ -691,7 +695,7 @@ export function AppShell() { }); return ( - {!isHuddleRoom ? ( + {!isCompanionWindow ? ( - {hasCommunityRail && !isHuddleRoom ? ( + {hasCommunityRail ? ( - {!settingsOpen && !isHuddleRoom ? ( + {!settingsOpen && !isCompanionWindow ? ( ) : (
- {!isHuddleRoom ? ( + {!isCompanionWindow ? ( - {!isHuddleRoom ? ( + {!isCompanionWindow ? ( { + void openAgentActivityWindow(channelId, pubkey) + .then((openedNativeWindow) => { + if (!openedNativeWindow) { + void goChannel(channelId, { agentSession: pubkey }); + } + }) + .catch((error) => { + console.error("Failed to open agent activity window:", error); + toast.error("Couldn't open the agent activity window."); + }); + }, + [goChannel], + ); + const openAgentActivity = React.useCallback( (pubkey: string, options?: { channelId?: string | null }): boolean => { // An explicit channel target (e.g. clicking a "Working in #channel" @@ -162,22 +167,18 @@ export function useOpenAgentActivity() { toast.warning(INACCESSIBLE_ACTIVITY_MESSAGE); return false; } - void goChannel(options.channelId, { agentSession: pubkey }); + openActivityDestination(pubkey, options.channelId); return true; } // A channel-scoped AgentSessionProvider belongs to the channel view // already authorized by its route. Do not reject its own current // channel while the member/reference query is still settling. - onOpenAgentSession(pubkey, options.channelId); - return true; - } - if (onOpenAgentSession) { - onOpenAgentSession(pubkey); + openActivityDestination(pubkey, options.channelId); return true; } const channelId = resolveChannelId(pubkey); if (channelId) { - void goChannel(channelId, { agentSession: pubkey }); + openActivityDestination(pubkey, channelId); return true; } // The agent may be working somewhere, just nowhere the viewer can open. @@ -188,7 +189,12 @@ export function useOpenAgentActivity() { } return false; }, - [findOpenableChannel, goChannel, onOpenAgentSession, resolveChannelId], + [ + findOpenableChannel, + onOpenAgentSession, + openActivityDestination, + resolveChannelId, + ], ); return { canOpenAgentActivity, openAgentActivity }; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 6254afd8c7..2e0501d3b0 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { toast } from "sonner"; import { useAppShell } from "@/app/AppShellContext"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader"; @@ -57,6 +58,8 @@ import type { TimelineMessage } from "@/features/messages/types"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { useRelaySelfQuery } from "@/features/moderation/hooks"; import type { RelayEvent, RespondToMode } from "@/shared/api/types"; +import { openAgentActivityWindow } from "@/shared/api/agentActivityWindow"; +import { isAgentActivityWindow } from "@/features/agents/lib/agentActivityWindow"; import { ChannelScreenLoadingFallback } from "@/features/channels/ui/ChannelScreenLoadingFallback"; import { useHuddleChannelMessages, @@ -583,6 +586,29 @@ export function ChannelScreen({ setThreadReplyTargetId, setThreadScrollTargetId, }); + const handleOpenAgentActivity = React.useCallback( + (pubkey: string, channelId?: string | null) => { + const destinationChannelId = channelId ?? activeChannelId; + if (!destinationChannelId) return; + + if (isAgentActivityWindow()) { + handleOpenAgentSession(pubkey, destinationChannelId); + return; + } + + void openAgentActivityWindow(destinationChannelId, pubkey) + .then((openedNativeWindow) => { + if (!openedNativeWindow) { + handleOpenAgentSession(pubkey, destinationChannelId); + } + }) + .catch((error) => { + console.error("Failed to open agent activity window:", error); + toast.error("Couldn't open the agent activity window."); + }); + }, + [activeChannelId, handleOpenAgentSession], + ); const { handleOpenProfilePanel, handleCloseProfilePanel, handleOpenDm } = useChannelProfilePanel({ closeAgentSession: handleCloseAgentSession, @@ -772,7 +798,7 @@ export function ChannelScreen({ ], ); return ( - + diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 634a9b171a..777861fa39 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -12,6 +12,7 @@ import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider"; import { migrateLegacyCommunityStorageBeforeRender } from "@/features/communities/legacyCommunityStorage"; import { CommunitiesProvider } from "@/features/communities/useCommunities"; import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; +import { isAgentActivityWindow } from "@/features/agents/lib/agentActivityWindow"; import { CommunityOnboardingProvider } from "@/features/onboarding/communityOnboarding"; import { ThemeProvider } from "@/shared/theme/ThemeProvider"; import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider"; @@ -86,7 +87,9 @@ function renderApp() { diff --git a/desktop/src/shared/api/agentActivityWindow.ts b/desktop/src/shared/api/agentActivityWindow.ts new file mode 100644 index 0000000000..d4e03b8202 --- /dev/null +++ b/desktop/src/shared/api/agentActivityWindow.ts @@ -0,0 +1,10 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; + +/** Opens a channel-scoped agent activity feed in its native companion window. */ +export async function openAgentActivityWindow( + channelId: string, + pubkey: string, +): Promise { + if (!isTauri()) return false; + return invoke("open_agent_activity_window", { channelId, pubkey }); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index e4028f0171..8bebac4318 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -11238,6 +11238,10 @@ export function maybeInstallE2eTauriMocks() { await emitMockHuddleState(); } return null; + case "open_agent_activity_window": + // Browser E2E keeps the in-app fallback so existing interaction specs + // can inspect the feed without a second native webview. + return false; case "open_huddle_window": if ((activeConfig?.mock?.openHuddleWindowDelayMs ?? 0) > 0) { await new Promise((resolve) =>