Skip to content
Closed
10 changes: 7 additions & 3 deletions desktop/src/app/routes/channels.$channelId.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";

import {
parseProfilePanelView,
type ProfilePanelView,
} from "@/features/profile/ui/UserProfilePanelUtils";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";

type ChannelRouteSearch = {
agentSession?: string;
messageId?: string;
profile?: string;
profileView?: "memories" | "channels";
profileView?: ProfilePanelView;
thread?: string;
threadRootId?: string;
};
Expand All @@ -16,8 +20,8 @@ function nonEmptyString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}

function profileViewValue(value: unknown): "memories" | "channels" | undefined {
return value === "memories" || value === "channels" ? value : undefined;
function profileViewValue(value: unknown): ProfilePanelView | undefined {
return parseProfilePanelView(value) ?? undefined;
}

function validateChannelSearch(
Expand Down
11 changes: 6 additions & 5 deletions desktop/src/app/routes/pulse.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import * as React from "react";
import { createFileRoute } from "@tanstack/react-router";

import {
parseProfilePanelView,
type ProfilePanelView,
} from "@/features/profile/ui/UserProfilePanelUtils";
import { usePreviewFeatureWarning } from "@/shared/features";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";

Expand All @@ -11,7 +15,7 @@ const PulseScreen = React.lazy(async () => {

type PulseRouteSearch = {
profile?: string;
profileView?: "memories" | "channels";
profileView?: ProfilePanelView;
};

function validatePulseSearch(
Expand All @@ -22,10 +26,7 @@ function validatePulseSearch(
typeof search.profile === "string" && search.profile.length > 0
? search.profile
: undefined,
profileView:
search.profileView === "memories" || search.profileView === "channels"
? search.profileView
: undefined,
profileView: parseProfilePanelView(search.profileView) ?? undefined,
};
}

Expand Down
45 changes: 45 additions & 0 deletions desktop/src/features/agents/observerRelayStore.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import assert from "node:assert/strict";
import { beforeEach, describe, it } from "node:test";

import {
isKnownAgentPubkey,
registerKnownAgentPubkeys,
resetAgentObserverStore,
unregisterKnownAgentPubkeys,
} from "./observerRelayStore.ts";

const AGENT_A =
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const AGENT_B =
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
const AGENT_C =
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";

describe("observerRelayStore known agent registrations", () => {
beforeEach(() => {
resetAgentObserverStore();
});

it("unions known agents from multiple bridge registrations", () => {
const agentsPage = Symbol("agents-page");
const profilePanel = Symbol("profile-panel");

registerKnownAgentPubkeys(agentsPage, [AGENT_A, AGENT_B]);
registerKnownAgentPubkeys(profilePanel, [AGENT_C]);

assert.equal(isKnownAgentPubkey(AGENT_A), true);
assert.equal(isKnownAgentPubkey(AGENT_B), true);
assert.equal(isKnownAgentPubkey(AGENT_C), true);

registerKnownAgentPubkeys(profilePanel, []);

assert.equal(isKnownAgentPubkey(AGENT_A), true);
assert.equal(isKnownAgentPubkey(AGENT_B), true);
assert.equal(isKnownAgentPubkey(AGENT_C), false);

unregisterKnownAgentPubkeys(agentsPage);

assert.equal(isKnownAgentPubkey(AGENT_A), false);
assert.equal(isKnownAgentPubkey(AGENT_B), false);
});
});
18 changes: 11 additions & 7 deletions desktop/src/features/agents/observerRelayStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const snapshotByAgent = new Map<string, ObserverSnapshot>();
// We key each subscriber's contribution in `knownAgentsBySubscription` and
// recompute the union, so co-mounted callers no longer clobber each other.
const knownAgentPubkeys = new Set<string>();
const knownAgentsBySubscription = new Map<string, Set<string>>();
const knownAgentsBySubscription = new Map<string | symbol, Set<string>>();

function recomputeKnownAgentPubkeys() {
knownAgentPubkeys.clear();
Expand All @@ -58,8 +58,8 @@ function recomputeKnownAgentPubkeys() {
}
}

function registerKnownAgents(
subscriptionId: string,
export function registerKnownAgentPubkeys(
subscriptionId: string | symbol,
pubkeys: readonly string[],
) {
knownAgentsBySubscription.set(
Expand All @@ -69,12 +69,16 @@ function registerKnownAgents(
recomputeKnownAgentPubkeys();
}

function unregisterKnownAgents(subscriptionId: string) {
export function unregisterKnownAgentPubkeys(subscriptionId: string | symbol) {
if (knownAgentsBySubscription.delete(subscriptionId)) {
recomputeKnownAgentPubkeys();
}
}

export function isKnownAgentPubkey(pubkey: string) {
return knownAgentPubkeys.has(normalizePubkey(pubkey));
}

let connectionState: ConnectionState = "idle";
let errorMessage: string | null = null;
let unsubscribeRelay: (() => Promise<void>) | null = null;
Expand Down Expand Up @@ -176,7 +180,7 @@ async function handleRelayObserverEvent(

// Verify agent is known/trusted before decrypting.
// Silently drop events from agents we are not managing.
if (!knownAgentPubkeys.has(normalizePubkey(agentPubkey))) {
if (!isKnownAgentPubkey(agentPubkey)) {
return;
}

Expand Down Expand Up @@ -326,9 +330,9 @@ export function useManagedAgentObserverBridge(
// own agent list. The store recomputes the union across all subscribers, so
// a co-mounted caller no longer wipes out this caller's agents.
React.useEffect(() => {
registerKnownAgents(subscriptionId, agentPubkeys);
registerKnownAgentPubkeys(subscriptionId, agentPubkeys);
return () => {
unregisterKnownAgents(subscriptionId);
unregisterKnownAgentPubkeys(subscriptionId);
};
}, [subscriptionId, agentPubkeys]);

Expand Down
21 changes: 3 additions & 18 deletions desktop/src/features/agents/ui/AgentGroupRows.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,40 +6,30 @@ export type AgentGroupRowsProps = {
agents: ManagedAgent[];
channelIdToName: Record<string, string>;
channelsByPubkey: Record<string, { id: string; name: string }[]>;
isActionPending: boolean;
logContent: string | null;
logError: Error | null;
logLoading: boolean;
personaLabelsById: Record<string, string>;
presenceLoaded: boolean;
presenceLookup: PresenceLookup;
selectedLogAgentPubkey: string | null;
onAddToChannel: (agent: ManagedAgent) => void;
onDelete: (pubkey: string) => void;
onOpenProfile: (pubkey: string) => void;
onSelectLogAgent: (pubkey: string | null) => void;
onStart: (pubkey: string) => void;
onStop: (pubkey: string) => void;
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
};

export function AgentGroupRows({
agents,
channelIdToName,
channelsByPubkey,
isActionPending,
logContent,
logError,
logLoading,
personaLabelsById,
presenceLoaded,
presenceLookup,
selectedLogAgentPubkey,
onAddToChannel,
onDelete,
onOpenProfile,
onSelectLogAgent,
onStart,
onStop,
onToggleStartOnAppLaunch,
}: AgentGroupRowsProps) {
return (
<div className="divide-y divide-border/50 border-t border-border/50">
Expand All @@ -48,7 +38,6 @@ export function AgentGroupRows({
agent={agent}
channelIdToName={channelIdToName}
channelNames={channelsByPubkey[normalizePubkey(agent.pubkey)] ?? []}
isActionPending={isActionPending}
isLogSelected={selectedLogAgentPubkey === agent.pubkey}
key={agent.pubkey}
logContent={
Expand All @@ -59,12 +48,8 @@ export function AgentGroupRows({
personaLabelsById={personaLabelsById}
presenceLoaded={presenceLoaded}
presenceLookup={presenceLookup}
onAddToChannel={onAddToChannel}
onDelete={onDelete}
onOpenProfile={onOpenProfile}
onSelectLogAgent={onSelectLogAgent}
onStart={onStart}
onStop={onStop}
onToggleStartOnAppLaunch={onToggleStartOnAppLaunch}
/>
))}
</div>
Expand Down
68 changes: 63 additions & 5 deletions desktop/src/features/agents/ui/AgentsScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,76 @@
import * as React from "react";

import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import { useOpenDmMutation } from "@/features/channels/hooks";
import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel";
import { useIdentityQuery } from "@/shared/api/hooks";
import type { AgentPersona } from "@/shared/api/types";
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";

const AgentsView = React.lazy(async () => {
const module = await import("@/features/agents/ui/AgentsView");
return { default: module.AgentsView };
});

type ProfilePanelTarget =
| { kind: "pubkey"; pubkey: string }
| { kind: "persona"; persona: AgentPersona };

export function AgentsScreen() {
const identityQuery = useIdentityQuery();
const [profilePanelTarget, setProfilePanelTarget] =
React.useState<ProfilePanelTarget | null>(null);
const threadPanelWidth = useThreadPanelWidth();
const openDmMutation = useOpenDmMutation();
const { goChannel } = useAppNavigation();

const handleOpenDm = React.useCallback(
async (pubkeys: string[]) => {
const dm = await openDmMutation.mutateAsync({ pubkeys });
await goChannel(dm.id);
},
[goChannel, openDmMutation],
);

return (
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<React.Suspense fallback={<ViewLoadingFallback kind="agents" />}>
<AgentsView />
</React.Suspense>
</div>
<ProfilePanelProvider
onOpenPersonaProfilePanel={(persona) =>
setProfilePanelTarget({ kind: "persona", persona })
}
onOpenProfilePanel={(pubkey) =>
setProfilePanelTarget({ kind: "pubkey", pubkey })
}
>
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
<React.Suspense fallback={<ViewLoadingFallback kind="agents" />}>
<AgentsView />
</React.Suspense>
{profilePanelTarget ? (
<UserProfilePanel
canResetWidth={threadPanelWidth.canReset}
currentPubkey={identityQuery.data?.pubkey}
onClose={() => setProfilePanelTarget(null)}
onOpenDm={handleOpenDm}
onResetWidth={threadPanelWidth.onResetWidth}
onResizeStart={threadPanelWidth.onResizeStart}
persona={
profilePanelTarget.kind === "persona"
? profilePanelTarget.persona
: undefined
}
pubkey={
profilePanelTarget.kind === "pubkey"
? profilePanelTarget.pubkey
: undefined
}
widthPx={threadPanelWidth.widthPx}
/>
) : null}
</div>
</div>
</ProfilePanelProvider>
);
}
33 changes: 7 additions & 26 deletions desktop/src/features/agents/ui/AgentsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ import { UnifiedAgentsSection } from "./UnifiedAgentsSection";
import { useManagedAgentActions } from "./useManagedAgentActions";
import { usePersonaActions } from "./usePersonaActions";
import { useTeamActions } from "./useTeamActions";
import { useProfilePanel } from "@/shared/context/ProfilePanelContext";

export function AgentsView() {
const { openPersonaProfilePanel, openProfilePanel } = useProfilePanel();
const agents = useManagedAgentActions();
const personas = usePersonaActions();
const teamActions = useTeamActions(
Expand Down Expand Up @@ -83,11 +85,6 @@ export function AgentsView() {
personaLabelsById={personas.personaLabelsById}
presenceLoaded={agents.managedPresenceQuery.isSuccess}
presenceLookup={agents.managedPresenceQuery.data ?? {}}
onAddToChannel={(agent) => {
agents.setActionNoticeMessage(null);
agents.setActionErrorMessage(null);
agents.setAgentToAddToChannel(agent);
}}
onBulkRemoveStopped={() => {
void agents.handleBulkRemoveStopped();
}}
Expand All @@ -97,22 +94,13 @@ export function AgentsView() {
onCreateAgent={() => {
agents.setIsCreateOpen(true);
}}
onDeleteAgent={(pubkey) => {
void agents.handleDelete(pubkey);
}}
onSelectLogAgent={agents.setLogAgentPubkey}
onStartAgent={(pubkey) => {
void agents.handleStart(pubkey);
}}
onStopAgent={(pubkey) => {
void agents.handleStop(pubkey);
onOpenAgentProfile={(pubkey) => {
openProfilePanel?.(pubkey);
}}
onToggleStartOnAppLaunch={(pubkey, startOnAppLaunch) => {
void agents.handleToggleStartOnAppLaunch(
pubkey,
startOnAppLaunch,
);
onOpenPersonaProfile={(persona) => {
openPersonaProfilePanel?.(persona);
}}
onSelectLogAgent={agents.setLogAgentPubkey}
selectedLogAgentPubkey={agents.logAgentPubkey}
// Persona props
canChooseCatalog={personas.catalogPersonas.length > 0}
Expand All @@ -136,13 +124,6 @@ export function AgentsView() {
isPersonasPending={personas.isPending}
onCreatePersona={personas.openCreate}
onChooseCatalog={personas.openCatalog}
onDuplicatePersona={personas.openDuplicate}
onEditPersona={personas.openEdit}
onExportPersona={personas.handleExport}
onDeactivatePersona={(persona) => {
void personas.handleSetActive(persona, false, "library");
}}
onDeletePersona={personas.openDelete}
onImportPersonaFile={(fileBytes, fileName) => {
void personas.handleImportFile(fileBytes, fileName);
}}
Expand Down
Loading
Loading