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 <cursoragent@cursor.com>
This commit is contained in:
Thomas Petersen
2026-05-04 09:00:17 -04:00
parent 18dfbff379
commit e874c9d2dd
17 changed files with 677 additions and 88 deletions

View File

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

View File

@@ -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 (
<div className="flex min-h-dvh items-center justify-center bg-[radial-gradient(circle_at_top,hsl(var(--primary)/0.14),transparent_48%),linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.55))] px-4 py-8">
<div className="w-full max-w-sm rounded-[28px] border border-border/70 bg-background/92 p-8 shadow-2xl backdrop-blur">
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
Sprout
</p>
<h1 className="mt-3 text-2xl font-semibold tracking-tight text-foreground">
Checking your setup
</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
One sec while we load your profile.
</p>
</div>
</div>
);
}
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 (
<OnboardingFlow
actions={onboarding.flow.actions}
initialProfile={onboarding.flow.initialProfile}
key={onboarding.currentPubkey ?? "anonymous"}
notifications={onboarding.flow.notifications}
/>
);
if (detachedAgentSessionParams) {
return <DetachedAgentSessionView {...detachedAgentSessionParams} />;
}
if (onboarding.stage === "blocking") {
return <AppLoadingGate />;
}
return <RouterProvider router={router} />;
return (
<Suspense fallback={<AppLoadingGate />}>
<LazyMainAppReady />
</Suspense>
);
}
export function App() {

View File

@@ -0,0 +1,17 @@
export function AppLoadingGate() {
return (
<div className="flex min-h-dvh items-center justify-center bg-[radial-gradient(circle_at_top,hsl(var(--primary)/0.14),transparent_48%),linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.55))] px-4 py-8">
<div className="w-full max-w-sm rounded-[28px] border border-border/70 bg-background/92 p-8 shadow-2xl backdrop-blur">
<p className="text-xs font-medium uppercase tracking-[0.2em] text-muted-foreground">
Sprout
</p>
<h1 className="mt-3 text-2xl font-semibold tracking-tight text-foreground">
Checking your setup
</h1>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
One sec while we load your profile.
</p>
</div>
</div>
);
}

View File

@@ -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 (
<OnboardingFlow
actions={onboarding.flow.actions}
initialProfile={onboarding.flow.initialProfile}
key={onboarding.currentPubkey ?? "anonymous"}
notifications={onboarding.flow.notifications}
/>
);
}
if (onboarding.stage === "blocking") {
return <AppLoadingGate />;
}
return <RouterProvider router={router} />;
}

View File

@@ -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<void>) | null = null;
let startPromise: Promise<void> | null = null;
let eventProcessingQueue: Promise<void> = 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<ObserverSyncMessage>,
) => {
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,

View File

@@ -21,8 +21,10 @@ import {
} from "./agentSessionUtils";
export function ToolItem({
enableInlineNavigation = true,
item,
}: {
enableInlineNavigation?: boolean;
item: Extract<TranscriptItem, { type: "tool" }>;
}) {
const [isExpanded, setIsExpanded] = React.useState(false);
@@ -60,7 +62,7 @@ export function ToolItem({
<span className="min-w-0 truncate text-sm font-medium">
{toolTitle}
</span>
{sproutTool ? (
{sproutTool && enableInlineNavigation ? (
<SproutToolInlineAction args={item.args} result={item.result} />
) : null}
{showStatus ? (

View File

@@ -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({
<TranscriptItemView
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
enableInlineNavigation={enableInlineNavigation}
item={item}
/>
</div>
@@ -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({
<MessageItem
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
enableInlineNavigation={enableInlineNavigation}
item={item}
/>
);
}
if (item.type === "tool") {
return <ToolItem item={item} />;
return (
<ToolItem enableInlineNavigation={enableInlineNavigation} item={item} />
);
}
if (item.type === "thought") {
return <ThoughtItem item={item} />;
return (
<ThoughtItem
enableInlineNavigation={enableInlineNavigation}
item={item}
/>
);
}
if (item.type === "metadata") {
return <MetadataItem item={item} />;
@@ -80,10 +93,12 @@ function TranscriptItemView({
function MessageItem({
agentAvatarUrl,
agentName,
enableInlineNavigation,
item,
}: {
agentAvatarUrl: string | null;
agentName: string;
enableInlineNavigation: boolean;
item: Extract<TranscriptItem, { type: "message" }>;
}) {
const isAssistant = item.role === "assistant";
@@ -114,7 +129,11 @@ function MessageItem({
)}
>
{isAssistant ? (
<Markdown compact content={text || " "} />
<Markdown
compact
content={text || " "}
enableChannelLinks={enableInlineNavigation}
/>
) : (
<p className="whitespace-pre-wrap break-words">{text}</p>
)}
@@ -125,8 +144,10 @@ function MessageItem({
}
function ThoughtItem({
enableInlineNavigation,
item,
}: {
enableInlineNavigation: boolean;
item: Extract<TranscriptItem, { type: "thought" }>;
}) {
return (
@@ -137,7 +158,11 @@ function ThoughtItem({
<ChevronDown className="h-3.5 w-3.5 shrink-0 transition-transform group-open:rotate-180" />
</summary>
<div className="py-2 pl-5 text-sm leading-6 text-muted-foreground">
<Markdown compact content={item.text.trim() || " "} />
<Markdown
compact
content={item.text.trim() || " "}
enableChannelLinks={enableInlineNavigation}
/>
</div>
</details>
);

View File

@@ -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 (
<DetachedAgentSessionErrorBoundary>
<DetachedAgentSessionContent {...props} />
</DetachedAgentSessionErrorBoundary>
);
}
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 (
<main className="flex h-dvh min-h-0 flex-col bg-background text-foreground">
<div className="flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
<p className="text-sm font-medium">Detached activity crashed</p>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
{this.state.errorMessage}
</p>
</div>
</main>
);
}
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 (
<main className="flex h-dvh min-h-0 flex-col bg-background text-foreground">
<header
className="flex shrink-0 items-center justify-between border-b border-border/70 px-4 py-3"
data-tauri-drag-region
>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold tracking-tight">
{agent?.name ?? "Agent activity"}
</h1>
<p className="truncate text-xs text-muted-foreground">
Detached live session
</p>
</div>
<Button
aria-label="Close detached agent window"
onClick={() => {
void closeWindow();
}}
size="icon"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
{!agentPubkey ? (
<DetachedWindowMessage
description="This detached window is missing an agent identifier."
title="No agent selected"
/>
) : agentsQuery.isLoading ? (
<DetachedWindowSkeleton />
) : agent ? (
<ManagedAgentSessionPanel
agent={agent}
channelId={channelId}
className="border-0 bg-transparent p-0 shadow-none"
enableInlineNavigation={false}
emptyDescription={`Mention ${agent.name} to see its work here.`}
showHeader
showRaw={false}
/>
) : (
<DetachedWindowMessage
description="The selected managed agent is no longer available."
title="Agent not found"
/>
)}
</div>
</main>
);
}
function DetachedWindowSkeleton() {
return (
<div className="space-y-4">
<Skeleton className="h-16 rounded-xl" />
<Skeleton className="h-28 rounded-xl" />
<Skeleton className="h-28 rounded-xl" />
</div>
);
}
function DetachedWindowMessage({
description,
title,
}: {
description: string;
title: string;
}) {
return (
<div className="flex min-h-64 flex-col items-center justify-center text-center">
<p className="text-sm font-medium">{title}</p>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
{description}
</p>
</div>
);
}

View File

@@ -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({
<AgentSessionTranscriptList
agentAvatarUrl={agentAvatarUrl}
agentName={agentName}
enableInlineNavigation={enableInlineNavigation}
emptyDescription={emptyDescription}
items={transcript}
/>

View File

@@ -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<void>((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());
}
});
}

View File

@@ -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<HTMLButtonElement>) => 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
</Badge>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Detach activity panel"
data-testid="agent-session-detach"
onClick={onDetach}
size="icon"
type="button"
variant="ghost"
>
<ExternalLink className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
Open this agent activity in a separate window.
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import { Loader2 } from "lucide-react";
import { ExternalLink, Loader2 } from "lucide-react";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import type { ManagedAgent } from "@/shared/api/types";
@@ -13,6 +13,7 @@ const MAX_INLINE_AGENT_AVATARS = 3;
type BotActivityBarProps = {
agents: ManagedAgent[];
onDetachAgentSession?: (agent: ManagedAgent) => void;
onOpenAgentSession: (pubkey: string) => void;
openAgentSessionPubkey: string | null;
profiles?: UserProfileLookup;
@@ -25,6 +26,7 @@ type BotActivityBarProps = {
*/
export function BotActivityBar({
agents,
onDetachAgentSession,
onOpenAgentSession,
openAgentSessionPubkey,
profiles,
@@ -160,31 +162,50 @@ export function BotActivityBar({
{activeAgentCountLabel}
</div>
{typingAgents.map((agent) => (
<button
<div
className={cn(
"flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground",
"flex w-full select-none items-center gap-1 rounded-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground",
"animate-in fade-in-0 duration-150 motion-reduce:animate-none",
selectedAgent?.pubkey === agent.pubkey && "bg-primary/10",
)}
data-testid={`bot-chip-overflow-item-${agent.pubkey}`}
key={agent.pubkey}
onClick={() => {
setIsOpen(false);
onOpenAgentSession(agent.pubkey);
}}
type="button"
>
<UserAvatar
avatarUrl={
profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null
}
className="rounded-full"
displayName={agent.name}
size="sm"
/>
<span className="min-w-0 flex-1 truncate">{agent.name}</span>
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground/70" />
</button>
<button
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 px-2 py-1.5 text-left text-sm outline-none focus-visible:bg-accent focus-visible:text-accent-foreground"
data-testid={`bot-chip-overflow-item-${agent.pubkey}`}
onClick={() => {
setIsOpen(false);
onOpenAgentSession(agent.pubkey);
}}
type="button"
>
<UserAvatar
avatarUrl={
profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null
}
className="rounded-full"
displayName={agent.name}
size="sm"
/>
<span className="min-w-0 flex-1 truncate">{agent.name}</span>
<Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground/70" />
</button>
{onDetachAgentSession ? (
<button
aria-label={`Detach ${agent.name} activity`}
className="mr-1 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-sm text-muted-foreground transition-colors hover:bg-background/80 hover:text-foreground focus-visible:bg-background/80 focus-visible:text-foreground focus-visible:outline-none"
data-testid={`bot-chip-detach-${agent.pubkey}`}
onClick={() => {
setIsOpen(false);
onDetachAgentSession(agent);
}}
title={`Detach ${agent.name} activity`}
type="button"
>
<ExternalLink className="h-3.5 w-3.5" />
</button>
) : null}
</div>
))}
</PopoverContent>
</Popover>

View File

@@ -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 (
<div className="flex min-h-0 flex-1 overflow-hidden">
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
@@ -274,6 +293,7 @@ export const ChannelPane = React.memo(function ChannelPane({
botTypingPubkeys.length > 0 ? (
<BotActivityBar
agents={agentSessionAgents}
onDetachAgentSession={handleDetachAgentSession}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
@@ -379,6 +399,7 @@ export const ChannelPane = React.memo(function ChannelPane({
threadBotTypingPubkeys.length > 0 ? (
<BotActivityBar
agents={agentSessionAgents}
onDetachAgentSession={handleDetachAgentSession}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
@@ -404,6 +425,7 @@ export const ChannelPane = React.memo(function ChannelPane({
pubkey.toLowerCase() === selectedAgent.pubkey.toLowerCase(),
)}
onClose={onCloseAgentSession}
onDetach={() => handleDetachAgentSession(selectedAgent)}
onResetWidth={handleThreadPanelWidthReset}
onResizeStart={handleThreadPanelResizeStart}
profiles={profiles}

View File

@@ -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 (
<AgentSessionProvider onOpenAgentSession={handleOpenAgentSession}>
<AgentSessionProvider
onDetachAgentSession={handleDetachAgentSession}
onOpenAgentSession={handleOpenAgentSession}
>
<ChannelScreenHeader
activeChannel={activeChannel}
activeChannelEphemeralDisplay={activeChannelEphemeralDisplay}

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { Activity } from "lucide-react";
import { Activity, ExternalLink } from "lucide-react";
import {
useUserNotesQuery,
@@ -76,7 +76,7 @@ export function UserProfilePopover({
enabled: open,
});
const { onOpenAgentSession } = useAgentSession();
const { onDetachAgentSession, onOpenAgentSession } = useAgentSession();
const relayAgent = relayAgentsQuery.data?.find((a) => 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({
</p>
) : null}
{canViewActivity ? (
<button
className="flex w-full items-center gap-2 rounded-lg border border-border/60 px-3 py-2 text-left text-xs font-medium text-foreground transition-colors hover:bg-muted/50"
data-testid={`user-profile-view-activity-${pubkey}`}
onClick={() => {
setOpen(false);
onOpenAgentSession?.(pubkey);
}}
type="button"
>
<Activity className="h-3.5 w-3.5 text-muted-foreground" />
View activity log
</button>
{canViewActivity || canDetachActivity ? (
<div className="flex gap-2">
{canViewActivity ? (
<button
className="flex min-w-0 flex-1 items-center gap-2 rounded-lg border border-border/60 px-3 py-2 text-left text-xs font-medium text-foreground transition-colors hover:bg-muted/50"
data-testid={`user-profile-view-activity-${pubkey}`}
onClick={() => {
setOpen(false);
onOpenAgentSession?.(pubkey);
}}
type="button"
>
<Activity className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">View activity log</span>
</button>
) : null}
{canDetachActivity && managedAgent ? (
<button
aria-label={`Open ${managedAgent.name} activity in a separate window`}
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-border/60 text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
data-testid={`user-profile-detach-activity-${pubkey}`}
onClick={() => {
setOpen(false);
onDetachAgentSession?.(managedAgent);
}}
type="button"
>
<ExternalLink className="h-3.5 w-3.5" />
</button>
) : null}
</div>
) : null}
<p className="truncate font-mono text-[10px] text-muted-foreground/60">

View File

@@ -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<AgentSessionContextValue>({
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 (

View File

@@ -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 (
<MarkdownContent
{...props}
channels={channels}
goChannel={handleGoChannel}
/>
);
}
function StaticMarkdownInner(props: MarkdownProps) {
const noopGoChannel = React.useCallback(() => {}, []);
return <MarkdownContent {...props} channels={[]} goChannel={noopGoChannel} />;
}
function MarkdownInner(props: MarkdownProps) {
if (props.enableChannelLinks === false) {
return <StaticMarkdownInner {...props} />;
}
return <NavigableMarkdownInner {...props} />;
}
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) &&