From e874c9d2dd231cf11926dacabc35cbb8b9b19a11 Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Mon, 4 May 2026 09:00:17 -0400 Subject: [PATCH] Add detachable agent activity window Allow live agent activity to open in a dedicated window while keeping the detached view synchronized and isolated from main-app routing. Co-authored-by: Cursor --- desktop/src-tauri/capabilities/default.json | 4 +- desktop/src/app/App.tsx | 55 ++--- desktop/src/app/AppLoadingGate.tsx | 17 ++ desktop/src/app/MainAppReady.tsx | 27 +++ .../src/features/agents/observerRelayStore.ts | 111 +++++++++- .../agents/ui/AgentSessionToolItem.tsx | 4 +- .../agents/ui/AgentSessionTranscriptList.tsx | 33 ++- .../agents/ui/DetachedAgentSessionView.tsx | 189 ++++++++++++++++++ .../agents/ui/ManagedAgentSessionPanel.tsx | 6 + .../agents/ui/detachedAgentSessionWindow.ts | 79 ++++++++ .../channels/ui/AgentSessionThreadPanel.tsx | 21 +- .../features/channels/ui/BotActivityBar.tsx | 61 ++++-- .../src/features/channels/ui/ChannelPane.tsx | 22 ++ .../features/channels/ui/ChannelScreen.tsx | 24 ++- .../profile/ui/UserProfilePopover.tsx | 52 +++-- .../shared/context/AgentSessionContext.tsx | 13 +- desktop/src/shared/ui/markdown.tsx | 47 ++++- 17 files changed, 677 insertions(+), 88 deletions(-) create mode 100644 desktop/src/app/AppLoadingGate.tsx create mode 100644 desktop/src/app/MainAppReady.tsx create mode 100644 desktop/src/features/agents/ui/DetachedAgentSessionView.tsx create mode 100644 desktop/src/features/agents/ui/detachedAgentSessionWindow.ts diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index bf7580f7da..a4c13bdc9e 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -2,10 +2,12 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["main"], + "windows": ["main", "agent-session-*"], "permissions": [ "core:default", + "core:webview:allow-create-webview-window", "core:webview:allow-set-webview-zoom", + "core:window:allow-create", "core:window:allow-set-badge-count", "core:window:allow-set-focus", "core:window:allow-start-dragging", diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 4d71da8fa6..6a7065ff05 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -1,52 +1,33 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { useQueryClient } from "@tanstack/react-query"; -import { RouterProvider } from "@tanstack/react-router"; -import { useCallback, useLayoutEffect } from "react"; +import { lazy, Suspense, useCallback, useLayoutEffect } from "react"; -import { router } from "@/app/router"; -import { useAppOnboardingState } from "@/features/onboarding/hooks"; -import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; +import { AppLoadingGate } from "@/app/AppLoadingGate"; +import { + DetachedAgentSessionView, + getDetachedAgentSessionParams, +} from "@/features/agents/ui/DetachedAgentSessionView"; import { useWorkspaceInit } from "@/features/workspaces/useWorkspaceInit"; import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; import { WelcomeSetup } from "@/features/workspaces/ui/WelcomeSetup"; -function AppLoadingGate() { - return ( -
-
-

- Sprout -

-

- Checking your setup -

-

- One sec while we load your profile. -

-
-
- ); -} +const LazyMainAppReady = lazy(async () => { + const module = await import("@/app/MainAppReady"); + return { default: module.MainAppReady }; +}); function AppReady() { - const onboarding = useAppOnboardingState(); + const detachedAgentSessionParams = getDetachedAgentSessionParams(); - if (onboarding.stage === "onboarding") { - return ( - - ); + if (detachedAgentSessionParams) { + return ; } - if (onboarding.stage === "blocking") { - return ; - } - - return ; + return ( + }> + + + ); } export function App() { diff --git a/desktop/src/app/AppLoadingGate.tsx b/desktop/src/app/AppLoadingGate.tsx new file mode 100644 index 0000000000..785af9f450 --- /dev/null +++ b/desktop/src/app/AppLoadingGate.tsx @@ -0,0 +1,17 @@ +export function AppLoadingGate() { + return ( +
+
+

+ Sprout +

+

+ Checking your setup +

+

+ One sec while we load your profile. +

+
+
+ ); +} diff --git a/desktop/src/app/MainAppReady.tsx b/desktop/src/app/MainAppReady.tsx new file mode 100644 index 0000000000..878e95cc45 --- /dev/null +++ b/desktop/src/app/MainAppReady.tsx @@ -0,0 +1,27 @@ +import { RouterProvider } from "@tanstack/react-router"; + +import { AppLoadingGate } from "@/app/AppLoadingGate"; +import { router } from "@/app/router"; +import { useAppOnboardingState } from "@/features/onboarding/hooks"; +import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; + +export function MainAppReady() { + const onboarding = useAppOnboardingState(); + + if (onboarding.stage === "onboarding") { + return ( + + ); + } + + if (onboarding.stage === "blocking") { + return ; + } + + return ; +} diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts index a5382fb3bb..4935fd81ff 100644 --- a/desktop/src/features/agents/observerRelayStore.ts +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -8,6 +8,7 @@ import { normalizePubkey } from "@/shared/lib/pubkey"; import type { ConnectionState, ObserverEvent } from "./ui/agentSessionTypes"; const MAX_OBSERVER_EVENTS = 800; +const OBSERVER_SYNC_CHANNEL_NAME = "sprout-agent-observer-events"; type ObserverSnapshot = { connectionState: ConnectionState; @@ -28,6 +29,22 @@ let unsubscribeRelay: (() => Promise) | null = null; let startPromise: Promise | null = null; let eventProcessingQueue: Promise = Promise.resolve(); let generation = 0; +let observerSyncChannel: BroadcastChannel | null = null; +let observerSyncInitialized = false; + +type ObserverSyncMessage = + | { + type: "event"; + agentPubkey: string; + event: ObserverEvent; + } + | { + type: "snapshot-request"; + } + | { + type: "snapshot-response"; + eventsByAgent: Array<[string, ObserverEvent[]]>; + }; function notifyListeners() { for (const listener of listeners) { @@ -35,6 +52,88 @@ function notifyListeners() { } } +function postObserverSyncMessage(message: ObserverSyncMessage) { + observerSyncChannel?.postMessage(message); +} + +function replaceObserverEventsFromSnapshot( + snapshotEntries: Array<[string, ObserverEvent[]]>, +) { + let changed = false; + + for (const [agentPubkey, events] of snapshotEntries) { + const key = normalizePubkey(agentPubkey); + const current = eventsByAgent.get(key) ?? []; + const merged = [...current]; + + for (const event of events) { + if ( + !merged.some( + (existing) => + existing.seq === event.seq && + existing.timestamp === event.timestamp, + ) + ) { + merged.push(event); + } + } + + if (merged.length !== current.length) { + eventsByAgent.set( + key, + merged.sort(compareObserverEvents).slice(-MAX_OBSERVER_EVENTS), + ); + changed = true; + } + } + + if (changed) { + notifyListeners(); + } +} + +function ensureObserverSyncChannel() { + if (observerSyncInitialized) { + return; + } + observerSyncInitialized = true; + + if (typeof BroadcastChannel === "undefined") { + return; + } + + observerSyncChannel = new BroadcastChannel(OBSERVER_SYNC_CHANNEL_NAME); + observerSyncChannel.onmessage = ( + message: MessageEvent, + ) => { + const payload = message.data; + if (!payload || typeof payload !== "object") { + return; + } + + if (payload.type === "event") { + appendAgentEvent(payload.agentPubkey, payload.event, { + broadcast: false, + }); + return; + } + + if (payload.type === "snapshot-request") { + postObserverSyncMessage({ + type: "snapshot-response", + eventsByAgent: [...eventsByAgent.entries()], + }); + return; + } + + if (payload.type === "snapshot-response") { + replaceObserverEventsFromSnapshot(payload.eventsByAgent); + } + }; + + postObserverSyncMessage({ type: "snapshot-request" }); +} + function setConnectionState( nextState: ConnectionState, nextErrorMessage: string | null = errorMessage, @@ -48,7 +147,11 @@ function observerTag(event: RelayEvent, tagName: string) { return event.tags.find((tag) => tag[0] === tagName)?.[1] ?? null; } -function appendAgentEvent(agentPubkey: string, event: ObserverEvent) { +function appendAgentEvent( + agentPubkey: string, + event: ObserverEvent, + options: { broadcast?: boolean } = {}, +) { const key = normalizePubkey(agentPubkey); const current = eventsByAgent.get(key) ?? []; if ( @@ -68,6 +171,9 @@ function appendAgentEvent(agentPubkey: string, event: ObserverEvent) { : next, ); notifyListeners(); + if (options.broadcast !== false) { + postObserverSyncMessage({ type: "event", agentPubkey: key, event }); + } } function compareObserverEvents(left: ObserverEvent, right: ObserverEvent) { @@ -125,6 +231,7 @@ async function handleRelayObserverEvent( } export function ensureRelayObserverSubscription() { + ensureObserverSyncChannel(); if (unsubscribeRelay) { return Promise.resolve(); } @@ -181,6 +288,7 @@ export function ensureRelayObserverSubscription() { } export function subscribeAgentObserverStore(listener: () => void) { + ensureObserverSyncChannel(); listeners.add(listener); return () => { listeners.delete(listener); @@ -190,6 +298,7 @@ export function subscribeAgentObserverStore(listener: () => void) { export function getAgentObserverSnapshot( agentPubkey: string, ): ObserverSnapshot { + ensureObserverSyncChannel(); return { connectionState, errorMessage, diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx index 7372eb9729..daddb60987 100644 --- a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -21,8 +21,10 @@ import { } from "./agentSessionUtils"; export function ToolItem({ + enableInlineNavigation = true, item, }: { + enableInlineNavigation?: boolean; item: Extract; }) { const [isExpanded, setIsExpanded] = React.useState(false); @@ -60,7 +62,7 @@ export function ToolItem({ {toolTitle} - {sproutTool ? ( + {sproutTool && enableInlineNavigation ? ( ) : null} {showStatus ? ( diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx index a39d6a01e8..9bbbfa4fbc 100644 --- a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -9,11 +9,13 @@ import { ToolItem } from "./AgentSessionToolItem"; export function AgentSessionTranscriptList({ agentAvatarUrl, agentName, + enableInlineNavigation = true, emptyDescription, items, }: { agentAvatarUrl: string | null; agentName: string; + enableInlineNavigation?: boolean; emptyDescription: string; items: TranscriptItem[]; }) { @@ -39,6 +41,7 @@ export function AgentSessionTranscriptList({ @@ -50,10 +53,12 @@ export function AgentSessionTranscriptList({ function TranscriptItemView({ agentAvatarUrl, agentName, + enableInlineNavigation, item, }: { agentAvatarUrl: string | null; agentName: string; + enableInlineNavigation: boolean; item: TranscriptItem; }) { if (item.type === "message") { @@ -61,15 +66,23 @@ function TranscriptItemView({ ); } if (item.type === "tool") { - return ; + return ( + + ); } if (item.type === "thought") { - return ; + return ( + + ); } if (item.type === "metadata") { return ; @@ -80,10 +93,12 @@ function TranscriptItemView({ function MessageItem({ agentAvatarUrl, agentName, + enableInlineNavigation, item, }: { agentAvatarUrl: string | null; agentName: string; + enableInlineNavigation: boolean; item: Extract; }) { const isAssistant = item.role === "assistant"; @@ -114,7 +129,11 @@ function MessageItem({ )} > {isAssistant ? ( - + ) : (

{text}

)} @@ -125,8 +144,10 @@ function MessageItem({ } function ThoughtItem({ + enableInlineNavigation, item, }: { + enableInlineNavigation: boolean; item: Extract; }) { return ( @@ -137,7 +158,11 @@ function ThoughtItem({
- +
); diff --git a/desktop/src/features/agents/ui/DetachedAgentSessionView.tsx b/desktop/src/features/agents/ui/DetachedAgentSessionView.tsx new file mode 100644 index 0000000000..27128394b5 --- /dev/null +++ b/desktop/src/features/agents/ui/DetachedAgentSessionView.tsx @@ -0,0 +1,189 @@ +import { getCurrentWindow } from "@tauri-apps/api/window"; +import { useQuery } from "@tanstack/react-query"; +import { X } from "lucide-react"; +import * as React from "react"; + +import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; +import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel"; +import { listManagedAgents } from "@/shared/api/tauri"; +import { Button } from "@/shared/ui/button"; +import { Skeleton } from "@/shared/ui/skeleton"; + +type DetachedAgentSessionParams = { + agentPubkey: string | null; + channelId: string | null; +}; + +type DetachedAgentSessionErrorBoundaryState = { + errorMessage: string | null; +}; + +export function getDetachedAgentSessionParams(): DetachedAgentSessionParams | null { + if (typeof window === "undefined") { + return null; + } + + const params = new URL(window.location.href).searchParams; + if (params.get("detachedAgentSession") !== "1") { + return null; + } + + return { + agentPubkey: params.get("agentPubkey"), + channelId: params.get("channelId"), + }; +} + +export function DetachedAgentSessionView(props: DetachedAgentSessionParams) { + return ( + + + + ); +} + +class DetachedAgentSessionErrorBoundary extends React.Component< + React.PropsWithChildren, + DetachedAgentSessionErrorBoundaryState +> { + state: DetachedAgentSessionErrorBoundaryState = { errorMessage: null }; + + static getDerivedStateFromError(error: unknown) { + return { + errorMessage: + error instanceof Error + ? error.message + : "Detached agent activity failed to render.", + }; + } + + render() { + if (this.state.errorMessage) { + return ( +
+
+

Detached activity crashed

+

+ {this.state.errorMessage} +

+
+
+ ); + } + + return this.props.children; + } +} + +function DetachedAgentSessionContent({ + agentPubkey, + channelId, +}: DetachedAgentSessionParams) { + const agentsQuery = useQuery({ + queryKey: ["detached-agent-session", "managed-agents"], + queryFn: listManagedAgents, + refetchInterval: 5_000, + staleTime: 5_000, + }); + useManagedAgentObserverBridge(agentsQuery.data ?? []); + const agent = React.useMemo( + () => + agentPubkey + ? (agentsQuery.data?.find( + (candidate) => + candidate.pubkey.toLowerCase() === agentPubkey.toLowerCase(), + ) ?? null) + : null, + [agentPubkey, agentsQuery.data], + ); + + React.useLayoutEffect(() => { + void getCurrentWindow().show(); + }, []); + + async function closeWindow() { + await getCurrentWindow().close(); + } + + return ( +
+
+
+

+ {agent?.name ?? "Agent activity"} +

+

+ Detached live session +

+
+ +
+ +
+ {!agentPubkey ? ( + + ) : agentsQuery.isLoading ? ( + + ) : agent ? ( + + ) : ( + + )} +
+
+ ); +} + +function DetachedWindowSkeleton() { + return ( +
+ + + +
+ ); +} + +function DetachedWindowMessage({ + description, + title, +}: { + description: string; + title: string; +}) { + return ( +
+

{title}

+

+ {description} +

+
+ ); +} diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx index 0800435073..6ecf48e005 100644 --- a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -29,6 +29,7 @@ type ManagedAgentSessionPanelProps = { channelId?: string | null; className?: string; emptyDescription?: string; + enableInlineNavigation?: boolean; showHeader?: boolean; showRaw?: boolean; }; @@ -39,6 +40,7 @@ export function ManagedAgentSessionPanel({ channelId = null, className, emptyDescription = "Mention this agent in a channel to watch the next turn.", + enableInlineNavigation = true, showHeader = true, showRaw = true, }: ManagedAgentSessionPanelProps) { @@ -84,6 +86,7 @@ export function ManagedAgentSessionPanel({ agentName={agent.name} connectionState={connectionState} emptyDescription={emptyDescription} + enableInlineNavigation={enableInlineNavigation} errorMessage={errorMessage} events={scopedEvents} hasObserver={agent.status === "running"} @@ -134,6 +137,7 @@ function SessionBody({ agentName, connectionState, emptyDescription, + enableInlineNavigation, errorMessage, events, hasObserver, @@ -144,6 +148,7 @@ function SessionBody({ agentName: string; connectionState: ConnectionState; emptyDescription: string; + enableInlineNavigation: boolean; errorMessage: string | null; events: ObserverEvent[]; hasObserver: boolean; @@ -167,6 +172,7 @@ function SessionBody({ diff --git a/desktop/src/features/agents/ui/detachedAgentSessionWindow.ts b/desktop/src/features/agents/ui/detachedAgentSessionWindow.ts new file mode 100644 index 0000000000..70c476b110 --- /dev/null +++ b/desktop/src/features/agents/ui/detachedAgentSessionWindow.ts @@ -0,0 +1,79 @@ +import { isTauri } from "@tauri-apps/api/core"; +import { WebviewWindow } from "@tauri-apps/api/webviewWindow"; + +type DetachedAgentSessionWindowInput = { + agentName?: string; + agentPubkey: string; + channelId?: string | null; +}; + +const DETACHED_AGENT_SESSION_WINDOW_PREFIX = "agent-session"; + +function detachedAgentSessionLabel( + agentPubkey: string, + channelId?: string | null, +) { + const channelPart = channelId ? `-${channelId}` : ""; + return `${DETACHED_AGENT_SESSION_WINDOW_PREFIX}-${agentPubkey}${channelPart}`; +} + +function detachedAgentSessionUrl({ + agentPubkey, + channelId, +}: DetachedAgentSessionWindowInput) { + const params = new URLSearchParams({ + detachedAgentSession: "1", + agentPubkey, + }); + + if (channelId) { + params.set("channelId", channelId); + } + + return `/?${params.toString()}`; +} + +export async function openDetachedAgentSessionWindow( + input: DetachedAgentSessionWindowInput, +) { + const url = detachedAgentSessionUrl(input); + + if (!isTauri()) { + window.open(url, "_blank", "noopener,noreferrer"); + return; + } + + const label = detachedAgentSessionLabel(input.agentPubkey, input.channelId); + const existingWindow = await WebviewWindow.getByLabel(label); + if (existingWindow) { + await existingWindow.close(); + } + + const detachedWindow = new WebviewWindow(label, { + center: true, + height: 760, + minHeight: 480, + minWidth: 420, + title: input.agentName ? `${input.agentName} activity` : "Agent activity", + url, + width: 560, + }); + + await new Promise((resolve, reject) => { + const unlistenCreated = detachedWindow.once("tauri://created", () => { + cleanup(); + resolve(); + }); + const unlistenError = detachedWindow.once("tauri://error", (event) => { + cleanup(); + reject( + new Error(String(event.payload ?? "Failed to open agent window.")), + ); + }); + + function cleanup() { + void unlistenCreated.then((unlisten) => unlisten()); + void unlistenError.then((unlisten) => unlisten()); + } + }); +} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index da785bf3b3..bff0e95c9b 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -1,5 +1,5 @@ import type * as React from "react"; -import { Activity, CircleDot, Octagon, X } from "lucide-react"; +import { Activity, CircleDot, ExternalLink, Octagon, X } from "lucide-react"; import { toast } from "sonner"; import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel"; @@ -26,6 +26,7 @@ type AgentSessionThreadPanelProps = { channel: Channel; isWorking: boolean; onClose: () => void; + onDetach: () => void; onResetWidth: () => void; onResizeStart: (event: React.PointerEvent) => void; profiles?: UserProfileLookup; @@ -38,6 +39,7 @@ export function AgentSessionThreadPanel({ channel, isWorking, onClose, + onDetach, onResetWidth, onResizeStart, profiles, @@ -120,6 +122,23 @@ export function AgentSessionThreadPanel({ Idle )} + + + + + + Open this agent activity in a separate window. + + + + {onDetachAgentSession ? ( + + ) : null} + ))} diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index df17334624..969441e45e 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { Hash, LogIn } from "lucide-react"; +import { toast } from "sonner"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel"; @@ -8,6 +9,7 @@ import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; import { BotActivityBar } from "@/features/channels/ui/BotActivityBar"; +import { openDetachedAgentSessionWindow } from "@/features/agents/ui/detachedAgentSessionWindow"; import { Button } from "@/shared/ui/button"; import type { useChannelFind } from "@/features/search/useChannelFind"; import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel"; @@ -235,6 +237,23 @@ export const ChannelPane = React.memo(function ChannelPane({ [agentSessionAgents, openAgentSessionPubkey], ); + const handleDetachAgentSession = React.useCallback( + (agent: ManagedAgent) => { + void openDetachedAgentSessionWindow({ + agentName: agent.name, + agentPubkey: agent.pubkey, + channelId: activeChannel?.id ?? null, + }).catch((error) => { + toast.error( + error instanceof Error + ? error.message + : "Failed to open detached agent window.", + ); + }); + }, + [activeChannel?.id], + ); + return (
@@ -274,6 +293,7 @@ export const ChannelPane = React.memo(function ChannelPane({ botTypingPubkeys.length > 0 ? ( 0 ? ( handleDetachAgentSession(selectedAgent)} onResetWidth={handleThreadPanelWidthReset} onResizeStart={handleThreadPanelResizeStart} profiles={profiles} diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 48c695e1a9..f6b9175e26 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -1,5 +1,7 @@ import * as React from "react"; +import { toast } from "sonner"; import { useAppShell } from "@/app/AppShellContext"; +import { openDetachedAgentSessionWindow } from "@/features/agents/ui/detachedAgentSessionWindow"; import { useActiveChannelHeader } from "@/features/channels/useActiveChannelHeader"; import { useChannelPaneHandlers } from "@/features/channels/useChannelPaneHandlers"; import { @@ -40,6 +42,7 @@ import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity"; import type { Channel, Identity, + ManagedAgent, Profile, RelayEvent, } from "@/shared/api/types"; @@ -359,6 +362,22 @@ export function ChannelScreen({ targetMessageId, timelineMessages, }); + const handleDetachAgentSession = React.useCallback( + (agent: ManagedAgent) => { + void openDetachedAgentSessionWindow({ + agentName: agent.name, + agentPubkey: agent.pubkey, + channelId: activeChannel?.id ?? null, + }).catch((error) => { + toast.error( + error instanceof Error + ? error.message + : "Failed to open detached agent window.", + ); + }); + }, + [activeChannel?.id], + ); const isTimelineLoading = activeChannel !== null && @@ -414,7 +433,10 @@ export function ChannelScreen({ useLoadMissingAncestors(activeChannel, resolvedMessages); return ( - + a.pubkey === pubkey); const managedAgent = managedAgentsQuery.data?.find( (a) => a.pubkey === pubkey, @@ -85,6 +85,10 @@ export function UserProfilePopover({ role === "bot" && managedAgent?.backend.type === "local" && Boolean(onOpenAgentSession); + const canDetachActivity = + role === "bot" && + managedAgent?.backend.type === "local" && + Boolean(onDetachAgentSession); const profile = profileQuery.data; const notes = notesQuery.data?.notes ?? []; const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()]; @@ -155,19 +159,37 @@ export function UserProfilePopover({

) : null} - {canViewActivity ? ( - + {canViewActivity || canDetachActivity ? ( +
+ {canViewActivity ? ( + + ) : null} + {canDetachActivity && managedAgent ? ( + + ) : null} +
) : null}

diff --git a/desktop/src/shared/context/AgentSessionContext.tsx b/desktop/src/shared/context/AgentSessionContext.tsx index 8fd4903c4c..9a46612fb9 100644 --- a/desktop/src/shared/context/AgentSessionContext.tsx +++ b/desktop/src/shared/context/AgentSessionContext.tsx @@ -1,23 +1,32 @@ import * as React from "react"; +import type { ManagedAgent } from "@/shared/api/types"; + type AgentSessionContextValue = { + onDetachAgentSession: ((agent: ManagedAgent) => void) | null; onOpenAgentSession: ((pubkey: string) => void) | null; }; const AgentSessionContext = React.createContext({ + onDetachAgentSession: null, onOpenAgentSession: null, }); export function AgentSessionProvider({ children, + onDetachAgentSession, onOpenAgentSession, }: { children: React.ReactNode; + onDetachAgentSession?: (agent: ManagedAgent) => void; onOpenAgentSession: (pubkey: string) => void; }) { const value = React.useMemo( - () => ({ onOpenAgentSession }), - [onOpenAgentSession], + () => ({ + onDetachAgentSession: onDetachAgentSession ?? null, + onOpenAgentSession, + }), + [onDetachAgentSession, onOpenAgentSession], ); return ( diff --git a/desktop/src/shared/ui/markdown.tsx b/desktop/src/shared/ui/markdown.tsx index cb2520fb05..29a293ba2d 100644 --- a/desktop/src/shared/ui/markdown.tsx +++ b/desktop/src/shared/ui/markdown.tsx @@ -29,6 +29,7 @@ type MarkdownProps = { className?: string; compact?: boolean; content: string; + enableChannelLinks?: boolean; imetaByUrl?: ImetaLookup; mentionNames?: string[]; searchQuery?: string; @@ -349,23 +350,26 @@ function createMarkdownComponents( } as Components; } -function MarkdownInner({ +function MarkdownContent({ channelNames, className, compact = false, content, + goChannel, imetaByUrl, mentionNames, + channels, searchQuery, tight = false, -}: MarkdownProps) { +}: MarkdownProps & { + channels: Channel[]; + goChannel: (channelId: string) => void; +}) { const variant: MarkdownVariant = tight ? "tight" : compact ? "compact" : "default"; - const { channels } = useChannelNavigation(); - const { goChannel } = useAppNavigation(); const components = React.useMemo( () => @@ -373,7 +377,7 @@ function MarkdownInner({ variant, channels, (channelId) => { - void goChannel(channelId); + goChannel(channelId); }, imetaByUrl, ), @@ -437,12 +441,45 @@ function MarkdownInner({ ); } +function NavigableMarkdownInner(props: MarkdownProps) { + const { channels } = useChannelNavigation(); + const { goChannel } = useAppNavigation(); + const handleGoChannel = React.useCallback( + (channelId: string) => { + void goChannel(channelId); + }, + [goChannel], + ); + + return ( + + ); +} + +function StaticMarkdownInner(props: MarkdownProps) { + const noopGoChannel = React.useCallback(() => {}, []); + return ; +} + +function MarkdownInner(props: MarkdownProps) { + if (props.enableChannelLinks === false) { + return ; + } + + return ; +} + export const Markdown = React.memo( MarkdownInner, (prev, next) => prev.content === next.content && prev.className === next.className && prev.compact === next.compact && + prev.enableChannelLinks === next.enableChannelLinks && prev.tight === next.tight && shallowArrayEqual(prev.mentionNames, next.mentionNames) && shallowArrayEqual(prev.channelNames, next.channelNames) &&