Open agent activity in companion window

Co-authored-by: Rizz <302abe414ca6e3134763d2539bfcf145aea2a63fe5f8455204ed602fd40cf381@buzz.block.builderlab.xyz>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
This commit is contained in:
Taylor Ho
2026-08-22 11:35:31 -07:00
committed by Rizz
parent 0720f5380c
commit e315b0d02b
10 changed files with 191 additions and 36 deletions

View File

@@ -0,0 +1,79 @@
//! Native companion-window lifecycle for an agent activity feed.
use tauri::{Manager, WebviewUrl, WebviewWindowBuilder};
use uuid::Uuid;
const PUBKEY_HEX_LENGTH: usize = 64;
fn normalized_pubkey(pubkey: &str) -> Result<String, String> {
let normalized = pubkey.trim().to_ascii_lowercase();
if normalized.len() != PUBKEY_HEX_LENGTH
|| !normalized.bytes().all(|byte| byte.is_ascii_hexdigit())
{
return Err("agent pubkey must be 64 hexadecimal characters".to_string());
}
Ok(normalized)
}
fn window_label(channel_id: &Uuid, pubkey: &str) -> String {
format!("agent-activity-{pubkey}-{channel_id}")
}
/// Open an agent's channel-scoped activity feed without replacing the main
/// window's thread panel. Each agent/channel pair owns one reusable window.
#[tauri::command]
pub fn open_agent_activity_window(
app: tauri::AppHandle,
channel_id: String,
pubkey: String,
) -> Result<bool, String> {
let channel_id =
Uuid::parse_str(channel_id.trim()).map_err(|_| "channel id must be a UUID".to_string())?;
let pubkey = normalized_pubkey(&pubkey)?;
let label = window_label(&channel_id, &pubkey);
if let Some(window) = app.get_webview_window(&label) {
window.show().map_err(|error| error.to_string())?;
window.set_focus().map_err(|error| error.to_string())?;
return Ok(true);
}
let route = format!("index.html#/channels/{channel_id}?agentSession={pubkey}");
WebviewWindowBuilder::new(&app, label, WebviewUrl::App(route.into()))
.title("Agent activity")
.inner_size(560.0, 760.0)
.min_inner_size(420.0, 520.0)
.build()
.map_err(|error| error.to_string())?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::{normalized_pubkey, window_label};
use uuid::Uuid;
#[test]
fn normalizes_valid_pubkeys() {
let uppercase = "AB".repeat(32);
assert_eq!(normalized_pubkey(&uppercase), Ok("ab".repeat(32)));
}
#[test]
fn labels_distinguish_pubkeys_with_the_same_prefix() {
let channel_id = Uuid::nil();
let first = format!("{}{}", "ab".repeat(6), "cd".repeat(26));
let second = format!("{}{}", "ab".repeat(6), "ef".repeat(26));
assert_ne!(
window_label(&channel_id, &first),
window_label(&channel_id, &second)
);
}
#[test]
fn rejects_invalid_pubkeys() {
assert!(normalized_pubkey("abc").is_err());
assert!(normalized_pubkey(&"zz".repeat(32)).is_err());
}
}

View File

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

View File

@@ -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<void>;
@@ -48,6 +49,7 @@ export function AppHuddleShell({
isCompanionOpen,
isDrawerOpen,
isRoom,
isPassiveWindow = false,
onCompanionOpen,
onHuddleStartPendingChange,
onHuddleStarted,
@@ -57,7 +59,7 @@ export function AppHuddleShell({
}: AppHuddleShellProps) {
return (
<HuddleProvider
ownsAudioSession={!isRoom}
ownsAudioSession={!isRoom && !isPassiveWindow}
onHuddleStartPendingChange={
isRoom ? undefined : onHuddleStartPendingChange
}
@@ -91,7 +93,7 @@ export function AppHuddleShell({
<BuzzTheme.GradientLayer />
{children}
</div>
{isRoom || !isCompanionOpen ? (
{!isPassiveWindow && (isRoom || !isCompanionOpen) ? (
<div className="buzz-huddle-drawer-slot absolute inset-x-0 bottom-0 z-[2] h-(--buzz-huddle-drawer-height)">
<AppHuddleBar
mode={isRoom ? "room" : "main"}

View File

@@ -44,6 +44,7 @@ import {
} from "@/features/notifications/hooks";
import { PreventSleepProvider } from "@/features/agents/usePreventSleep";
import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent";
import { isAgentActivityWindow } from "@/features/agents/lib/agentActivityWindow";
import { useAgentsDataRefresh } from "@/features/agents/lib/useAgentsDataRefresh";
import { useManagedAgentRuntimeReconciliation } from "@/features/agents/useManagedAgentRuntimeReconciliation";
import { useAutoRestartPolicy } from "@/features/agents/lib/useAutoRestartPolicy";
@@ -125,7 +126,10 @@ export function AppShell() {
showHuddleInMainApp,
viewHuddleChannel,
} = useHuddlePresentation();
const hasCommunityRail = communitiesHook.communities.length > 1;
const isActivityWindow = isAgentActivityWindow();
const isCompanionWindow = isHuddleRoom || isActivityWindow;
const hasCommunityRail =
communitiesHook.communities.length > 1 && !isCompanionWindow;
const addCommunityDialog = useAddCommunityDialogState();
const [isChannelManagementOpen, setIsChannelManagementOpen] =
React.useState(false);
@@ -691,7 +695,7 @@ export function AppShell() {
});
return (
<PreventSleepProvider>
{!isHuddleRoom ? (
{!isCompanionWindow ? (
<AppShellTrayMenu
channels={channels}
goChannel={goChannel}
@@ -743,6 +747,7 @@ export function AppShell() {
isCompanionOpen={isHuddleCompanionOpen}
isDrawerOpen={isHuddleDrawerOpen}
isRoom={isHuddleRoom}
isPassiveWindow={isActivityWindow}
onCompanionOpen={handleHuddleCompanionOpen}
onHuddleStartPendingChange={handleHuddleStartPendingChange}
onHuddleStarted={handleHuddleStarted}
@@ -750,7 +755,7 @@ export function AppShell() {
onViewHuddleChannel={viewHuddleChannel}
onVisibilityChange={handleHuddleVisibilityChange}
>
{hasCommunityRail && !isHuddleRoom ? (
{hasCommunityRail ? (
<CommunityRail
activeCommunityId={communitiesHook.activeCommunity?.id ?? null}
onAddCommunity={addCommunityDialog.openDialog}
@@ -766,7 +771,7 @@ export function AppShell() {
>
<AppProfilePanelProvider>
<AppWorkflowEditorOverlayProvider>
{!settingsOpen && !isHuddleRoom ? (
{!settingsOpen && !isCompanionWindow ? (
<AppTopChrome
canGoBack={canGoBack}
canGoForward={canGoForward}
@@ -817,7 +822,7 @@ export function AppShell() {
</div>
) : (
<div className="relative flex min-h-0 flex-1 overflow-visible">
{!isHuddleRoom ? (
{!isCompanionWindow ? (
<AppSidebar
activeCommunity={communitiesHook.activeCommunity}
channels={sidebarChannels}
@@ -935,7 +940,7 @@ export function AppShell() {
<Outlet />
</AppShellChannelSurface>
</TerminalContextOverrideProvider>
{!isHuddleRoom ? (
{!isCompanionWindow ? (
<RelayConnectionOverlay
card={relayConnectionCard}
errorMessage={channelsErrorMessage}

View File

@@ -0,0 +1,17 @@
import { isTauri } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
const AGENT_ACTIVITY_WINDOW_LABEL_PREFIX = "agent-activity-";
/** Whether this webview is dedicated to an agent activity feed. */
export function isAgentActivityWindow(): boolean {
if (!isTauri()) return false;
try {
return getCurrentWindow().label.startsWith(
AGENT_ACTIVITY_WINDOW_LABEL_PREFIX,
);
} catch {
return false;
}
}

View File

@@ -5,6 +5,7 @@ import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useChannelReferences } from "@/features/channels/openChannelDirectory";
import { useAgentSession } from "@/shared/context/AgentSessionContext";
import type { Channel } from "@/shared/api/types";
import { openAgentActivityWindow } from "@/shared/api/agentActivityWindow";
import { normalizePubkey } from "@/shared/lib/pubkey";
import { getAgentWorkingState } from "./agentWorkingSignal";
import { useRelayAgentsQuery } from "./hooks";
@@ -54,23 +55,11 @@ export function resolveOpenableActivityChannelId({
}
/**
* Universal ingress for opening an agent's activity pane.
* Universal ingress for opening an agent's activity feed.
*
* Inside a channel screen the AgentSessionContext handler opens the pane in
* place. Everywhere else (agents page, home profile panel, popovers reached
* from non-channel routes) there is no provider, so we navigate to a channel
* with the `agentSession` search param instead — preferring a channel the
* agent is currently working in (unified working signal), then falling back
* to the first channel the agent is a member of.
*
* Navigation only ever targets channels the viewer can actually open
* (joined, or open visibility). Owner-global ingestion means the working
* signal can report activity in rooms the viewer can't access; deep-linking
* there would land on a screen they can't read. In that case we surface a
* safe warning instead of navigating — no channel content, no trap-door.
*
* This replaces the old behavior where "View activity log" silently
* disappeared on routes without an AgentSessionProvider.
* The selected channel is still resolved and authorized in the current
* webview, but Tauri presents the existing channel-scoped feed in a dedicated
* companion window. Browser builds keep the previous in-app route fallback.
*/
export function useOpenAgentActivity() {
const { onOpenAgentSession } = useAgentSession();
@@ -149,6 +138,22 @@ export function useOpenAgentActivity() {
[areChannelsReady, onOpenAgentSession, resolveChannelId],
);
const openActivityDestination = React.useCallback(
(pubkey: string, channelId: string): void => {
void openAgentActivityWindow(channelId, pubkey)
.then((openedNativeWindow) => {
if (!openedNativeWindow) {
void goChannel(channelId, { agentSession: pubkey });
}
})
.catch((error) => {
console.error("Failed to open agent activity window:", error);
toast.error("Couldn't open the agent activity window.");
});
},
[goChannel],
);
const openAgentActivity = React.useCallback(
(pubkey: string, options?: { channelId?: string | null }): boolean => {
// An explicit channel target (e.g. clicking a "Working in #channel"
@@ -162,22 +167,18 @@ export function useOpenAgentActivity() {
toast.warning(INACCESSIBLE_ACTIVITY_MESSAGE);
return false;
}
void goChannel(options.channelId, { agentSession: pubkey });
openActivityDestination(pubkey, options.channelId);
return true;
}
// A channel-scoped AgentSessionProvider belongs to the channel view
// already authorized by its route. Do not reject its own current
// channel while the member/reference query is still settling.
onOpenAgentSession(pubkey, options.channelId);
return true;
}
if (onOpenAgentSession) {
onOpenAgentSession(pubkey);
openActivityDestination(pubkey, options.channelId);
return true;
}
const channelId = resolveChannelId(pubkey);
if (channelId) {
void goChannel(channelId, { agentSession: pubkey });
openActivityDestination(pubkey, channelId);
return true;
}
// The agent may be working somewhere, just nowhere the viewer can open.
@@ -188,7 +189,12 @@ export function useOpenAgentActivity() {
}
return false;
},
[findOpenableChannel, goChannel, onOpenAgentSession, resolveChannelId],
[
findOpenableChannel,
onOpenAgentSession,
openActivityDestination,
resolveChannelId,
],
);
return { canOpenAgentActivity, openAgentActivity };

View File

@@ -1,4 +1,5 @@
import * as React from "react";
import { toast } from "sonner";
import { useAppShell } from "@/app/AppShellContext";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader";
@@ -57,6 +58,8 @@ import type { TimelineMessage } from "@/features/messages/types";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { useRelaySelfQuery } from "@/features/moderation/hooks";
import type { RelayEvent, RespondToMode } from "@/shared/api/types";
import { openAgentActivityWindow } from "@/shared/api/agentActivityWindow";
import { isAgentActivityWindow } from "@/features/agents/lib/agentActivityWindow";
import { ChannelScreenLoadingFallback } from "@/features/channels/ui/ChannelScreenLoadingFallback";
import {
useHuddleChannelMessages,
@@ -583,6 +586,29 @@ export function ChannelScreen({
setThreadReplyTargetId,
setThreadScrollTargetId,
});
const handleOpenAgentActivity = React.useCallback(
(pubkey: string, channelId?: string | null) => {
const destinationChannelId = channelId ?? activeChannelId;
if (!destinationChannelId) return;
if (isAgentActivityWindow()) {
handleOpenAgentSession(pubkey, destinationChannelId);
return;
}
void openAgentActivityWindow(destinationChannelId, pubkey)
.then((openedNativeWindow) => {
if (!openedNativeWindow) {
handleOpenAgentSession(pubkey, destinationChannelId);
}
})
.catch((error) => {
console.error("Failed to open agent activity window:", error);
toast.error("Couldn't open the agent activity window.");
});
},
[activeChannelId, handleOpenAgentSession],
);
const { handleOpenProfilePanel, handleCloseProfilePanel, handleOpenDm } =
useChannelProfilePanel({
closeAgentSession: handleCloseAgentSession,
@@ -772,7 +798,7 @@ export function ChannelScreen({
],
);
return (
<AgentSessionProvider onOpenAgentSession={handleOpenAgentSession}>
<AgentSessionProvider onOpenAgentSession={handleOpenAgentActivity}>
<ProfilePanelProvider onOpenProfilePanel={handleOpenProfilePanel}>
<WelcomeAgentCreateDialog
guideName={welcomeGuideAgent?.name ?? "your welcome guide"}
@@ -911,7 +937,7 @@ export function ChannelScreen({
onMarkUnread={handleMessageMarkUnread}
onMarkRead={handleMessageMarkRead}
onExpandThreadReplies={handleExpandThreadReplies}
onOpenAgentSession={handleOpenAgentSession}
onOpenAgentSession={handleOpenAgentActivity}
onOpenDm={handleOpenDm}
onOpenProfilePanel={handleOpenProfilePanel}
onResetThreadPanelWidth={handleThreadPanelWidthReset}
@@ -969,7 +995,7 @@ export function ChannelScreen({
currentPubkey={currentPubkey}
open={isMembersSidebarOpen}
onOpenChange={setIsMembersSidebarOpen}
onViewActivity={handleOpenAgentSession}
onViewActivity={handleOpenAgentActivity}
relayUrl={activeCommunity?.relayUrl}
/>
</ProfilePanelProvider>

View File

@@ -12,6 +12,7 @@ import { UpdaterProvider } from "@/features/settings/hooks/UpdaterProvider";
import { migrateLegacyCommunityStorageBeforeRender } from "@/features/communities/legacyCommunityStorage";
import { CommunitiesProvider } from "@/features/communities/useCommunities";
import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow";
import { isAgentActivityWindow } from "@/features/agents/lib/agentActivityWindow";
import { CommunityOnboardingProvider } from "@/features/onboarding/communityOnboarding";
import { ThemeProvider } from "@/shared/theme/ThemeProvider";
import { EmojiBurstProvider } from "@/shared/ui/EmojiBurstProvider";
@@ -86,7 +87,9 @@ function renderApp() {
<RootErrorBoundary>
<CommunitiesProvider>
<CommunityOnboardingProvider
enabled={huddleWindowChannelId() === null}
enabled={
huddleWindowChannelId() === null && !isAgentActivityWindow()
}
>
<ThemeProvider defaultTheme="buzz">
<TooltipProvider>

View File

@@ -0,0 +1,10 @@
import { invoke, isTauri } from "@tauri-apps/api/core";
/** Opens a channel-scoped agent activity feed in its native companion window. */
export async function openAgentActivityWindow(
channelId: string,
pubkey: string,
): Promise<boolean> {
if (!isTauri()) return false;
return invoke<boolean>("open_agent_activity_window", { channelId, pubkey });
}

View File

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