diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index ff8a0e7703b..86c195de002 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -51,6 +51,7 @@ export default defineConfig({ "**/observer-feed-screenshots.spec.ts", "**/core-memory-screenshots.spec.ts", "**/activity-scope-label-screenshots.spec.ts", + "**/agent-activity-window.spec.ts", "**/welcome-agent-modal-screenshots.spec.ts", "**/local-archive-screenshots.spec.ts", "**/voice-settings.spec.ts", diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index a2e09bcb333..97ee30cbdde 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -1,8 +1,8 @@ { "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", - "description": "Capability for the main window and trusted huddle companions", - "windows": ["main", "huddle-*"], + "description": "Capability for the main window and trusted companion windows", + "windows": ["main", "huddle-*", "agent-activity-*"], "permissions": [ "core:default", "core:webview:allow-set-webview-zoom", @@ -10,6 +10,7 @@ "core:window:allow-set-badge-label", "core:window:allow-request-user-attention", "core:window:allow-set-focus", + "core:window:allow-set-title", "core:window:allow-start-dragging", "core:window:allow-toggle-maximize", "core:window:allow-unminimize", 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 00000000000..e79d8ef0408 --- /dev/null +++ b/desktop/src-tauri/src/agent_activity_window.rs @@ -0,0 +1,130 @@ +//! Native companion-window lifecycle for an agent activity feed. + +use sha2::{Digest, Sha256}; +use tauri::{Manager, WebviewUrl, WebviewWindowBuilder}; +use url::form_urlencoded; +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 normalized_community_id(community_id: &str) -> Result { + let normalized = community_id.trim(); + if normalized.is_empty() { + return Err("community id must not be empty".to_string()); + } + Ok(normalized.to_string()) +} + +fn window_label(community_id: &str, channel_id: &Uuid, pubkey: &str) -> String { + let community_scope = hex::encode(Sha256::digest(community_id.as_bytes())); + format!("agent-activity-{community_scope}-{pubkey}-{channel_id}") +} + +fn activity_route(community_id: &str, channel_id: &Uuid, pubkey: &str) -> String { + let query = form_urlencoded::Serializer::new(String::new()) + .append_pair("community", community_id) + .append_pair("agentSession", pubkey) + .append_pair("agentSessionChannel", &channel_id.to_string()) + .finish(); + format!("index.html#/channels/{channel_id}?{query}") +} + +/// 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, + community_id: String, + channel_id: String, + pubkey: String, +) -> Result { + let community_id = normalized_community_id(&community_id)?; + 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(&community_id, &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 = activity_route(&community_id, &channel_id, &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::{activity_route, normalized_community_id, 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("community-a", &channel_id, &first), + window_label("community-a", &channel_id, &second) + ); + } + + #[test] + fn labels_distinguish_communities() { + let channel_id = Uuid::nil(); + let pubkey = "ab".repeat(32); + + assert_ne!( + window_label("community-a", &channel_id, &pubkey), + window_label("community-b", &channel_id, &pubkey) + ); + } + + #[test] + fn route_carries_immutable_community_scope() { + let channel_id = Uuid::nil(); + let pubkey = "ab".repeat(32); + + assert_eq!( + activity_route("community & one", &channel_id, &pubkey), + format!( + "index.html#/channels/{channel_id}?community=community+%26+one&agentSession={pubkey}&agentSessionChannel={channel_id}" + ) + ); + } + + #[test] + fn rejects_empty_community_ids() { + assert!(normalized_community_id(" ").is_err()); + } + + #[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 71a5eb3806e..160083adf1f 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-tauri/tests/csp.rs b/desktop/src-tauri/tests/csp.rs index 3497c368632..12c48488268 100644 --- a/desktop/src-tauri/tests/csp.rs +++ b/desktop/src-tauri/tests/csp.rs @@ -1,4 +1,5 @@ -//! Guards on the packaged-app Content-Security-Policy in `tauri.conf.json`. +//! Guards on packaged-app security configuration in `tauri.conf.json` and +//! `capabilities/default.json`. //! //! The CSP is only enforced on assets Tauri itself serves, so neither //! `just dev` (loads the Vite `devUrl`) nor the Playwright suite (runs under @@ -12,6 +13,25 @@ use std::collections::HashMap; const TAURI_CONF: &str = include_str!("../tauri.conf.json"); +const DEFAULT_CAPABILITY: &str = include_str!("../capabilities/default.json"); + +#[test] +fn companion_windows_receive_the_default_capability() { + let capability: serde_json::Value = + serde_json::from_str(DEFAULT_CAPABILITY).expect("default capability is valid JSON"); + let windows = capability["windows"] + .as_array() + .expect("default capability declares trusted windows"); + + for pattern in ["huddle-*", "agent-activity-*"] { + assert!( + windows + .iter() + .any(|window| window.as_str() == Some(pattern)), + "default capability must include trusted companion pattern {pattern}" + ); + } +} fn csp_directives() -> HashMap> { let conf: serde_json::Value = diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index bfaf2ba2008..4bea3af8056 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -23,7 +23,10 @@ import { CommunityThemeController } from "@/shared/theme/CommunityThemeControlle import { useReloadShortcut } from "@/app/useReloadShortcut"; import { useCloseWindowShortcut } from "@/app/useCloseWindowShortcut"; import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys"; -import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; +import { + acceptsNativeDeepLinks, + currentCompanionWindowKind, +} from "@/app/companionWindow"; import { useAppOnboardingState } from "@/features/onboarding/hooks"; import { useMachineOnboardingState } from "@/features/onboarding/machineOnboarding"; import { @@ -396,6 +399,7 @@ function CommunityApp({ communityKey, sharedIdentity, isFindingCommunityAfterLeave, + currentCompanionWindowKind() === null, ); const transitionCommunity = useCallback( @@ -712,10 +716,12 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) { [activeCommunity, communityOnboarding.start], ); - // Community links are app-global work. A Huddle companion loads the same + // Community links are app-global work. Companion windows load the same // React tree, but must never race the main window for the native pending-link - // queue or replace its dedicated transcript surface with onboarding. - const acceptsCommunityDeepLinks = huddleWindowChannelId() === null; + // queue or replace their dedicated surface with onboarding. + const acceptsCommunityDeepLinks = acceptsNativeDeepLinks( + currentCompanionWindowKind(), + ); useEffect(() => { if (!acceptsCommunityDeepLinks) return; diff --git a/desktop/src/app/AppHuddleShell.tsx b/desktop/src/app/AppHuddleShell.tsx index 3e32697e9ec..a28b1d4195b 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) ? (
{ + assert.deepEqual(mainOwnedEffects(false), MAIN_OWNED_EFFECTS_ENABLED); +}); + +test("companion windows disable singleton and mutating app effects", () => { + assert.deepEqual( + mainOwnedEffects(true), + Object.fromEntries( + Object.keys(MAIN_OWNED_EFFECTS_ENABLED).map((effect) => [effect, false]), + ), + ); +}); + test("shouldBounceForChannelNotification_allowsTopLevelChannelMessages", () => { assert.equal(shouldBounceForChannelNotification([["h", "channel"]]), true); }); diff --git a/desktop/src/app/AppShell.helpers.ts b/desktop/src/app/AppShell.helpers.ts index 9fc14736c7c..0a0e87044dd 100644 --- a/desktop/src/app/AppShell.helpers.ts +++ b/desktop/src/app/AppShell.helpers.ts @@ -2,6 +2,27 @@ import { isThreadReply } from "@/features/messages/lib/threading"; import type { DesktopNotificationTarget } from "@/features/notifications/lib/desktop"; import type { SearchHit } from "@/shared/api/types"; +export type MainOwnedEffects = { + agentRuntimeReconciliation: boolean; + autoRestart: boolean; + membershipNotifications: boolean; + presenceSession: boolean; + reminderNotifications: boolean; + markAsReadShortcuts: boolean; +}; + +export function mainOwnedEffects(isCompanionWindow: boolean): MainOwnedEffects { + const enabled = !isCompanionWindow; + return { + agentRuntimeReconciliation: enabled, + autoRestart: enabled, + membershipNotifications: enabled, + presenceSession: enabled, + reminderNotifications: enabled, + markAsReadShortcuts: enabled, + }; +} + export type AppView = | "home" | "channel" diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index e111f93ca0e..bcf0a6a511b 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -1,7 +1,11 @@ import * as React from "react"; import { useQueryClient } from "@tanstack/react-query"; import { Outlet, useLocation } from "@tanstack/react-router"; -import { deriveShellRoute, markAllReadSources } from "@/app/AppShell.helpers"; +import { + deriveShellRoute, + mainOwnedEffects, + markAllReadSources, +} from "@/app/AppShell.helpers"; import { useTerminalContext } from "@/app/useTerminalContext"; import { AppShellProvider } from "@/app/AppShellContext"; import { AppShellOverlays, TerminalBootstrap } from "@/app/AppShellOverlays"; @@ -44,6 +48,7 @@ import { } from "@/features/notifications/hooks"; import { PreventSleepProvider } from "@/features/agents/usePreventSleep"; import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; +import { currentCompanionWindowKind } from "@/app/companionWindow"; import { useAgentsDataRefresh } from "@/features/agents/lib/useAgentsDataRefresh"; import { useManagedAgentRuntimeReconciliation } from "@/features/agents/useManagedAgentRuntimeReconciliation"; import { useAutoRestartPolicy } from "@/features/agents/lib/useAutoRestartPolicy"; @@ -125,7 +130,12 @@ export function AppShell() { showHuddleInMainApp, viewHuddleChannel, } = useHuddlePresentation(); - const hasCommunityRail = communitiesHook.communities.length > 1; + const companionWindowKind = currentCompanionWindowKind(); + const isActivityWindow = companionWindowKind === "agent-activity"; + const isCompanionWindow = companionWindowKind !== null; + const ownedEffects = mainOwnedEffects(isCompanionWindow); + const hasCommunityRail = + communitiesHook.communities.length > 1 && !isCompanionWindow; const addCommunityDialog = useAddCommunityDialogState(); const [isChannelManagementOpen, setIsChannelManagementOpen] = React.useState(false); @@ -140,7 +150,10 @@ export function AppShell() { const mainInsetRef = React.useRef(null); const location = useLocation(); const queryClient = useQueryClient(); - useManagedAgentRuntimeReconciliation(communitiesHook.communities); // sync storage snapshot + useManagedAgentRuntimeReconciliation( + communitiesHook.communities, + ownedEffects.agentRuntimeReconciliation, + ); const { goAgents, goChannel, @@ -192,30 +205,13 @@ export function AppShell() { communitiesHook.activeCommunity?.relayUrl, ); useAgentsDataRefresh(); - // Chunk F: auto-restart drifted idle agents (per-agent opt-out, default ON). - useAutoRestartPolicy(); - // Owner-global observer ingestion: receives + decrypts agent observer - // frames and keeps derived active-turn liveness in sync app-wide, so no - // individual screen/panel has to mount its own bridge for ingestion. - // Intentionally mounted without a `startupReady`/identity guard: before - // `currentPubkey` resolves the hook ingests managed agents only, and - // relay-owned agents join automatically once identity arrives. Adding a - // guard here would drop managed-agent coverage during startup. + useAutoRestartPolicy(ownedEffects.autoRestart); useAgentObserverIngestion(); - // Kind 24200 is relay-ephemeral, so reconciliation runs eagerly (not - // deferred): seeds kind 24200 for fresh identities, no-ops for explicit - // opt-outs. Frames before the listener opens are permanently lost. const observerReconciled = useObserverArchiveReconciliation( identityQuery.data?.pubkey, ); - // useArchiveSync must wait for reconciliation, or listeners could open - // before kind 24200 is guaranteed present in the subscription. useArchiveSync(observerReconciled); - // The archive batch now persists in Rust, so the agent-metrics invalidation - // signal arrives as a Tauri event rather than an in-process call. useArchiveAgentMetricsBridge(); - // Kind 44200 is relay-persisted (durable) and stays deferred: missed - // startup frames can be replayed, so there's no ordering constraint. const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined; useAgentMetricArchiveSeed(deferredPubkey); const profileQuery = useProfileQuery(); @@ -223,8 +219,13 @@ export function AppShell() { usePresenceSubscription(); useUserStatusSubscription(); useCommunityEmojiLiveUpdates(); - useMembershipNotifications(identityQuery.data?.pubkey); - const presenceSession = usePresenceSession(deferredPubkey); + const mainOwnedPubkey = ownedEffects.membershipNotifications + ? identityQuery.data?.pubkey + : undefined; + useMembershipNotifications(mainOwnedPubkey); + const presenceSession = usePresenceSession( + startupReady && ownedEffects.presenceSession ? mainOwnedPubkey : undefined, + ); const selfStatusQuery = useUserStatusQuery( deferredPubkey ? [deferredPubkey] : [], ); @@ -235,7 +236,7 @@ export function AppShell() { const channelsQuery = useChannelsQuery(); const channels = channelsQuery.data ?? []; useReminderNotifications( - identityQuery.data?.pubkey, + ownedEffects.reminderNotifications ? mainOwnedPubkey : undefined, notificationSettings.settings, channels, ); @@ -350,7 +351,7 @@ export function AppShell() { handleThreadReplyDesktopNotification, } = useAppShellDesktopNotifications({ channels, - enabled: !isHuddleRoom, + enabled: !isCompanionWindow, goChannel, goHome, notificationSettings: notificationSettings.settings, @@ -387,8 +388,8 @@ export function AppShell() { muteThread, unmuteThread, } = useUnreadChannels( - isHuddleRoom ? EMPTY_CHANNELS : sidebarChannels, - isHuddleRoom ? null : activeChannel, + isCompanionWindow ? EMPTY_CHANNELS : sidebarChannels, + isCompanionWindow ? null : activeChannel, { pubkey: identityQuery.data?.pubkey, relayClient, @@ -450,7 +451,7 @@ export function AppShell() { identityQuery.data?.pubkey, notificationSettings.settings, notificationSettings.setDesktopEnabled, - !isHuddleRoom, + !isCompanionWindow, selectedView === "home" && !settingsOpen, getChannelReadAt, readStateVersion, @@ -654,13 +655,13 @@ export function AppShell() { [openSearchHit], ); useAppShellLifecycleEffects({ - desktopBadgeEnabled: !isHuddleRoom, + desktopBadgeEnabled: !isCompanionWindow, homeBadgeCountExcludingHighPriority, topLevelUnreadChannelIds, unreadChannelNotificationCount, }); - // Dispatch `buzz://` deep links only from the main window; the companion is dedicated to its active Huddle route. - useAppDeepLinks(!isHuddleRoom); + // Dispatch `buzz://` deep links only from the primary window; companions own their focused route. + useAppDeepLinks(!isCompanionWindow); const handleOpenCreateChannel = React.useCallback( () => setIsCreateChannelOpen(true), [], @@ -669,7 +670,7 @@ export function AppShell() { activeChannelId: selectedView === "channel" ? selectedChannelId : null, canSearchCurrentChannel: selectedView === "channel" && Boolean(activeChannel), - disabled: settingsOpen || isHuddleRoom, + disabled: settingsOpen || isCompanionWindow, onBrowseChannels: handleOpenBrowseChannels, onCreateChannel: handleOpenCreateChannel, onGoHome: goHome, @@ -680,18 +681,19 @@ export function AppShell() { useSettingsShortcuts({ onClose: handleCloseSettings, onOpenSettings: handleOpenSettings, - open: isHuddleRoom ? undefined : settingsOpen, + open: isCompanionWindow ? undefined : settingsOpen, }); useMarkAsReadShortcuts({ activeChannelId: activeChannel?.id ?? null, activeChannelLastMessageAt: activeChannel?.lastMessageAt, + enabled: ownedEffects.markAsReadShortcuts, markAllChannelsRead, markChannelRead, selectedView, }); return ( - {!isHuddleRoom ? ( + {!isCompanionWindow ? ( - {hasCommunityRail && !isHuddleRoom ? ( + {hasCommunityRail ? ( - {!settingsOpen && !isHuddleRoom ? ( + {!settingsOpen && !isCompanionWindow ? ( ) : (
- {!isHuddleRoom ? ( + {!isCompanionWindow ? ( @@ -935,7 +939,7 @@ export function AppShell() { - {!isHuddleRoom ? ( + {!isCompanionWindow ? ( ; terminal?: React.ReactNode; }; @@ -20,12 +21,13 @@ export function AppShellChannelSurface({ hasCommunityRail, isHuddleRoom, isHuddleRoomStarting, + unframed = false, mainInsetRef, terminal, }: AppShellChannelSurfaceProps) { const { isMobile, openMobile, state: sidebarState } = useSidebar(); const hasCollapsedSidebarGutter = - !isHuddleRoom && + !unframed && !hasCommunityRail && (isMobile ? !openMobile : sidebarState === "collapsed"); @@ -35,11 +37,11 @@ export function AppShellChannelSurface({ ref={mainInsetRef} className={cn( "isolate z-0 min-h-0 min-w-0 overflow-hidden", - isHuddleRoom ? "bg-background" : "bg-sidebar", + unframed ? "bg-background" : "bg-sidebar", hasCollapsedSidebarGutter && "pl-2", )} - data-buzz-content-surface={isHuddleRoom ? true : undefined} - data-buzz-content-unframed={isHuddleRoom ? true : undefined} + data-buzz-content-surface={unframed ? true : undefined} + data-buzz-content-unframed={unframed ? true : undefined} data-buzz-glass-inset data-buzz-shadow-viewport style={chromeCssVarDefaults as React.CSSProperties} @@ -51,7 +53,7 @@ export function AppShellChannelSurface({ /> ) : null} {isHuddleRoom && !isHuddleRoomStarting ? : null} - + {isHuddleRoomStarting ? : children} diff --git a/desktop/src/app/BuzzThemeSurfaces.tsx b/desktop/src/app/BuzzThemeSurfaces.tsx index b7912c98bcf..45101d5e65f 100644 --- a/desktop/src/app/BuzzThemeSurfaces.tsx +++ b/desktop/src/app/BuzzThemeSurfaces.tsx @@ -27,7 +27,7 @@ export function ContentSurface({ }: { children: ReactNode; terminal?: ReactNode; - /** Used by dedicated huddle windows, which should not resemble app cards. */ + /** Used by dedicated companion windows, which should not resemble app cards. */ unframed?: boolean; }) { return ( diff --git a/desktop/src/app/companionWindow.test.mjs b/desktop/src/app/companionWindow.test.mjs new file mode 100644 index 00000000000..a09a8735488 --- /dev/null +++ b/desktop/src/app/companionWindow.test.mjs @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + acceptsNativeDeepLinks, + agentActivityCompanionCoordinates, + companionCommunityBootstrap, + companionCommunityIdForHash, + companionWindowKindForLabel, + pinAgentActivityCompanionSearch, +} from "./companionWindow.ts"; + +describe("companionWindowKindForLabel", () => { + it("classifies focused companion labels", () => { + assert.equal( + companionWindowKindForLabel("agent-activity-deadbeef-channel"), + "agent-activity", + ); + assert.equal(companionWindowKindForLabel("huddle-channel-id"), "huddle"); + }); + + it("leaves primary and unrelated windows unclassified", () => { + assert.equal(companionWindowKindForLabel("main"), null); + assert.equal(companionWindowKindForLabel("reader-document"), null); + }); +}); + +describe("agentActivityCompanionCoordinates", () => { + const coordinates = { + community: "community-one", + agentSession: "agent-one", + agentSessionChannel: "channel-one", + }; + + it("returns every immutable activity companion coordinate", () => { + assert.deepEqual( + agentActivityCompanionCoordinates("agent-activity", coordinates), + coordinates, + ); + }); + + it("rejects non-activity windows and incomplete coordinates", () => { + assert.equal( + agentActivityCompanionCoordinates("huddle", coordinates), + undefined, + ); + assert.equal( + agentActivityCompanionCoordinates("agent-activity", { + community: coordinates.community, + agentSession: coordinates.agentSession, + }), + undefined, + ); + }); +}); + +describe("pinAgentActivityCompanionSearch", () => { + const coordinates = { + community: "community-one", + agentSession: "agent-one", + agentSessionChannel: "channel-one", + }; + + it("preserves coordinates while stripping panel-swapping state", () => { + assert.deepEqual( + pinAgentActivityCompanionSearch("agent-activity", coordinates, { + community: "community-two", + agentSession: "agent-two", + agentSessionChannel: "channel-two", + messageId: "message-one", + profile: "profile-one", + thread: "thread-one", + unrelated: "kept", + }), + { ...coordinates, unrelated: "kept" }, + ); + }); + + it("leaves ordinary windows unchanged", () => { + const nextSearch = { messageId: "message-one" }; + assert.equal( + pinAgentActivityCompanionSearch(null, coordinates, nextSearch), + nextSearch, + ); + }); +}); + +describe("acceptsNativeDeepLinks", () => { + it("reserves the pending-link queue for the main realm", () => { + assert.equal(acceptsNativeDeepLinks(null), true); + assert.equal(acceptsNativeDeepLinks("huddle"), false); + assert.equal(acceptsNativeDeepLinks("agent-activity"), false); + }); +}); + +describe("companionCommunityBootstrap", () => { + it("lets huddle companions boot through normal community selection", () => { + assert.deepEqual(companionCommunityBootstrap("huddle", ""), { + initialActiveCommunityId: undefined, + missingRequiredCommunity: false, + }); + }); + + it("rejects agent activity companions without community context", () => { + assert.deepEqual(companionCommunityBootstrap("agent-activity", ""), { + initialActiveCommunityId: undefined, + missingRequiredCommunity: true, + }); + }); + + it("pins agent activity companions to their encoded community", () => { + assert.deepEqual( + companionCommunityBootstrap( + "agent-activity", + "#/channels/channel?community=community-one", + ), + { + initialActiveCommunityId: "community-one", + missingRequiredCommunity: false, + }, + ); + }); +}); + +describe("companionCommunityIdForHash", () => { + it("reads and decodes immutable community identity from the route", () => { + assert.equal( + companionCommunityIdForHash( + "#/channels/channel?community=community+%26+one&agentSession=agent", + ), + "community & one", + ); + }); + + it("returns null when the bootstrap contract is absent", () => { + assert.equal(companionCommunityIdForHash("#/channels/channel"), null); + }); +}); diff --git a/desktop/src/app/companionWindow.ts b/desktop/src/app/companionWindow.ts new file mode 100644 index 00000000000..a7f40000524 --- /dev/null +++ b/desktop/src/app/companionWindow.ts @@ -0,0 +1,146 @@ +import { isTauri } from "@tauri-apps/api/core"; +import { getCurrentWindow } from "@tauri-apps/api/window"; + +export type CompanionWindowKind = "agent-activity" | "huddle"; + +/** Classify native companion surfaces that reuse the main application shell. */ +export function companionWindowKindForLabel( + label: string, +): CompanionWindowKind | null { + if (label.startsWith("agent-activity-")) return "agent-activity"; + if (label.startsWith("huddle-")) return "huddle"; + return null; +} + +/** Return this webview's companion kind, or null for the primary app window. */ +export function currentCompanionWindowKind(): CompanionWindowKind | null { + if (!isTauri()) return null; + + try { + return companionWindowKindForLabel(getCurrentWindow().label); + } catch { + // Browser previews can expose the Tauri IPC mock without window metadata. + return null; + } +} + +export type AgentActivityCompanionCoordinates = { + community: string; + agentSession: string; + agentSessionChannel: string; +}; + +export function agentActivityCompanionCoordinates( + companionKind: CompanionWindowKind | null, + search: Record, +): AgentActivityCompanionCoordinates | undefined { + if ( + companionKind !== "agent-activity" || + typeof search.community !== "string" || + typeof search.agentSession !== "string" || + typeof search.agentSessionChannel !== "string" + ) { + return undefined; + } + + return { + community: search.community, + agentSession: search.agentSession, + agentSessionChannel: search.agentSessionChannel, + }; +} + +/** Return the immutable route coordinates owned by this activity companion. */ +export function currentAgentActivityCompanionCoordinates( + search: Record, +): AgentActivityCompanionCoordinates | undefined { + return agentActivityCompanionCoordinates( + currentCompanionWindowKind(), + search, + ); +} + +/** Search keys that replace the dedicated feed with another channel panel. */ +const AGENT_ACTIVITY_COMPANION_PANEL_KEYS = [ + "autoSend", + "channelManagement", + "messageId", + "profile", + "profileTab", + "profileView", + "thread", + "threadRootId", +] as const; + +/** Keep a dedicated feed on its immutable coordinates and activity panel. */ +export function pinAgentActivityCompanionSearch( + companionKind: CompanionWindowKind | null, + currentSearch: Record, + nextSearch: Record, +): Record { + const coordinates = agentActivityCompanionCoordinates( + companionKind, + currentSearch, + ); + if (!coordinates) return nextSearch; + + const pinnedSearch = { ...nextSearch }; + for (const key of AGENT_ACTIVITY_COMPANION_PANEL_KEYS) { + delete pinnedSearch[key]; + } + return { ...pinnedSearch, ...coordinates }; +} + +/** Apply the dedicated-feed search invariant for this native window. */ +export function pinCurrentAgentActivityCompanionSearch( + currentSearch: Record, + nextSearch: Record, +): Record { + return pinAgentActivityCompanionSearch( + currentCompanionWindowKind(), + currentSearch, + nextSearch, + ); +} + +/** Community encoded into a companion bootstrap hash. */ +export function companionCommunityIdForHash(hash: string): string | null { + const query = hash.indexOf("?"); + if (query === -1) return null; + return new URLSearchParams(hash.slice(query + 1)).get("community"); +} + +export type CompanionCommunityBootstrap = { + initialActiveCommunityId: string | undefined; + missingRequiredCommunity: boolean; +}; + +/** Agent activity windows pin their origin community; huddles use normal selection. */ +export function companionCommunityBootstrap( + companionKind: CompanionWindowKind | null, + hash: string, +): CompanionCommunityBootstrap { + if (companionKind !== "agent-activity") { + return { + initialActiveCommunityId: undefined, + missingRequiredCommunity: false, + }; + } + const communityId = companionCommunityIdForHash(hash); + return { + initialActiveCommunityId: communityId ?? undefined, + missingRequiredCommunity: communityId === null, + }; +} + +/** Whether this realm owns the native pending deep-link queue. */ +export function acceptsNativeDeepLinks( + companionKind: CompanionWindowKind | null, +): boolean { + return companionKind === null; +} + +/** Whether this webview is a focused companion rather than the primary app. */ +export function isCompanionWindow(): boolean { + return currentCompanionWindowKind() !== null; +} diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index c4776564e34..5b60133c014 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -7,6 +7,10 @@ import { } from "@tanstack/react-router"; import { openSearchHitWithNavigation } from "@/app/navigation/searchHitNavigation"; +import { + currentAgentActivityCompanionCoordinates, + pinCurrentAgentActivityCompanionSearch, +} from "@/app/companionWindow"; import type { SearchHit } from "@/shared/api/types"; type NavigationBehavior = { @@ -20,6 +24,9 @@ export function useAppNavigation() { const navigate = useNavigate(); const location = useLocation(); const canGoBack = useCanGoBack(); + const companionCoordinates = currentAgentActivityCompanionCoordinates( + location.search as Record, + ); const commitNavigation = React.useCallback( async ( @@ -31,20 +38,39 @@ export function useAppNavigation() { }, behavior: NavigationBehavior = {}, ) => { - const nextLocation = router.buildLocation(next as never); + const destination = companionCoordinates + ? { + ...next, + search: pinCurrentAgentActivityCompanionSearch( + location.search as Record, + next.search ?? {}, + ), + } + : next; + + if ( + companionCoordinates && + (destination.to !== "/channels/$channelId" || + destination.params?.channelId !== + companionCoordinates.agentSessionChannel) + ) { + return false; + } + + const nextLocation = router.buildLocation(destination as never); if (location.href === nextLocation.href && !behavior.force) { return false; } await navigate({ - ...next, + ...destination, replace: behavior.replace, resetScroll: behavior.resetScroll, } as never); return true; }, - [location.href, navigate, router], + [companionCoordinates, location.href, location.search, navigate, router], ); const goHome = React.useCallback( diff --git a/desktop/src/app/routes/channels.$channelId.tsx b/desktop/src/app/routes/channels.$channelId.tsx index 892eef6a56c..14cc275350e 100644 --- a/desktop/src/app/routes/channels.$channelId.tsx +++ b/desktop/src/app/routes/channels.$channelId.tsx @@ -13,6 +13,10 @@ import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; type ChannelRouteSearch = { agentSession?: string; + /** Immutable channel scope carried by dedicated activity windows. */ + agentSessionChannel?: string; + /** Immutable origin community carried by dedicated activity windows. */ + community?: string; /** * When set, the composer on mount will auto-submit its loaded draft once, * then clear this param. Value is the draft key that was loaded so the @@ -36,6 +40,8 @@ function validateChannelSearch( ): ChannelRouteSearch { return { agentSession: nonEmptyString(search.agentSession), + agentSessionChannel: nonEmptyString(search.agentSessionChannel), + community: nonEmptyString(search.community), autoSend: nonEmptyString(search.autoSend), messageId: nonEmptyString(search.messageId), profile: nonEmptyString(search.profile), diff --git a/desktop/src/app/useMarkAsReadShortcuts.test.mjs b/desktop/src/app/useMarkAsReadShortcuts.test.mjs new file mode 100644 index 00000000000..75f3032ae52 --- /dev/null +++ b/desktop/src/app/useMarkAsReadShortcuts.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + KeyboardEvent: dom.window.KeyboardEvent, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +async function renderShortcuts(enabled) { + const { renderHook } = await import("@testing-library/react"); + const { useMarkAsReadShortcuts } = await import( + "./useMarkAsReadShortcuts.ts" + ); + const calls = []; + const view = renderHook(() => + useMarkAsReadShortcuts({ + activeChannelId: "general", + activeChannelLastMessageAt: "2026-08-24T00:00:00.000Z", + enabled, + markAllChannelsRead: () => calls.push("all"), + markChannelRead: () => calls.push("channel"), + selectedView: "channel", + }), + ); + return { calls, view }; +} + +function pressEscape({ shiftKey = false } = {}) { + window.dispatchEvent( + new KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Escape", + shiftKey, + }), + ); +} + +test("disabled shortcuts ignore Escape and Shift+Escape", async () => { + const { calls } = await renderShortcuts(false); + + pressEscape(); + pressEscape({ shiftKey: true }); + + assert.deepEqual(calls, []); +}); + +test("enabled shortcuts preserve channel and all-channel actions", async () => { + const { calls } = await renderShortcuts(true); + + pressEscape(); + pressEscape({ shiftKey: true }); + + assert.deepEqual(calls, ["channel", "all"]); +}); diff --git a/desktop/src/app/useMarkAsReadShortcuts.ts b/desktop/src/app/useMarkAsReadShortcuts.ts index d2499d80bb0..2daa0797078 100644 --- a/desktop/src/app/useMarkAsReadShortcuts.ts +++ b/desktop/src/app/useMarkAsReadShortcuts.ts @@ -9,6 +9,7 @@ export function useMarkAsReadShortcuts({ markAllChannelsRead, markChannelRead, selectedView, + enabled = true, }: { activeChannelId: string | null; activeChannelLastMessageAt: string | null | undefined; @@ -18,8 +19,11 @@ export function useMarkAsReadShortcuts({ lastMessageAt: string | null | undefined, ) => void; selectedView: string; + enabled?: boolean; }) { React.useEffect(() => { + if (!enabled) return; + function handleKeyDown(event: KeyboardEvent) { if (event.key !== "Escape") return; if (event.defaultPrevented) return; @@ -51,6 +55,7 @@ export function useMarkAsReadShortcuts({ }, [ activeChannelId, activeChannelLastMessageAt, + enabled, markAllChannelsRead, markChannelRead, selectedView, diff --git a/desktop/src/features/agents/lib/agentActivityWindow.test.mjs b/desktop/src/features/agents/lib/agentActivityWindow.test.mjs new file mode 100644 index 00000000000..a40fd4d5786 --- /dev/null +++ b/desktop/src/features/agents/lib/agentActivityWindow.test.mjs @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { agentActivityWindowTitle } from "./agentActivityWindow.ts"; + +describe("agentActivityWindowTitle", () => { + it("names both the agent and channel", () => { + assert.equal( + agentActivityWindowTitle("Mongo", "buzz-inline-chip-wrap"), + "Mongo · #buzz-inline-chip-wrap", + ); + }); + + it("does not duplicate a supplied channel hash", () => { + assert.equal( + agentActivityWindowTitle(" Mongo ", " #design "), + "Mongo · #design", + ); + }); +}); diff --git a/desktop/src/features/agents/lib/agentActivityWindow.ts b/desktop/src/features/agents/lib/agentActivityWindow.ts new file mode 100644 index 00000000000..fb91aa7cafc --- /dev/null +++ b/desktop/src/features/agents/lib/agentActivityWindow.ts @@ -0,0 +1,30 @@ +import { currentCompanionWindowKind } from "@/app/companionWindow"; +import { getCurrentWindow } from "@tauri-apps/api/window"; + +/** Whether this webview is dedicated to an agent activity feed. */ +export function isAgentActivityWindow(): boolean { + return currentCompanionWindowKind() === "agent-activity"; +} + +/** Build the concise native title for a channel-scoped activity window. */ +export function agentActivityWindowTitle( + agentName: string, + channelName: string, +): string { + const normalizedAgentName = agentName.trim() || "Agent"; + const normalizedChannelName = channelName.trim().replace(/^#+/, ""); + return `${normalizedAgentName} · #${normalizedChannelName}`; +} + +/** Keep a companion window's native title aligned with its resolved scope. */ +export async function setAgentActivityWindowTitle( + agentName: string, + channelName: string, +): Promise { + if (!isAgentActivityWindow() || !channelName.trim()) return false; + + await getCurrentWindow().setTitle( + agentActivityWindowTitle(agentName, channelName), + ); + return true; +} diff --git a/desktop/src/features/agents/lib/useAutoRestartPolicy.ts b/desktop/src/features/agents/lib/useAutoRestartPolicy.ts index 652617dee36..0090e44dfb5 100644 --- a/desktop/src/features/agents/lib/useAutoRestartPolicy.ts +++ b/desktop/src/features/agents/lib/useAutoRestartPolicy.ts @@ -35,7 +35,7 @@ const POLICY_TICK_MS = 15_000; * on the backend store lock, so a cross-window double-fire is benign (and * further shrunk by the pre-fire summary re-fetch). */ -export function useAutoRestartPolicy() { +export function useAutoRestartPolicy(enabled = true) { const queryClient = useQueryClient(); const agents: ManagedAgent[] | undefined = useManagedAgentsQuery().data; const edgesRef = React.useRef(new Map()); @@ -46,17 +46,17 @@ export function useAutoRestartPolicy() { // Re-evaluate on an interval so the quiescence clock advances even when // summaries and observer stores are quiet. React.useEffect(() => { - if (!documentVisible) return; + if (!enabled || !documentVisible) return; setTick((t) => t + 1); const timer = setInterval(() => setTick((t) => t + 1), POLICY_TICK_MS); return () => clearInterval(timer); - }, [documentVisible]); + }, [documentVisible, enabled]); // No dependency array by design: the tick pattern re-runs this effect // every render so it reads live store state; all mutation is ref-local. React.useEffect(() => { - if (!agents) return; + if (!enabled || !agents) return; const now = Date.now(); const edges = edgesRef.current; diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx index 424837a068f..d617b416d65 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem/CompactMessageSummary.tsx @@ -7,6 +7,8 @@ import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { Markdown } from "@/shared/ui/markdown"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext"; +import { activityWindowMarkdownInteractive } from "../activityRenderClasses/activityWindowPresentation"; +import { isAgentActivityWindow } from "../../lib/agentActivityWindow"; import { MessageLinkHoverCue } from "../activityRenderClasses/MessageLinkHoverCue"; import { TranscriptTimestamp } from "../activityRenderClasses/TranscriptTimestamp"; import { useTranscriptBubbleOverflow } from "../activityRenderClasses/useTranscriptBubbleOverflow"; @@ -52,10 +54,12 @@ export function CompactMessageSummary({ const { goChannel } = useAppNavigation(); const { openProfilePanel } = useProfilePanel(); const isCompactPreview = variant === "compactPreview"; - const shouldClampBubble = !isCompactPreview; + const isDedicatedActivityWindow = isAgentActivityWindow(); + const shouldClampBubble = !isCompactPreview && !isDedicatedActivityWindow; + const effectiveMessageLink = isDedicatedActivityWindow ? null : messageLink; const [bubbleRef, hasBubbleOverflow] = useTranscriptBubbleOverflow(shouldClampBubble); - const canOpenMessage = shouldClampBubble && messageLink !== null; + const canOpenMessage = shouldClampBubble && effectiveMessageLink !== null; const mutedTone = compactSummaryTone(); const avatarClassName = cn( "mr-2 mt-1 shrink-0", @@ -63,19 +67,19 @@ export function CompactMessageSummary({ ); const handleBubbleClick = React.useCallback( (event: React.MouseEvent) => { - if (!messageLink || isNestedInteractiveTarget(event)) return; + if (!effectiveMessageLink || isNestedInteractiveTarget(event)) return; event.preventDefault(); event.stopPropagation(); - void goChannel(messageLink.channelId, { - messageId: messageLink.messageId, + void goChannel(effectiveMessageLink.channelId, { + messageId: effectiveMessageLink.messageId, }); }, - [goChannel, messageLink], + [effectiveMessageLink, goChannel], ); const handleBubbleKeyDown = React.useCallback( (event: React.KeyboardEvent) => { if ( - !messageLink || + !effectiveMessageLink || isNestedInteractiveTarget(event) || (event.key !== "Enter" && event.key !== " ") ) { @@ -84,11 +88,11 @@ export function CompactMessageSummary({ event.preventDefault(); event.stopPropagation(); - void goChannel(messageLink.channelId, { - messageId: messageLink.messageId, + void goChannel(effectiveMessageLink.channelId, { + messageId: effectiveMessageLink.messageId, }); }, - [goChannel, messageLink], + [effectiveMessageLink, goChannel], ); const bubbleLinkProps = canOpenMessage ? { @@ -101,7 +105,7 @@ export function CompactMessageSummary({ return ( <>
- {openProfilePanel && !isCompactPreview ? ( + {openProfilePanel && !isCompactPreview && !isDedicatedActivityWindow ? (
diff --git a/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx index 334a1b0807f..79e692fd832 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/PlanActivity.tsx @@ -1,4 +1,5 @@ import { Markdown } from "@/shared/ui/markdown"; +import { activityWindowMarkdownInteractive } from "./activityWindowPresentation"; import { ActivityRow, ActivityRowContent, @@ -39,8 +40,10 @@ export function PlanActivity(props: ActivityRenderClassItemProps) { diff --git a/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx b/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx index 8dfaea86ef6..3da04cefca2 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/ThoughtActivity.tsx @@ -1,4 +1,5 @@ import { Markdown } from "@/shared/ui/markdown"; +import { activityWindowMarkdownInteractive } from "./activityWindowPresentation"; import { ActivityRow, ActivityRowContent, @@ -24,8 +25,10 @@ export function ThoughtActivity(props: ActivityRenderClassItemProps) { diff --git a/desktop/src/features/agents/ui/activityRenderClasses/TranscriptTimestamp.tsx b/desktop/src/features/agents/ui/activityRenderClasses/TranscriptTimestamp.tsx index b544a391f92..b713e84197c 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/TranscriptTimestamp.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/TranscriptTimestamp.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { buildMessageLink } from "@/features/messages/lib/messageLink"; +import { isAgentActivityWindow } from "@/features/agents/lib/agentActivityWindow"; import { cn } from "@/shared/lib/cn"; import { formatTranscriptTime, @@ -24,17 +25,20 @@ export function TranscriptTimestamp({ }) { const formatted = formatTranscriptTime(timestamp); const { goChannel } = useAppNavigation(); - const href = messageLink ? buildMessageLink(messageLink) : null; + const effectiveMessageLink = isAgentActivityWindow() ? null : messageLink; + const href = effectiveMessageLink + ? buildMessageLink(effectiveMessageLink) + : null; const openMessage = React.useCallback( (event: React.MouseEvent) => { - if (!messageLink) return; + if (!effectiveMessageLink) return; event.preventDefault(); event.stopPropagation(); - void goChannel(messageLink.channelId, { - messageId: messageLink.messageId, + void goChannel(effectiveMessageLink.channelId, { + messageId: effectiveMessageLink.messageId, }); }, - [goChannel, messageLink], + [effectiveMessageLink, goChannel], ); if (!formatted) return null; diff --git a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx index 181e4febf5c..93a288e2669 100644 --- a/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx +++ b/desktop/src/features/agents/ui/activityRenderClasses/UserMessageBubble.tsx @@ -8,9 +8,11 @@ import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { cn } from "@/shared/lib/cn"; import { useProfilePanel } from "@/shared/context/ProfilePanelContext"; import { Markdown } from "@/shared/ui/markdown"; +import { activityWindowMarkdownInteractive } from "./activityWindowPresentation"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import { useAgentSessionTranscriptVariant } from "../agentSessionTranscriptContext"; import type { TranscriptItem } from "../agentSessionTypes"; +import { isAgentActivityWindow } from "../../lib/agentActivityWindow"; import { MessageLinkHoverCue } from "./MessageLinkHoverCue"; import { useTranscriptBubbleOverflow } from "./useTranscriptBubbleOverflow"; @@ -33,12 +35,16 @@ export function UserMessageBubble({ const { goChannel } = useAppNavigation(); const { openProfilePanel } = useProfilePanel(); const isCompactPreview = variant === "compactPreview"; - const shouldClampBubble = !isCompactPreview; + const isDedicatedActivityWindow = isAgentActivityWindow(); + const shouldClampBubble = !isCompactPreview && !isDedicatedActivityWindow; const [bubbleRef, hasBubbleOverflow] = useTranscriptBubbleOverflow(shouldClampBubble); const text = item.text.trim(); const messageLink = - shouldClampBubble && item.channelId && item.messageId + shouldClampBubble && + !isDedicatedActivityWindow && + item.channelId && + item.messageId ? { channelId: item.channelId, messageId: item.messageId } : null; const authorProfile = item.authorPubkey @@ -98,7 +104,9 @@ export function UserMessageBubble({ data-role="user-message" data-testid="transcript-user-message" > - {isCompactPreview ? null : item.authorPubkey && openProfilePanel ? ( + {isCompactPreview ? null : item.authorPubkey && + openProfilePanel && + !isDedicatedActivityWindow ? ( - - event.preventDefault()} + + + + + event.preventDefault()} + > + {!isDedicatedActivityWindow && sessionChannelId ? ( + <> + { + void handleOpenExternalWindow(); + }} + > + + Pop-out window + + + + ) : null} + { + event.preventDefault(); + handleRawFeedChange(!showRawFeed); + }} + title={ + showRawFeed + ? "Hide raw JSON-RPC payloads." + : sessionChannelId + ? "Show raw JSON-RPC payloads for this channel." + : "Show raw JSON-RPC payloads for this agent." + } + > + + + + Raw - - { - event.preventDefault(); - setTranscriptTimestampsEnabled(!showTimestamps); - }} - title={ - showTimestamps - ? "Hide per-row activity timestamps." - : "Show a timestamp under each activity row." - } - > - - - - Show Timestamps - + + Show raw JSON-RPC activity. - - - { - void handleInterruptTurn(); - }} - title={ - canStopCurrentTurn - ? "Interrupt the current ACP turn without stopping the agent process." - : isWorking - ? "Only locally managed agents can be interrupted from this community." - : "Available while the agent is working." - } - > - - - - Stop current turn - - {!canStopCurrentTurn ? ( - - {isWorking - ? "Only available for locally managed agents." - : "Available while the agent is working."} - - ) : null} + + + { + event.preventDefault(); + setTranscriptAnimationEnabled(!animateActivity); + }} + title={ + showRawFeed + ? "Raw activity rows don't animate in." + : animateActivity + ? "Stop animating new activity rows." + : "Animate new activity rows as they arrive." + } + > + + + + Show Animations - - - - ) : null} + + + ); @@ -469,6 +524,7 @@ export function AgentSessionThreadPanel({ backdrop={layout !== "split" && !isOverlay} backdropSurface="soft" inset={layout !== "split" ? "wide" : "default"} + showCloseAction={!isDedicatedActivityWindow} > {agentHeaderContent} diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx index cfa84f02e3c..4b719338809 100644 --- a/desktop/src/features/channels/ui/BotActivityBar.tsx +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Loader2 } from "lucide-react"; +import { ExternalLink, Loader2 } from "lucide-react"; import { useAgentTranscript } from "@/features/agents/ui/useObserverEvents"; import { @@ -10,6 +10,12 @@ import { import type { UserProfileLookup } from "@/features/profile/lib/identity"; import type { ManagedAgent } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuTrigger, +} from "@/shared/ui/context-menu"; import { DEFAULT_POPOVER_HOVER_OPEN_DELAY_MS, Popover, @@ -25,6 +31,10 @@ type BotActivityBarProps = { agents: BotActivityAgent[]; channelId?: string | null; onOpenAgentSession: (pubkey: string, channelId?: string | null) => void; + onOpenAgentSessionExternal: ( + pubkey: string, + channelId?: string | null, + ) => void; openAgentSessionPubkey: string | null; profiles?: UserProfileLookup; workingBotPubkeys: string[]; @@ -38,6 +48,7 @@ export function BotActivityComposerAction({ agents, channelId = null, onOpenAgentSession, + onOpenAgentSessionExternal, openAgentSessionPubkey, profiles, workingBotPubkeys, @@ -161,116 +172,147 @@ export function BotActivityComposerAction({ : `${workingAgents[0]?.name ?? "Agent"} +${workingAgents.length - 1}`; return ( - - - - - event.preventDefault()} - side="top" - sideOffset={8} - > -
- Agents working -
-
- {workingAgents.map((agent) => { - const isSelected = selectedPubkey === agent.pubkey.toLowerCase(); - - return ( - + + + event.preventDefault()} + side="top" + sideOffset={8} + > +
+ Agents working +
+
+ {workingAgents.map((agent) => { + const isSelected = selectedPubkey === agent.pubkey.toLowerCase(); + + return ( + + ); + })} +
+
+ + + {workingAgents.map((agent) => ( + { + clearHoverTimer(); + setOpen(false); + onOpenAgentSessionExternal(agent.pubkey, channelId); + }} + > + + + Pop-out window + {workingAgents.length > 1 ? ( + + {agent.name} - - - ); - })} -
-
-
+ ) : null} + + + ))} + + ); } diff --git a/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx b/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx index f99888f0112..034a6718cde 100644 --- a/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx +++ b/desktop/src/features/channels/ui/ChannelComposerActivityAccessory.tsx @@ -13,6 +13,9 @@ type ChannelComposerActivityAccessoryProps = { onOpenAgentSession: ComponentProps< typeof BotActivityComposerAction >["onOpenAgentSession"]; + onOpenAgentSessionExternal: ComponentProps< + typeof BotActivityComposerAction + >["onOpenAgentSessionExternal"]; openAgentSessionPubkey: ComponentProps< typeof BotActivityComposerAction >["openAgentSessionPubkey"]; @@ -27,6 +30,7 @@ export function ChannelComposerActivityAccessory({ channel, currentPubkey, onOpenAgentSession, + onOpenAgentSessionExternal, openAgentSessionPubkey, profiles, typingPubkeys, @@ -48,6 +52,7 @@ export function ChannelComposerActivityAccessory({ agents={agents} channelId={channel?.id ?? null} onOpenAgentSession={onOpenAgentSession} + onOpenAgentSessionExternal={onOpenAgentSessionExternal} openAgentSessionPubkey={openAgentSessionPubkey} profiles={profiles} workingBotPubkeys={workingBotPubkeys} diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs new file mode 100644 index 00000000000..620bec568f2 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { shouldShowAgentSessionUnavailable } from "./ChannelPane.helpers.ts"; + +test("unavailable activity state is exclusive to dedicated windows", () => { + const unresolvedSession = { + openAgentSessionPubkey: "agent-pubkey", + selectedAgent: null, + }; + + assert.equal( + shouldShowAgentSessionUnavailable({ + ...unresolvedSession, + isDedicatedActivityWindow: true, + }), + true, + ); + assert.equal( + shouldShowAgentSessionUnavailable({ + ...unresolvedSession, + isDedicatedActivityWindow: false, + }), + false, + ); +}); diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index cb0600a28ae..c46369dbb35 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -57,6 +57,22 @@ export function isChannelCreatedSystemMessage(message: TimelineMessage) { } } +export function shouldShowAgentSessionUnavailable({ + isDedicatedActivityWindow, + openAgentSessionPubkey, + selectedAgent, +}: { + isDedicatedActivityWindow: boolean; + openAgentSessionPubkey: string | null; + selectedAgent: unknown; +}): boolean { + return ( + isDedicatedActivityWindow && + openAgentSessionPubkey !== null && + selectedAgent == null + ); +} + export function mentionsKnownAgent( mentionPubkeys: string[], knownAgentPubkeys: ReadonlySet, diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index bccc163ed40..6765680ef37 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { Hash, LogIn } from "lucide-react"; +import { Hash, LoaderCircle, LogIn } from "lucide-react"; import { AnimatePresence } from "motion/react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; @@ -44,7 +44,10 @@ import { WelcomeComposerGuidanceLayer, } from "@/features/channels/ui/WelcomeComposerBanner"; import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeComposerBanner"; -import { mentionsKnownAgent } from "@/features/channels/ui/ChannelPane.helpers"; +import { + mentionsKnownAgent, + shouldShowAgentSessionUnavailable, +} from "@/features/channels/ui/ChannelPane.helpers"; import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; import { useChannelIntro } from "@/features/channels/ui/useChannelIntro"; import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; @@ -81,12 +84,14 @@ export const ChannelPane = React.memo(function ChannelPane({ historyExhausted, isFetchingOlder, isHuddleTranscript = false, + isDedicatedActivityWindow = false, followThreadById, isFollowingThread, isFollowingThreadById, isMessageUnreadById, isJoining = false, isSinglePanelView = false, + isAgentSessionLoading = false, isSending, isTimelineLoading, entranceMessageId = null, @@ -118,6 +123,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onExpandThreadReplies, onJoinChannel, onOpenAgentSession, + onOpenAgentSessionExternal, onOpenDm, onOpenMembers, onOpenProfilePanel, @@ -743,6 +749,7 @@ export const ChannelPane = React.memo(function ChannelPane({ channel={activeChannel} currentPubkey={currentPubkey} onOpenAgentSession={onOpenAgentSession} + onOpenAgentSessionExternal={onOpenAgentSessionExternal} openAgentSessionPubkey={openAgentSessionPubkey} profiles={profiles} typingPubkeys={typingPubkeys} @@ -845,6 +852,7 @@ export const ChannelPane = React.memo(function ChannelPane({ agents={activityAgents} channelId={activeChannel?.id ?? null} onOpenAgentSession={onOpenAgentSession} + onOpenAgentSessionExternal={onOpenAgentSessionExternal} openAgentSessionPubkey={openAgentSessionPubkey} profiles={profiles} workingBotPubkeys={threadComposerBotTypingPubkeys} @@ -872,15 +880,16 @@ export const ChannelPane = React.memo(function ChannelPane({ })() ) : activeChannel && selectedAgent ? ( (() => { - // When the panel was opened from a different channel than the - // currently active one, re-scope it to the active channel so - // that both the content/header AND channel-backed actions (e.g. - // Stop current turn) operate on the same channel object. + // In normal app navigation, re-scope a panel opened from another + // channel so its content and channel-backed actions stay aligned. + // A dedicated activity companion has an immutable channel scope, + // even when one of its links navigates the underlying route. const effectiveAgentSessionChannelId = - openAgentSessionChannelId && - activeChannel.id !== openAgentSessionChannelId - ? activeChannelId - : openAgentSessionChannelId; + isDedicatedActivityWindow || + !openAgentSessionChannelId || + activeChannel.id === openAgentSessionChannelId + ? openAgentSessionChannelId + : activeChannelId; const panel = ( +
+ {isAgentSessionLoading ? ( +
+
) : profilePanelPubkey ? ( (() => { const panel = ( diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 760ef58073b..6fab201ec2b 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -52,8 +52,10 @@ export type ChannelPaneProps = { isFetchingOlder?: boolean; /** A companion huddle window presents the channel only as a transcript. */ isHuddleTranscript?: boolean; + isDedicatedActivityWindow?: boolean; isJoining?: boolean; isSinglePanelView?: boolean; + isAgentSessionLoading?: boolean; isSending: boolean; isTimelineLoading: boolean; /** Newly-created message that should receive the one-shot conversation arrival motion. */ @@ -96,6 +98,10 @@ export type ChannelPaneProps = { onExpandThreadReplies: (message: TimelineMessage) => void; onJoinChannel?: () => Promise; onOpenAgentSession: (pubkey: string, channelId?: string | null) => void; + onOpenAgentSessionExternal: ( + pubkey: string, + channelId?: string | null, + ) => void; onOpenDm?: (pubkeys: string[]) => Promise | void; onOpenMembers?: () => void; onOpenProfilePanel: ( diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 6254afd8c71..b7c92f40e59 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -57,6 +57,7 @@ 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 { isAgentActivityWindow } from "@/features/agents/lib/agentActivityWindow"; import { ChannelScreenLoadingFallback } from "@/features/channels/ui/ChannelScreenLoadingFallback"; import { useHuddleChannelMessages, @@ -76,6 +77,7 @@ import { normalizePubkey } from "@/shared/lib/pubkey"; import { useChannelActivityTyping } from "./useChannelActivityTyping"; import { useChannelAgentSessions } from "./useChannelAgentSessions"; import { useMessageProfiles } from "./useMessageProfiles"; +import { useOpenAgentActivityActions } from "./useOpenAgentActivityActions"; import { useChannelPanelHistoryState } from "./useChannelPanelHistoryState"; import { useChannelProfilePanel } from "./useChannelProfilePanel"; import { useChannelRouteTarget } from "./useChannelRouteTarget"; @@ -95,6 +97,7 @@ export function ChannelScreen({ targetMessageEvents, targetMessageId, }: ChannelScreenProps) { + const isDedicatedActivityWindow = isAgentActivityWindow(); const { goHome } = useAppNavigation(); const { activeCommunity } = useCommunities(); const { @@ -214,6 +217,7 @@ export function ChannelScreen({ activeChannelId, activeChannel?.isMember, activeReadAt, + !isDedicatedActivityWindow, ); React.useEffect(() => { if (!activeChannelId) { @@ -253,6 +257,7 @@ export function ChannelScreen({ activeChannelId, activeChannelIsMember: activeChannel?.isMember, isHuddleTranscript, + enabled: !isDedicatedActivityWindow, markChannelRead, messages: messagesQuery.data, resolvedMessages, @@ -302,6 +307,10 @@ export function ChannelScreen({ welcomeGuideAgent, }); const relayAgentsQuery = useRelayAgentsQuery(); + const agentsLoading = + channelMembersQuery.isLoading || + managedAgentsQuery.isLoading || + relayAgentsQuery.isLoading; const relayAgents = relayAgentsQuery.data ?? []; const knownAgentPubkeys = React.useMemo( () => @@ -564,16 +573,14 @@ export function ChannelScreen({ } = useChannelAgentSessions({ activeChannel, activeChannelId, - agentsLoaded: - !channelMembersQuery.isLoading && - !managedAgentsQuery.isLoading && - !relayAgentsQuery.isLoading, + agentsLoaded: !agentsLoading, channelMembers, handleOpenThread, managedAgents: agentSessionCandidates, openAgentSessionPubkey, openThreadHeadId: effectiveOpenThreadHeadId, profilePanelPubkey, + preserveUnresolvedSession: isDedicatedActivityWindow, setChannelManagementOpen, setExpandedThreadReplyIds, setOpenAgentSessionChannelId, @@ -583,6 +590,14 @@ export function ChannelScreen({ setThreadReplyTargetId, setThreadScrollTargetId, }); + const { + openInApp: handleOpenAgentActivity, + openInExternalWindow: handleOpenAgentActivityExternal, + } = useOpenAgentActivityActions( + activeCommunity?.id ?? null, + activeChannelId, + handleOpenAgentSession, + ); const { handleOpenProfilePanel, handleCloseProfilePanel, handleOpenDm } = useChannelProfilePanel({ closeAgentSession: handleCloseAgentSession, @@ -688,9 +703,10 @@ export function ChannelScreen({ channelContentWidthPx > 0 && channelContentWidthPx < AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX; const isSinglePanelView = - isNarrowPanelViewport && - activeChannel?.channelType !== "forum" && - hasAuxiliaryPanel; + isDedicatedActivityWindow || + (isNarrowPanelViewport && + activeChannel?.channelType !== "forum" && + hasAuxiliaryPanel); const shouldCompactHeaderActions = hasAuxiliaryPanel && channelContentWidthPx > 0 && @@ -772,7 +788,7 @@ export function ChannelScreen({ ], ); return ( - + } @@ -853,6 +870,7 @@ export function ChannelScreen({ onOpenMembers={handleOpenMembersSidebar} isFetchingOlder={isFetchingOlder} isHuddleTranscript={isHuddleTranscript} + isDedicatedActivityWindow={isDedicatedActivityWindow} entranceMessageId={welcomeEntranceMessageId} onEntranceMessageComplete={handleWelcomeEntranceComplete} welcomeKickoffStage={welcomeKickoffStage} @@ -874,6 +892,7 @@ export function ChannelScreen({ isMessageUnreadById={isMessageUnread} isFollowingThread={isNotifiedForEffectiveThread} isSending={sendMessageMutation.isPending} + isAgentSessionLoading={agentsLoading} isSinglePanelView={isSinglePanelView} isTimelineLoading={isTimelineLoading} messages={timelineMessages} @@ -911,7 +930,8 @@ export function ChannelScreen({ onMarkUnread={handleMessageMarkUnread} onMarkRead={handleMessageMarkRead} onExpandThreadReplies={handleExpandThreadReplies} - onOpenAgentSession={handleOpenAgentSession} + onOpenAgentSession={handleOpenAgentActivity} + onOpenAgentSessionExternal={handleOpenAgentActivityExternal} onOpenDm={handleOpenDm} onOpenProfilePanel={handleOpenProfilePanel} onResetThreadPanelWidth={handleThreadPanelWidthReset} @@ -969,7 +989,7 @@ export function ChannelScreen({ currentPubkey={currentPubkey} open={isMembersSidebarOpen} onOpenChange={setIsMembersSidebarOpen} - onViewActivity={handleOpenAgentSession} + onViewActivity={handleOpenAgentActivity} relayUrl={activeCommunity?.relayUrl} /> diff --git a/desktop/src/features/channels/ui/ChannelScreenLoadingFallback.tsx b/desktop/src/features/channels/ui/ChannelScreenLoadingFallback.tsx index a566b71d906..461fb2d19a4 100644 --- a/desktop/src/features/channels/ui/ChannelScreenLoadingFallback.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenLoadingFallback.tsx @@ -2,13 +2,15 @@ import { HuddleStartingView } from "@/features/huddle/components/HuddleStartingV import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback"; export function ChannelScreenLoadingFallback({ + includeHeader = true, isHuddleTranscript, }: { + includeHeader?: boolean; isHuddleTranscript: boolean; }) { return isHuddleTranscript ? ( ) : ( - + ); } diff --git a/desktop/src/features/channels/ui/useChannelAgentSessions.test.mjs b/desktop/src/features/channels/ui/useChannelAgentSessions.test.mjs new file mode 100644 index 00000000000..68372aa2fbc --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelAgentSessions.test.mjs @@ -0,0 +1,31 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { shouldClearUnresolvedAgentSession } from "./useChannelAgentSessions.ts"; + +const unresolvedSession = { + agentSessionAgents: [], + agentsLoaded: true, + openAgentSessionPubkey: "agent-pubkey", + profilePanelPubkey: null, +}; + +test("dedicated activity windows preserve unresolved sessions for an empty state", () => { + assert.equal( + shouldClearUnresolvedAgentSession({ + ...unresolvedSession, + preserveUnresolvedSession: true, + }), + false, + ); +}); + +test("the in-app panel still clears stale unresolved sessions", () => { + assert.equal( + shouldClearUnresolvedAgentSession({ + ...unresolvedSession, + preserveUnresolvedSession: false, + }), + true, + ); +}); diff --git a/desktop/src/features/channels/ui/useChannelAgentSessions.ts b/desktop/src/features/channels/ui/useChannelAgentSessions.ts index 20c561e7981..cb4dc0e3a61 100644 --- a/desktop/src/features/channels/ui/useChannelAgentSessions.ts +++ b/desktop/src/features/channels/ui/useChannelAgentSessions.ts @@ -39,6 +39,7 @@ type UseChannelAgentSessionsOptions = { openAgentSessionPubkey: string | null; openThreadHeadId: string | null; profilePanelPubkey?: string | null; + preserveUnresolvedSession?: boolean; setChannelManagementOpen: (open: boolean) => void; setExpandedThreadReplyIds: (value: Set) => void; setOpenAgentSessionChannelId: PanelValueSetter; @@ -163,6 +164,33 @@ export function getChannelAgentSessionAgents({ }); } +export function shouldClearUnresolvedAgentSession({ + agentSessionAgents, + agentsLoaded, + openAgentSessionPubkey, + preserveUnresolvedSession, + profilePanelPubkey, +}: { + agentSessionAgents: ChannelAgentSessionAgent[]; + agentsLoaded: boolean; + openAgentSessionPubkey: string | null; + preserveUnresolvedSession: boolean; + profilePanelPubkey: string | null; +}): boolean { + return Boolean( + openAgentSessionPubkey && + !preserveUnresolvedSession && + agentsLoaded && + normalizePubkey(profilePanelPubkey ?? "") !== + normalizePubkey(openAgentSessionPubkey) && + !agentSessionAgents.some( + (agent) => + normalizePubkey(agent.pubkey) === + normalizePubkey(openAgentSessionPubkey), + ), + ); +} + export function useChannelAgentSessions({ activeChannel, activeChannelId, @@ -173,6 +201,7 @@ export function useChannelAgentSessions({ openAgentSessionPubkey, openThreadHeadId, profilePanelPubkey = null, + preserveUnresolvedSession = false, setChannelManagementOpen, setExpandedThreadReplyIds, setOpenAgentSessionChannelId, @@ -298,15 +327,13 @@ export function useChannelAgentSessions({ // agent queries have settled. Once loaded, a channel that legitimately has // zero agents will still auto-close a stale param. if ( - openAgentSessionPubkey && - agentsLoaded && - normalizePubkey(profilePanelPubkey ?? "") !== - normalizePubkey(openAgentSessionPubkey) && - !agentSessionAgents.some( - (agent) => - normalizePubkey(agent.pubkey) === - normalizePubkey(openAgentSessionPubkey), - ) + shouldClearUnresolvedAgentSession({ + agentSessionAgents, + agentsLoaded, + openAgentSessionPubkey, + preserveUnresolvedSession, + profilePanelPubkey, + }) ) { returnTarget.clear(); setOpenAgentSessionPubkey(null, { replace: true }); @@ -315,6 +342,7 @@ export function useChannelAgentSessions({ agentSessionAgents, agentsLoaded, openAgentSessionPubkey, + preserveUnresolvedSession, profilePanelPubkey, returnTarget, setOpenAgentSessionPubkey, diff --git a/desktop/src/features/channels/ui/useChannelOpenReadState.test.mjs b/desktop/src/features/channels/ui/useChannelOpenReadState.test.mjs index 4f54d8b3098..f19f3790312 100644 --- a/desktop/src/features/channels/ui/useChannelOpenReadState.test.mjs +++ b/desktop/src/features/channels/ui/useChannelOpenReadState.test.mjs @@ -1,7 +1,10 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getTopLevelInboxUnreadOverrideIds } from "./useChannelOpenReadState.ts"; +import { + getTopLevelInboxUnreadOverrideIds, + shouldAdvanceChannelReadState, +} from "./useChannelOpenReadState.ts"; test("opening a channel clears only its top-level Inbox overrides", () => { assert.deepEqual( @@ -23,3 +26,22 @@ test("opening a channel clears only its top-level Inbox overrides", () => { ["top-level"], ); }); + +test("passive activity companions never advance synced read state", () => { + assert.equal( + shouldAdvanceChannelReadState({ + activeChannelId: "general", + enabled: false, + isChannelMember: true, + }), + false, + ); + assert.equal( + shouldAdvanceChannelReadState({ + activeChannelId: "general", + enabled: true, + isChannelMember: true, + }), + true, + ); +}); diff --git a/desktop/src/features/channels/ui/useChannelOpenReadState.ts b/desktop/src/features/channels/ui/useChannelOpenReadState.ts index ff927c00bb0..a8336cd584c 100644 --- a/desktop/src/features/channels/ui/useChannelOpenReadState.ts +++ b/desktop/src/features/channels/ui/useChannelOpenReadState.ts @@ -17,16 +17,38 @@ export function getTopLevelInboxUnreadOverrideIds( ); } +export function shouldAdvanceChannelReadState({ + activeChannelId, + enabled, + isChannelMember, +}: { + activeChannelId: string | null; + enabled: boolean; + isChannelMember: boolean | undefined; +}): boolean { + return enabled && activeChannelId !== null && isChannelMember !== false; +} + export function useChannelOpenReadState( activeChannelId: string | null, isChannelMember: boolean | undefined, activeReadAt: string | null, + enabled = true, ) { const { feedItemState, locallyUnreadFeedItems, markChannelRead } = useAppShell(); React.useEffect(() => { - if (!activeChannelId || isChannelMember === false) return; + if ( + !shouldAdvanceChannelReadState({ + activeChannelId, + enabled, + isChannelMember, + }) || + !activeChannelId + ) { + return; + } for (const itemId of getTopLevelInboxUnreadOverrideIds( locallyUnreadFeedItems, activeChannelId, @@ -37,6 +59,7 @@ export function useChannelOpenReadState( }, [ activeChannelId, activeReadAt, + enabled, feedItemState.undoUnread, isChannelMember, locallyUnreadFeedItems, diff --git a/desktop/src/features/channels/ui/useHuddleReadMarker.test.mjs b/desktop/src/features/channels/ui/useHuddleReadMarker.test.mjs new file mode 100644 index 00000000000..90d4512e42d --- /dev/null +++ b/desktop/src/features/channels/ui/useHuddleReadMarker.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { after, afterEach, before, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +const dom = new JSDOM("", { + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, + window: dom.window, + }); +}); + +afterEach(async () => { + const { cleanup } = await import("@testing-library/react"); + cleanup(); +}); + +after(() => dom.window.close()); + +const topLevelMessage = { + content: "message", + created_at: 1_724_457_600, + id: "message-id", + kind: 9, + pubkey: "author", + sig: "signature", + tags: [["h", "general"]], +}; + +async function mountReadMarker(enabled) { + const { renderHook } = await import("@testing-library/react"); + const { useHuddleReadMarker } = await import("./useHuddleReadMarker.ts"); + const calls = []; + const view = renderHook(() => + useHuddleReadMarker({ + activeChannelId: "general", + activeChannelIsMember: true, + enabled, + isHuddleTranscript: false, + markChannelRead: (...args) => calls.push(args), + messages: [topLevelMessage], + resolvedMessages: [topLevelMessage], + }), + ); + return { calls, view }; +} + +test("disabled activity companions do not advance the huddle read marker", async () => { + const { calls } = await mountReadMarker(false); + assert.deepEqual(calls, []); +}); + +test("enabled channel screens retain the top-level read marker", async () => { + const { calls } = await mountReadMarker(true); + assert.deepEqual(calls, [ + ["general", "2024-08-24T00:00:00.000Z", { topLevelOnly: true }], + ]); +}); diff --git a/desktop/src/features/channels/ui/useHuddleReadMarker.ts b/desktop/src/features/channels/ui/useHuddleReadMarker.ts index a9188fa27ff..4c17cd0d5aa 100644 --- a/desktop/src/features/channels/ui/useHuddleReadMarker.ts +++ b/desktop/src/features/channels/ui/useHuddleReadMarker.ts @@ -13,6 +13,7 @@ type HuddleReadMarkerOptions = { activeChannelId: string | null; activeChannelIsMember: boolean | undefined; isHuddleTranscript: boolean; + enabled?: boolean; markChannelRead: MarkChannelRead; messages: RelayEvent[] | undefined; resolvedMessages: RelayEvent[]; @@ -22,6 +23,7 @@ export function useHuddleReadMarker({ activeChannelId, activeChannelIsMember, isHuddleTranscript, + enabled = true, markChannelRead, messages, resolvedMessages, @@ -65,7 +67,7 @@ export function useHuddleReadMarker({ const lastHuddleReadKeyRef = React.useRef(null); React.useEffect(() => { - if (!activeChannelId || activeChannelIsMember === false) return; + if (!enabled || !activeChannelId || activeChannelIsMember === false) return; const huddleReadKey = hasFlattenedHuddleReplies ? `${activeChannelId}:${huddleReadAt}` : null; @@ -82,6 +84,7 @@ export function useHuddleReadMarker({ }, [ activeChannelId, activeChannelIsMember, + enabled, hasFlattenedHuddleReplies, huddleReadAt, markChannelRead, diff --git a/desktop/src/features/channels/ui/useOpenAgentActivityActions.ts b/desktop/src/features/channels/ui/useOpenAgentActivityActions.ts new file mode 100644 index 00000000000..0fd72f06b34 --- /dev/null +++ b/desktop/src/features/channels/ui/useOpenAgentActivityActions.ts @@ -0,0 +1,49 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { isAgentActivityWindow } from "@/features/agents/lib/agentActivityWindow"; +import { openAgentActivityWindow } from "@/shared/api/agentActivityWindow"; + +type OpenAgentSession = (pubkey: string, channelId?: string | null) => void; + +export function useOpenAgentActivityActions( + activeCommunityId: string | null, + activeChannelId: string | null, + openAgentSession: OpenAgentSession, +) { + const openInExternalWindow = React.useCallback( + (pubkey: string, channelId?: string | null) => { + const destinationChannelId = channelId ?? activeChannelId; + if (!destinationChannelId || !activeCommunityId) return; + + if (isAgentActivityWindow()) { + openAgentSession(pubkey, destinationChannelId); + return; + } + + void openAgentActivityWindow( + activeCommunityId, + destinationChannelId, + pubkey, + ) + .then((openedNativeWindow) => { + if (!openedNativeWindow) { + openAgentSession(pubkey, destinationChannelId); + } + }) + .catch((error) => { + console.error("Failed to open agent activity window:", error); + toast.error("Couldn't open the agent activity window."); + }); + }, + [activeChannelId, activeCommunityId, openAgentSession], + ); + const openInApp = React.useCallback( + (pubkey: string, channelId?: string | null) => { + openAgentSession(pubkey, channelId ?? activeChannelId); + }, + [activeChannelId, openAgentSession], + ); + + return { openInApp, openInExternalWindow }; +} diff --git a/desktop/src/features/communities/useCommunities.tsx b/desktop/src/features/communities/useCommunities.tsx index e0a10017883..d92d512918d 100644 --- a/desktop/src/features/communities/useCommunities.tsx +++ b/desktop/src/features/communities/useCommunities.tsx @@ -154,8 +154,14 @@ export type UseCommunitiesReturn = { const CommunitiesContext = createContext(null); -export function CommunitiesProvider({ children }: { children: ReactNode }) { - const value = useCommunitiesInternal(); +export function CommunitiesProvider({ + children, + initialActiveCommunityId, +}: { + children: ReactNode; + initialActiveCommunityId?: string | null; +}) { + const value = useCommunitiesInternal(initialActiveCommunityId); return ( {children} @@ -171,19 +177,26 @@ export function useCommunities(): UseCommunitiesReturn { return ctx; } -function useCommunitiesInternal(): UseCommunitiesReturn { +function useCommunitiesInternal( + initialActiveCommunityId?: string | null, +): UseCommunitiesReturn { const [communities, setCommunitiesState] = useState(loadCommunities); - const [activeId, setActiveId] = useState( - loadActiveCommunityId, + const [activeId, setActiveId] = useState(() => + initialActiveCommunityId === undefined + ? loadActiveCommunityId() + : initialActiveCommunityId, ); const [reinitKey, setReinitKey] = useState(0); const communitiesRef = useRef(communities); communitiesRef.current = communities; const activeCommunity = useMemo( - () => communities.find((w) => w.id === activeId) ?? communities[0] ?? null, - [communities, activeId], + () => + communities.find((community) => community.id === activeId) ?? + (initialActiveCommunityId === undefined ? communities[0] : null) ?? + null, + [activeId, communities, initialActiveCommunityId], ); const addCommunity = useCallback((community: Community): string => { diff --git a/desktop/src/features/communities/useCommunityInit.ts b/desktop/src/features/communities/useCommunityInit.ts index 5493a47b1e3..2f0034b7b7c 100644 --- a/desktop/src/features/communities/useCommunityInit.ts +++ b/desktop/src/features/communities/useCommunityInit.ts @@ -106,6 +106,7 @@ export function useCommunityInit( communityKey: string, isSharedIdentity: boolean, suppressAutoConnect = false, + applyBackend = true, ): CommunityInitResult { const [result, setResult] = useState({ isReady: false, @@ -289,13 +290,15 @@ export function useCommunityInit( // imported key. `loadCommunities()` strips lingering `nsec` fields from // legacy entries; this site refuses to apply one even if present. try { - await applyCommunity( - activeCommunity.relayUrl, - undefined, - activeCommunity.token, - activeCommunity.reposDir, - getOverrides().agentManagedProfiles === true, - ); + if (applyBackend) { + await applyCommunity( + activeCommunity.relayUrl, + undefined, + activeCommunity.token, + activeCommunity.reposDir, + getOverrides().agentManagedProfiles === true, + ); + } } catch (error) { // A bad `repos_dir` no longer reaches here — `apply_workspace` treats // it as non-fatal (relay/keys apply, bad value not persisted, REPOS @@ -356,6 +359,7 @@ export function useCommunityInit( activeCommunity?.relayUrl, activeCommunity?.token, activeCommunity?.reposDir, + applyBackend, isSharedIdentity, suppressAutoConnect, communityKey, diff --git a/desktop/src/features/local-archive/useArchiveSync.test.mjs b/desktop/src/features/local-archive/useArchiveSync.test.mjs index 672c197582e..057f7c8c0bd 100644 --- a/desktop/src/features/local-archive/useArchiveSync.test.mjs +++ b/desktop/src/features/local-archive/useArchiveSync.test.mjs @@ -75,7 +75,7 @@ function mountGate({ }, // `getCurrentWindow()` reads exactly this path (api/window.js:85), and // `isTauri()` reads `globalThis.isTauri` (api/core.js:280). Both are what - // `huddleWindowChannelId` uses to tell a companion realm from the main one. + // `isCompanionWindow` uses to tell a companion realm from the main one. metadata: { currentWindow: { label: windowLabel } }, transformCallback: () => Math.random(), }; @@ -265,25 +265,28 @@ describe("archive sync lifecycle leases", () => { // commands at all. describe("archive sync realm ownership", () => { - it("issues no archive lifecycle commands from a companion window", async () => { - const gate = mountGate({ - windowLabel: "huddle-11111111-2222-3333-4444-555555555555", + for (const windowLabel of [ + "huddle-test-channel-do-not-use", + "agent-activity-test-agent-do-not-use-test-channel-do-not-use", + ]) { + it(`issues no archive lifecycle commands from ${windowLabel}`, async () => { + const gate = mountGate({ windowLabel }); + await gate.mount(); + await gate.settleGate(); + await gate.unmount(); + + assert.deepEqual( + gate.invoked.filter( + (cmd) => + cmd.endsWith("_archive_sync") || + cmd === "announce_archive_sync_epoch", + ), + [], + "a companion realm must not announce or run lifecycle commands — its " + + "cleanup would cancel the main window's live sync task", + ); }); - await gate.mount(); - await gate.settleGate(); - await gate.unmount(); - - assert.deepEqual( - gate.invoked.filter( - (cmd) => - cmd.endsWith("_archive_sync") || - cmd === "announce_archive_sync_epoch", - ), - [], - "a companion realm must not announce or run lifecycle commands — its " + - "cleanup would cancel the main window's live sync task", - ); - }); + } it("still runs the lifecycle in the main window", async () => { const gate = mountGate({ windowLabel: "main" }); diff --git a/desktop/src/features/local-archive/useArchiveSync.ts b/desktop/src/features/local-archive/useArchiveSync.ts index 666b1aac725..0aabff5a0a5 100644 --- a/desktop/src/features/local-archive/useArchiveSync.ts +++ b/desktop/src/features/local-archive/useArchiveSync.ts @@ -1,6 +1,6 @@ import * as React from "react"; -import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; +import { isCompanionWindow } from "@/app/companionWindow"; import { announceArchiveSyncEpoch, nextArchiveSyncLease, @@ -60,7 +60,7 @@ export function useArchiveSync(ready: boolean): void { React.useEffect(() => { if (!ready) return; // Companion realms do not participate; see the ownership rule above. - if (huddleWindowChannelId() !== null) return; + if (isCompanionWindow()) return; const lease = nextArchiveSyncLease(); let stopped = false; diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 634a9b171a1..a1cefaa2ff8 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -1,3 +1,4 @@ +import { getCurrentWindow } from "@tauri-apps/api/window"; import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "@/app/App"; @@ -10,8 +11,13 @@ import "@fontsource/jetbrains-mono/700.css"; import "@/shared/styles/globals.css"; import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider"; import { migrateLegacyCommunityStorageBeforeRender } from "@/features/communities/legacyCommunityStorage"; +import { loadCommunities } from "@/features/communities/communityStorage"; import { CommunitiesProvider } from "@/features/communities/useCommunities"; -import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow"; +import { + companionCommunityBootstrap, + currentCompanionWindowKind, + isCompanionWindow, +} from "@/app/companionWindow"; import { CommunityOnboardingProvider } from "@/features/onboarding/communityOnboarding"; import { ThemeProvider } from "@/shared/theme/ThemeProvider"; import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider"; @@ -78,16 +84,57 @@ function configureDevE2eBridgeFromUrl() { ); } +function renderCompanionBootstrapError(message: string) { + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( +
+
+

Couldn’t open activity

+

{message}

+ +
+
, + ); +} + function renderApp() { + const companionKind = currentCompanionWindowKind(); + const companionCommunity = companionCommunityBootstrap( + companionKind, + window.location.hash, + ); + if (companionCommunity.missingRequiredCommunity) { + renderCompanionBootstrapError( + "This window is missing its community context. Close it and try again.", + ); + return; + } + if ( + companionCommunity.initialActiveCommunityId && + !loadCommunities().some( + ({ id }) => id === companionCommunity.initialActiveCommunityId, + ) + ) { + renderCompanionBootstrapError( + "This community is no longer available. Close this window and open activity again from Buzz.", + ); + return; + } + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( {/* block/buzz#5078 — catch any uncaught render error so a WebKit SecurityError from localStorage can't blank the whole window. */} - - + + diff --git a/desktop/src/shared/api/agentActivityWindow.ts b/desktop/src/shared/api/agentActivityWindow.ts new file mode 100644 index 00000000000..dbf1d695340 --- /dev/null +++ b/desktop/src/shared/api/agentActivityWindow.ts @@ -0,0 +1,15 @@ +import { invoke, isTauri } from "@tauri-apps/api/core"; + +/** Opens a channel-scoped agent activity feed in its native companion window. */ +export async function openAgentActivityWindow( + communityId: string, + channelId: string, + pubkey: string, +): Promise { + if (!isTauri()) return false; + return invoke("open_agent_activity_window", { + communityId, + channelId, + pubkey, + }); +} diff --git a/desktop/src/shared/hooks/useHistorySearchState.ts b/desktop/src/shared/hooks/useHistorySearchState.ts index dd8d39c3cee..94efbc380de 100644 --- a/desktop/src/shared/hooks/useHistorySearchState.ts +++ b/desktop/src/shared/hooks/useHistorySearchState.ts @@ -1,3 +1,4 @@ +import { pinCurrentAgentActivityCompanionSearch } from "@/app/companionWindow"; import * as React from "react"; import { useNavigate, useSearch } from "@tanstack/react-router"; @@ -85,7 +86,10 @@ export function useHistorySearchState(keys: readonly K[]) { nextSearch[key] = value; } } - return nextSearch; + return pinCurrentAgentActivityCompanionSearch( + previousSearch, + nextSearch, + ); }, replace: flush.replace, resetScroll: false, diff --git a/desktop/src/shared/layout/AuxiliaryPanelHeader.tsx b/desktop/src/shared/layout/AuxiliaryPanelHeader.tsx index 5140c4d8d52..547e7f81e09 100644 --- a/desktop/src/shared/layout/AuxiliaryPanelHeader.tsx +++ b/desktop/src/shared/layout/AuxiliaryPanelHeader.tsx @@ -19,6 +19,7 @@ type AuxiliaryPanelHeaderProps = Omit< inset?: "default" | "wide"; mode?: AuxiliaryPanelMode; resizeBorder?: boolean; + showCloseAction?: boolean; surface?: AuxiliaryPanelSurface; /** Render header content without its own backdrop for a shared parent chrome. */ transparent?: boolean; @@ -113,6 +114,7 @@ export function AuxiliaryPanelHeader({ inset = "default", mode, resizeBorder = false, + showCloseAction = true, surface = "default", transparent, ...props @@ -159,7 +161,7 @@ export function AuxiliaryPanelHeader({ data-tauri-drag-region {...props} > - {renderAuxiliaryPanelHeaderContent(children)} + {renderAuxiliaryPanelHeaderContent(children, showCloseAction)} ); @@ -181,15 +183,21 @@ export function AuxiliaryPanelHeader({ data-tauri-drag-region >
- {renderAuxiliaryPanelHeaderContent(children)} + {renderAuxiliaryPanelHeaderContent(children, showCloseAction)}
); } -function renderAuxiliaryPanelHeaderContent(children: React.ReactNode) { - const { foundActions, content } = attachCloseActionToHeaderActions(children); +function renderAuxiliaryPanelHeaderContent( + children: React.ReactNode, + showCloseAction: boolean, +) { + const { foundActions, content } = attachCloseActionToHeaderActions( + children, + showCloseAction, + ); if (foundActions) { return content; @@ -198,12 +206,15 @@ function renderAuxiliaryPanelHeaderContent(children: React.ReactNode) { return ( <> {children} - + ); } -function attachCloseActionToHeaderActions(children: React.ReactNode): { +function attachCloseActionToHeaderActions( + children: React.ReactNode, + includeCloseAction: boolean, +): { content: React.ReactNode; foundActions: boolean; } { @@ -216,11 +227,14 @@ function attachCloseActionToHeaderActions(children: React.ReactNode): { if (child.type === AuxiliaryPanelHeaderActions) { foundActions = true; - return React.cloneElement(child, { includeCloseAction: true }); + return React.cloneElement(child, { includeCloseAction }); } if (child.type === React.Fragment) { - const nested = attachCloseActionToHeaderActions(child.props.children); + const nested = attachCloseActionToHeaderActions( + child.props.children, + includeCloseAction, + ); if (!nested.foundActions) { return child; diff --git a/desktop/src/shared/layout/auxiliaryPanelContext.test.mjs b/desktop/src/shared/layout/auxiliaryPanelContext.test.mjs index 69cea299740..7a06e9d6614 100644 --- a/desktop/src/shared/layout/auxiliaryPanelContext.test.mjs +++ b/desktop/src/shared/layout/auxiliaryPanelContext.test.mjs @@ -8,6 +8,7 @@ import { AuxiliaryPanel } from "./AuxiliaryPanel/index.ts"; import { AuxiliaryPanelBody } from "./AuxiliaryPanel/index.ts"; import { AuxiliaryPanelHeader, + AuxiliaryPanelHeaderActions, AuxiliaryPanelHeaderGroup, } from "./AuxiliaryPanel/index.ts"; import { @@ -177,6 +178,33 @@ test("AuxiliaryPanelHeader renders a generic close action from context", () => { assert.match(html, /data-testid="auxiliary-panel-close"/); }); +test("AuxiliaryPanelHeader can suppress its close action", () => { + const html = render( + React.createElement( + AuxiliaryPanel, + { + header: React.createElement( + AuxiliaryPanelHeader, + { showCloseAction: false }, + React.createElement(AuxiliaryPanelHeaderGroup, null, "Title"), + React.createElement( + AuxiliaryPanelHeaderActions, + null, + React.createElement("button", { type: "button" }, "Settings"), + ), + ), + onClose: () => {}, + widthPx: 420, + }, + "Panel", + ), + ); + + assert.match(html, />Settings { const html = render( React.createElement( diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index e4028f01716..8bebac43181 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) => diff --git a/desktop/tests/e2e/agent-activity-window.spec.ts b/desktop/tests/e2e/agent-activity-window.spec.ts new file mode 100644 index 00000000000..31f1934ee03 --- /dev/null +++ b/desktop/tests/e2e/agent-activity-window.spec.ts @@ -0,0 +1,305 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge, TEST_IDENTITIES } from "../helpers/bridge"; + +const AGENT_PUBKEY = TEST_IDENTITIES.tyler.pubkey; +const REPOSITORY_OWNER_PUBKEY = TEST_IDENTITIES.alice.pubkey; +const AGENTS_CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; +const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; +const COMMUNITY_ID = "e2e-default-community"; + +test("keeps dedicated activity windows pinned to their original channel", async ({ + page, +}) => { + await page.setViewportSize({ width: 560, height: 760 }); + await installMockBridge(page, { + windowLabel: `agent-activity-${AGENT_PUBKEY}-${AGENTS_CHANNEL_ID}`, + managedAgents: [ + { + pubkey: AGENT_PUBKEY, + name: "Observer Agent", + status: "running", + channelNames: ["agents"], + }, + ], + }); + + await page.goto( + `/#/channels/${AGENTS_CHANNEL_ID}?community=${COMMUNITY_ID}&agentSession=${AGENT_PUBKEY}&agentSessionChannel=${AGENTS_CHANNEL_ID}`, + ); + const panel = page.getByTestId("agent-session-thread-panel"); + await expect(panel).toBeVisible({ timeout: 10_000 }); + await page.waitForFunction( + () => typeof window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__ === "function", + ); + await page.evaluate( + ({ agentPubkey, channelId, generalChannelId, repositoryOwnerPubkey }) => { + window.__BUZZ_E2E_SEED_OBSERVER_EVENTS__?.({ + agentPubkey, + events: [ + { + seq: 1, + timestamp: new Date().toISOString(), + kind: "acp_read", + agentIndex: 0, + channelId, + sessionId: "session-activity-window", + turnId: "turn-activity-window", + payload: { + method: "session/update", + params: { + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: `Continue in #general or open \n\n\`\`\`text\nverification-${"x".repeat(120)}-END\n\`\`\``, + }, + }, + }, + }, + }, + { + seq: 2, + timestamp: new Date().toISOString(), + kind: "acp_read", + agentIndex: 0, + channelId, + sessionId: "session-user-message", + turnId: "turn-user-message", + payload: { + method: "session/update", + params: { + update: { + sessionUpdate: "user_message_chunk", + messageId: "b".repeat(64), + authorPubkey: repositoryOwnerPubkey, + content: { + type: "text", + text: `${Array.from({ length: 11 }, (_, index) => `Prompt line ${index + 1}: inspect every detail in the dedicated activity feed.`).join("\n\n")}\n\nPrompt line 12: FINAL-PROMPT-LINE`, + }, + }, + }, + }, + }, + { + seq: 3, + timestamp: new Date().toISOString(), + kind: "acp_read", + agentIndex: 0, + channelId, + sessionId: "session-sent-message", + turnId: "turn-sent-message", + payload: { + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call_update", + toolCallId: "sent-message-activity-window", + status: "completed", + title: "send_message", + toolName: "send_message", + rawInput: { + channel_id: channelId, + content: "Sent update for #general", + }, + content: { + type: "text", + text: JSON.stringify({ + accepted: true, + event_id: "mock-agents-charlie", + }), + }, + }, + }, + }, + }, + { + seq: 4, + timestamp: new Date().toISOString(), + kind: "turn_started", + agentIndex: 0, + channelId, + sessionId: "session-original-channel", + turnId: "turn-original-channel", + payload: { source: "channel", triggeringEventIds: [] }, + }, + { + seq: 5, + timestamp: new Date().toISOString(), + kind: "acp_read", + agentIndex: 0, + channelId: generalChannelId, + sessionId: "session-other-channel", + turnId: "turn-other-channel", + payload: { + method: "session/update", + params: { + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "Other channel activity must stay hidden", + }, + }, + }, + }, + }, + ], + }); + }, + { + agentPubkey: AGENT_PUBKEY, + channelId: AGENTS_CHANNEL_ID, + generalChannelId: GENERAL_CHANNEL_ID, + repositoryOwnerPubkey: REPOSITORY_OWNER_PUBKEY, + }, + ); + + await expect(panel).toContainText("Activity · #agents"); + await expect(panel).not.toContainText( + "Other channel activity must stay hidden", + ); + + const channelReference = panel + .getByTestId("transcript-assistant-message") + .locator("[data-channel-link]"); + await expect(channelReference).toContainText("general"); + await expect( + panel.getByRole("button", { name: "Open channel general" }), + ).toHaveCount(0); + const codeBlock = panel + .getByTestId("transcript-assistant-message") + .locator("pre"); + await expect(codeBlock).toHaveCount(1); + await expect(codeBlock).toContainText("-END"); + const codeOverflow = await codeBlock.evaluate((element) => ({ + clientWidth: element.clientWidth, + overflowX: getComputedStyle(element).overflowX, + scrollWidth: element.scrollWidth, + })); + expect(codeOverflow.overflowX).toBe("auto"); + expect(codeOverflow.scrollWidth).toBeGreaterThan(codeOverflow.clientWidth); + await codeBlock.evaluate((element) => { + element.scrollLeft = element.scrollWidth; + }); + await expect + .poll(() => codeBlock.evaluate((element) => element.scrollLeft)) + .toBeGreaterThan(0); + const activityWindowUrl = page.url(); + + await expect( + panel.getByRole("button", { name: "Open alice profile" }), + ).toHaveCount(0); + await expect(panel).toBeVisible(); + await expect(page).toHaveURL(/agentSession=/); + await expect(page).toHaveURL(/agentSessionChannel=/); + + await expect(panel).toContainText("relay-tools"); + await expect( + panel.getByRole("button", { name: "Open repository relay-tools" }), + ).toHaveCount(0); + await expect(page).toHaveURL(activityWindowUrl); + + const sentMessage = panel.getByTestId("transcript-tool-message-preview"); + await expect(sentMessage).toContainText("Sent update for"); + await expect(sentMessage).not.toHaveAttribute("role", "link"); + await expect( + panel.getByRole("button", { name: "Open Observer Agent profile" }), + ).toHaveCount(0); + await expect( + sentMessage.getByRole("button", { name: "Open channel general" }), + ).toHaveCount(0); + await expect(sentMessage.locator("[data-channel-link]")).toContainText( + "general", + ); + const userMessage = panel.getByTestId("transcript-user-message"); + const userBubble = userMessage.locator(":scope > div > div").first(); + await expect(userBubble).toContainText("FINAL-PROMPT-LINE"); + const promptTailGeometry = await userBubble.evaluate((bubble) => { + const walker = document.createTreeWalker(bubble, NodeFilter.SHOW_TEXT); + let tailNode: Text | null = null; + while (walker.nextNode()) { + const node = walker.currentNode as Text; + if (node.data.includes("FINAL-PROMPT-LINE")) { + tailNode = node; + break; + } + } + if (!tailNode) + throw new Error("Final prompt line text node was not found."); + + const tailStart = tailNode.data.indexOf("FINAL-PROMPT-LINE"); + const range = document.createRange(); + range.setStart(tailNode, tailStart); + range.setEnd(tailNode, tailStart + "FINAL-PROMPT-LINE".length); + const bubbleRect = bubble.getBoundingClientRect(); + const tailRect = range.getBoundingClientRect(); + return { + bubbleBottom: bubbleRect.bottom, + bubbleTop: bubbleRect.top, + overflowY: getComputedStyle(bubble).overflowY, + tailBottom: tailRect.bottom, + tailTop: tailRect.top, + }; + }); + expect(promptTailGeometry.overflowY).toBe("visible"); + expect(promptTailGeometry.tailTop).toBeGreaterThanOrEqual( + promptTailGeometry.bubbleTop, + ); + expect(promptTailGeometry.tailBottom).toBeLessThanOrEqual( + promptTailGeometry.bubbleBottom + 1, + ); + const userTimestamp = userMessage.locator("span[title]").last(); + await expect(userTimestamp).toBeVisible(); + await expect(userTimestamp).toHaveCSS("cursor", "default"); + await expect(panel.getByTestId("transcript-open-message-link")).toHaveCount( + 0, + ); + const openedPage = page + .context() + .waitForEvent("page", { timeout: 500 }) + .then(() => true) + .catch(() => false); + await userTimestamp.click({ button: "middle" }); + expect(await openedPage).toBe(false); + await sentMessage.click(); + await expect(panel).toBeVisible(); + await expect(page.getByTestId("message-thread-panel")).toHaveCount(0); + await expect(page).toHaveURL(activityWindowUrl); + + await page.getByTestId("agent-session-settings-menu-trigger").click(); + const rawFeedToggle = page.getByTestId("agent-session-toggle-raw-feed"); + await expect(rawFeedToggle).toHaveAttribute( + "title", + "Show raw JSON-RPC payloads for this channel.", + ); + const stopTurn = page.getByTestId("agent-session-stop-turn"); + await expect(stopTurn).toBeEnabled(); + + await page.evaluate(() => { + const tauri = window.__TAURI_INTERNALS__; + const invoke = tauri?.invoke; + if (!tauri || !invoke) + throw new Error("Mock invoke bridge is unavailable."); + window.__BUZZ_E2E_ACTIVITY_CONTROL_REQUESTS__ = []; + tauri.invoke = async (command, payload) => { + if (command === "build_observer_control_event") { + window.__BUZZ_E2E_ACTIVITY_CONTROL_REQUESTS__?.push(payload); + throw new Error("Control request captured by the E2E test."); + } + return invoke(command, payload); + }; + }); + await stopTurn.click(); + await expect + .poll(() => + page.evaluate( + () => window.__BUZZ_E2E_ACTIVITY_CONTROL_REQUESTS__?.[0] ?? null, + ), + ) + .toMatchObject({ + agentPubkey: AGENT_PUBKEY, + payload: { type: "cancel_turn", channelId: AGENTS_CHANNEL_ID }, + }); + await expect(page.locator("body")).not.toBeEmpty(); +}); diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 52d91dea3ed..136b9f754fd 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -2081,6 +2081,9 @@ test("shows and clears activity indicators for active channel agents", async ({ await expect(page.getByTestId("auxiliary-panel-close")).toBeVisible(); await expect(page.getByTestId("agent-transcript-now-summary")).toHaveCount(0); await page.getByTestId("agent-session-settings-menu-trigger").click(); + await expect( + page.getByTestId("agent-session-open-external-window"), + ).toContainText("Pop-out window"); await expect(page.getByTestId("agent-session-stop-turn")).toBeVisible(); await expect(page.getByTestId("agent-session-stop-turn")).toBeDisabled(); await page.keyboard.press("Escape"); @@ -2088,6 +2091,16 @@ test("shows and clears activity indicators for active channel agents", async ({ "No ACP activity yet", ); await expect(page.getByTestId("message-typing-indicator")).toHaveCount(0); + await page.getByTestId("auxiliary-panel-close").click(); + await page + .getByTestId("bot-activity-composer-trigger") + .click({ button: "right" }); + await expect( + page.getByTestId( + `bot-activity-open-external-${TEST_IDENTITIES.alice.pubkey}`, + ), + ).toContainText("Pop-out window"); + await page.keyboard.press("Escape"); await page.evaluate((pubkey) => { window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({