mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +00:00
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:
79
desktop/src-tauri/src/agent_activity_window.rs
Normal file
79
desktop/src-tauri/src/agent_activity_window.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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}
|
||||
|
||||
17
desktop/src/features/agents/lib/agentActivityWindow.ts
Normal file
17
desktop/src/features/agents/lib/agentActivityWindow.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
10
desktop/src/shared/api/agentActivityWindow.ts
Normal file
10
desktop/src/shared/api/agentActivityWindow.ts
Normal 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 });
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user