mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
17
desktop/src/app/AppLoadingGate.tsx
Normal file
17
desktop/src/app/AppLoadingGate.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
desktop/src/app/MainAppReady.tsx
Normal file
27
desktop/src/app/MainAppReady.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
189
desktop/src/features/agents/ui/DetachedAgentSessionView.tsx
Normal file
189
desktop/src/features/agents/ui/DetachedAgentSessionView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
79
desktop/src/features/agents/ui/detachedAgentSessionWindow.ts
Normal file
79
desktop/src/features/agents/ui/detachedAgentSessionWindow.ts
Normal 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());
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
Reference in New Issue
Block a user