mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): move agent management into profile sidebar (#1274)
Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: klopez4212 <klopez4212@gmail.com> Co-authored-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Marge <marge@users.noreply.github.com> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
klopez4212
npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f
Marge
npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent
8c3d0c92e8
commit
8d40150c83
@@ -1,14 +1,43 @@
|
||||
import * as React from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import {
|
||||
parseProfilePanelTab,
|
||||
parseProfilePanelView,
|
||||
type ProfilePanelTab,
|
||||
type ProfilePanelView,
|
||||
} from "@/features/profile/ui/UserProfilePanelUtils";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
|
||||
type AgentsRouteSearch = {
|
||||
profile?: string;
|
||||
profilePersona?: string;
|
||||
profileTab?: ProfilePanelTab;
|
||||
profileView?: ProfilePanelView;
|
||||
};
|
||||
|
||||
function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function validateAgentsSearch(
|
||||
search: Record<string, unknown>,
|
||||
): AgentsRouteSearch {
|
||||
return {
|
||||
profile: nonEmptyString(search.profile),
|
||||
profilePersona: nonEmptyString(search.profilePersona),
|
||||
profileTab: parseProfilePanelTab(search.profileTab) ?? undefined,
|
||||
profileView: parseProfilePanelView(search.profileView) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const AgentsScreen = React.lazy(async () => {
|
||||
const module = await import("@/features/agents/ui/AgentsScreen");
|
||||
return { default: module.AgentsScreen };
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/agents")({
|
||||
validateSearch: validateAgentsSearch,
|
||||
component: AgentsRouteComponent,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import * as React from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import {
|
||||
parseProfilePanelTab,
|
||||
parseProfilePanelView,
|
||||
type ProfilePanelTab,
|
||||
type ProfilePanelView,
|
||||
} from "@/features/profile/ui/UserProfilePanelUtils";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
|
||||
type ChannelRouteSearch = {
|
||||
agentSession?: string;
|
||||
messageId?: string;
|
||||
profile?: string;
|
||||
profileView?: "memories" | "channels";
|
||||
profileTab?: ProfilePanelTab;
|
||||
profileView?: ProfilePanelView;
|
||||
thread?: string;
|
||||
threadRootId?: string;
|
||||
};
|
||||
@@ -16,10 +23,6 @@ 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 validateChannelSearch(
|
||||
search: Record<string, unknown>,
|
||||
): ChannelRouteSearch {
|
||||
@@ -27,7 +30,8 @@ function validateChannelSearch(
|
||||
agentSession: nonEmptyString(search.agentSession),
|
||||
messageId: nonEmptyString(search.messageId),
|
||||
profile: nonEmptyString(search.profile),
|
||||
profileView: profileViewValue(search.profileView),
|
||||
profileTab: parseProfilePanelTab(search.profileTab) ?? undefined,
|
||||
profileView: parseProfilePanelView(search.profileView) ?? undefined,
|
||||
thread: nonEmptyString(search.thread),
|
||||
threadRootId: nonEmptyString(search.threadRootId),
|
||||
};
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import * as React from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import {
|
||||
parseProfilePanelTab,
|
||||
parseProfilePanelView,
|
||||
type ProfilePanelTab,
|
||||
type ProfilePanelView,
|
||||
} from "@/features/profile/ui/UserProfilePanelUtils";
|
||||
import { usePreviewFeatureWarning } from "@/shared/features";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
|
||||
@@ -11,7 +17,8 @@ const PulseScreen = React.lazy(async () => {
|
||||
|
||||
type PulseRouteSearch = {
|
||||
profile?: string;
|
||||
profileView?: "memories" | "channels";
|
||||
profileTab?: ProfilePanelTab;
|
||||
profileView?: ProfilePanelView;
|
||||
};
|
||||
|
||||
function validatePulseSearch(
|
||||
@@ -22,10 +29,8 @@ function validatePulseSearch(
|
||||
typeof search.profile === "string" && search.profile.length > 0
|
||||
? search.profile
|
||||
: undefined,
|
||||
profileView:
|
||||
search.profileView === "memories" || search.profileView === "channels"
|
||||
? search.profileView
|
||||
: undefined,
|
||||
profileTab: parseProfilePanelTab(search.profileTab) ?? undefined,
|
||||
profileView: parseProfilePanelView(search.profileView) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from "react";
|
||||
import { AlertTriangle, ChevronDown, RefreshCw } from "lucide-react";
|
||||
import { AlertTriangle, Brain, ChevronDown, RefreshCw } from "lucide-react";
|
||||
|
||||
import { useAgentMemoryGraph } from "@/features/agent-memory/hooks";
|
||||
import type { MemoryTreeNode } from "@/features/agent-memory/lib/buildMemoryGraph";
|
||||
@@ -225,12 +225,16 @@ function MemoryGraphView({
|
||||
const isEmpty = !rootedTree && orphans.length === 0;
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<p
|
||||
className="text-sm italic text-muted-foreground"
|
||||
<div
|
||||
className="flex min-h-56 flex-col items-center justify-center px-6 py-10 text-center"
|
||||
data-testid="agent-memory-empty"
|
||||
>
|
||||
This agent has no memories yet.
|
||||
</p>
|
||||
<Brain className="mx-auto h-4 w-4 text-muted-foreground" />
|
||||
<p className="mt-3 text-sm font-medium">Build this agent's memory</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Try telling this agent to remember something for next time.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { describe, it, beforeEach, afterEach, mock } from "node:test";
|
||||
import {
|
||||
syncAgentTurnsFromEvents,
|
||||
getActiveTurnsForAgent,
|
||||
getActiveTurnsByChannel,
|
||||
resetActiveAgentTurnsStore,
|
||||
subscribeActiveAgentTurns,
|
||||
} from "./activeAgentTurnsStore.ts";
|
||||
@@ -11,6 +12,8 @@ import { formatElapsed } from "./ui/agentSessionUtils.ts";
|
||||
|
||||
const AGENT =
|
||||
"abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234";
|
||||
const AGENT_2 =
|
||||
"dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321dcba4321";
|
||||
|
||||
/** Channel-id Set view of the summary array — keeps legacy assertions terse. */
|
||||
function channelIdsOf(turns) {
|
||||
@@ -163,6 +166,60 @@ describe("activeAgentTurnsStore", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("channel aggregation", () => {
|
||||
it("collapses active turns by channel across agents", () => {
|
||||
syncAgentTurnsFromEvents(AGENT, [
|
||||
makeEvent({
|
||||
seq: 1,
|
||||
turnId: "agent-1-early",
|
||||
channelId: "shared",
|
||||
timestamp: "2024-01-01T00:00:00Z",
|
||||
}),
|
||||
makeEvent({
|
||||
seq: 2,
|
||||
turnId: "agent-1-late",
|
||||
channelId: "shared",
|
||||
timestamp: "2024-01-01T00:01:00Z",
|
||||
}),
|
||||
]);
|
||||
syncAgentTurnsFromEvents(AGENT_2, [
|
||||
makeEvent({
|
||||
seq: 1,
|
||||
turnId: "agent-2",
|
||||
channelId: "shared",
|
||||
timestamp: "2024-01-01T00:02:00Z",
|
||||
}),
|
||||
]);
|
||||
|
||||
const summaries = getActiveTurnsByChannel();
|
||||
assert.deepEqual(
|
||||
summaries.map(({ channelId, agentCount }) => ({
|
||||
channelId,
|
||||
agentCount,
|
||||
})),
|
||||
[{ channelId: "shared", agentCount: 2 }],
|
||||
);
|
||||
assert.equal(
|
||||
summaries[0].anchorAt,
|
||||
getActiveTurnsForAgent(AGENT)[0].anchorAt,
|
||||
);
|
||||
});
|
||||
|
||||
it("removes a channel summary when the last active turn ends", () => {
|
||||
syncAgentTurnsFromEvents(AGENT, [
|
||||
makeEvent({ seq: 1, turnId: "t1", channelId: "c1" }),
|
||||
makeEvent({
|
||||
seq: 2,
|
||||
kind: "turn_completed",
|
||||
turnId: "t1",
|
||||
channelId: "c1",
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.deepEqual(getActiveTurnsByChannel(), []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("endTurn turnId-vs-channelId fallback", () => {
|
||||
it("ends turn by turnId when provided", () => {
|
||||
syncAgentTurnsFromEvents(AGENT, [
|
||||
|
||||
@@ -44,6 +44,13 @@ export type ActiveTurnSummary = {
|
||||
anchorAt: number;
|
||||
};
|
||||
|
||||
/** One channel with active agent work, aggregated across agents. */
|
||||
export type ActiveChannelTurnSummary = {
|
||||
channelId: string;
|
||||
anchorAt: number;
|
||||
agentCount: number;
|
||||
};
|
||||
|
||||
// Module-level state: agentPubkey → turnId → ActiveTurn
|
||||
const activeTurnsByAgent = new Map<string, Map<string, ActiveTurn>>();
|
||||
const listeners = new Set<() => void>();
|
||||
@@ -68,6 +75,7 @@ const clockOffsetByAgent = new Map<string, number>();
|
||||
// Cached snapshots for useSyncExternalStore reference stability.
|
||||
// Only regenerated when the underlying turn map for an agent actually changes.
|
||||
const cachedTurnSummaries = new Map<string, ActiveTurnSummary[]>();
|
||||
let cachedChannelTurnSummaries: ActiveChannelTurnSummary[] | null = null;
|
||||
|
||||
// Composite watermark per agent: the newest observer event processed, by
|
||||
// (timestamp, seq) ordering. An event is processed only if it is strictly
|
||||
@@ -87,6 +95,7 @@ let pruneInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
function invalidateCache(agentKey: string) {
|
||||
cachedTurnSummaries.delete(agentKey);
|
||||
cachedChannelTurnSummaries = null;
|
||||
}
|
||||
|
||||
function notifyListeners() {
|
||||
@@ -427,6 +436,53 @@ export function getActiveTurnsForAgent(
|
||||
}
|
||||
|
||||
const EMPTY_TURNS: ActiveTurnSummary[] = [];
|
||||
const EMPTY_CHANNEL_TURNS: ActiveChannelTurnSummary[] = [];
|
||||
|
||||
/**
|
||||
* Returns active working channels across all tracked agents, sorted by
|
||||
* channelId and anchored to the earliest live turn in each channel.
|
||||
*/
|
||||
export function getActiveTurnsByChannel(): ActiveChannelTurnSummary[] {
|
||||
if (cachedChannelTurnSummaries) return cachedChannelTurnSummaries;
|
||||
if (activeTurnsByAgent.size === 0) return EMPTY_CHANNEL_TURNS;
|
||||
|
||||
const summaries = new Map<
|
||||
string,
|
||||
{ anchorAt: number; agentPubkeys: Set<string> }
|
||||
>();
|
||||
|
||||
for (const [agentKey, agentTurns] of activeTurnsByAgent) {
|
||||
if (agentTurns.size === 0) continue;
|
||||
const offset = clockOffsetByAgent.get(agentKey) ?? 0;
|
||||
|
||||
for (const turn of agentTurns.values()) {
|
||||
const anchorAt = turn.startedAt + offset;
|
||||
const summary = summaries.get(turn.channelId);
|
||||
if (!summary) {
|
||||
summaries.set(turn.channelId, {
|
||||
anchorAt,
|
||||
agentPubkeys: new Set([agentKey]),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.agentPubkeys.add(agentKey);
|
||||
if (anchorAt < summary.anchorAt) {
|
||||
summary.anchorAt = anchorAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = [...summaries.entries()]
|
||||
.map(([channelId, summary]) => ({
|
||||
channelId,
|
||||
anchorAt: summary.anchorAt,
|
||||
agentCount: summary.agentPubkeys.size,
|
||||
}))
|
||||
.sort((a, b) => a.channelId.localeCompare(b.channelId));
|
||||
cachedChannelTurnSummaries = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize the active-turns store with the latest observer events for a
|
||||
@@ -457,6 +513,17 @@ export function useActiveAgentTurns(
|
||||
return React.useSyncExternalStore(subscribeActiveAgentTurns, getSnapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: returns channels with active agent work across all tracked agents.
|
||||
* Re-renders when the channel set changes — not when the clock ticks.
|
||||
*/
|
||||
export function useActiveAgentTurnsByChannel(): ActiveChannelTurnSummary[] {
|
||||
return React.useSyncExternalStore(
|
||||
subscribeActiveAgentTurns,
|
||||
getActiveTurnsByChannel,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge hook: processes observer events into the active-turns store.
|
||||
* Should be called by a parent component that has access to the observer events.
|
||||
@@ -483,6 +550,7 @@ export function resetActiveAgentTurnsStore() {
|
||||
lastProcessed.clear();
|
||||
clockOffsetByAgent.clear();
|
||||
cachedTurnSummaries.clear();
|
||||
cachedChannelTurnSummaries = null;
|
||||
terminalAtByAgent.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -143,9 +143,11 @@ export async function deleteManagedAgentWithRules({
|
||||
preferredChannelId,
|
||||
presenceLookup,
|
||||
relayAgents,
|
||||
skipRemoteDeleteConfirm = false,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
deleteManagedAgent: DeleteManagedAgent;
|
||||
skipRemoteDeleteConfirm?: boolean;
|
||||
} & ManagedAgentActionContext): Promise<ManagedAgentActionResult> {
|
||||
if (agent.backend.type === "provider" && agent.backendAgentId) {
|
||||
const presence = presenceLookup?.[normalizePubkey(agent.pubkey)];
|
||||
@@ -161,30 +163,36 @@ export async function deleteManagedAgentWithRules({
|
||||
agent.pubkey,
|
||||
]);
|
||||
|
||||
const confirmed = window.confirm(
|
||||
"Shutdown command sent, but the agent may still be running. " +
|
||||
"Deleting now removes the local record — the remote deployment " +
|
||||
"will be orphaned if shutdown hasn't completed. Continue?",
|
||||
);
|
||||
if (!confirmed) {
|
||||
return { cancelled: true };
|
||||
if (!skipRemoteDeleteConfirm) {
|
||||
const confirmed = window.confirm(
|
||||
"Shutdown command sent, but the agent may still be running. " +
|
||||
"Deleting now removes the local record — the remote deployment " +
|
||||
"will be orphaned if shutdown hasn't completed. Continue?",
|
||||
);
|
||||
if (!confirmed) {
|
||||
return { cancelled: true };
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const confirmed = window.confirm(
|
||||
"This agent is offline but the remote deployment may still exist. " +
|
||||
"Deleting removes the local management record. Continue?",
|
||||
);
|
||||
if (!confirmed) {
|
||||
return { cancelled: true };
|
||||
if (!skipRemoteDeleteConfirm) {
|
||||
const confirmed = window.confirm(
|
||||
"This agent is offline but the remote deployment may still exist. " +
|
||||
"Deleting removes the local management record. Continue?",
|
||||
);
|
||||
if (!confirmed) {
|
||||
return { cancelled: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const confirmed = window.confirm(
|
||||
"This agent is deployed but not in any channel. " +
|
||||
"Deleting will orphan the remote deployment (it will keep running). Continue?",
|
||||
);
|
||||
if (!confirmed) {
|
||||
return { cancelled: true };
|
||||
if (!skipRemoteDeleteConfirm) {
|
||||
const confirmed = window.confirm(
|
||||
"This agent is deployed but not in any channel. " +
|
||||
"Deleting will orphan the remote deployment (it will keep running). Continue?",
|
||||
);
|
||||
if (!confirmed) {
|
||||
return { cancelled: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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;
|
||||
@@ -14,19 +13,14 @@ export type AgentGroupRowsProps = {
|
||||
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,
|
||||
@@ -34,12 +28,8 @@ export function AgentGroupRows({
|
||||
presenceLoaded,
|
||||
presenceLookup,
|
||||
selectedLogAgentPubkey,
|
||||
onAddToChannel,
|
||||
onDelete,
|
||||
onOpenProfile,
|
||||
onSelectLogAgent,
|
||||
onStart,
|
||||
onStop,
|
||||
onToggleStartOnAppLaunch,
|
||||
}: AgentGroupRowsProps) {
|
||||
return (
|
||||
<div className="divide-y divide-border/50 border-t border-border/50">
|
||||
@@ -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={
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { usePersonasQuery } from "@/features/agents/hooks";
|
||||
import { useOpenDmMutation } from "@/features/channels/hooks";
|
||||
import {
|
||||
type ProfilePanelTab,
|
||||
type ProfilePanelView,
|
||||
UserProfilePanel,
|
||||
} from "@/features/profile/ui/UserProfilePanel";
|
||||
import {
|
||||
profilePanelTabFromSearch,
|
||||
profilePanelViewFromSearch,
|
||||
} from "@/features/profile/ui/UserProfilePanelUtils";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import type { AgentPersona } from "@/shared/api/types";
|
||||
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
|
||||
import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState";
|
||||
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
|
||||
const AgentsView = React.lazy(async () => {
|
||||
@@ -7,12 +24,133 @@ const AgentsView = React.lazy(async () => {
|
||||
return { default: module.AgentsView };
|
||||
});
|
||||
|
||||
type ProfilePanelTarget =
|
||||
| { kind: "pubkey"; pubkey: string }
|
||||
| { kind: "persona"; persona: AgentPersona };
|
||||
|
||||
const AGENTS_PROFILE_SEARCH_KEYS = [
|
||||
"profile",
|
||||
"profilePersona",
|
||||
"profileTab",
|
||||
"profileView",
|
||||
] as const;
|
||||
|
||||
export function AgentsScreen() {
|
||||
const identityQuery = useIdentityQuery();
|
||||
const personasQuery = usePersonasQuery();
|
||||
const { applyPatch, values } = useHistorySearchState(
|
||||
AGENTS_PROFILE_SEARCH_KEYS,
|
||||
);
|
||||
const profilePanelTab = profilePanelTabFromSearch(values.profileTab);
|
||||
const profilePanelView = profilePanelViewFromSearch(values.profileView);
|
||||
const profilePanelTarget = React.useMemo<ProfilePanelTarget | null>(() => {
|
||||
if (values.profile) {
|
||||
return { kind: "pubkey", pubkey: values.profile };
|
||||
}
|
||||
|
||||
if (values.profilePersona) {
|
||||
const persona = personasQuery.data?.find(
|
||||
(candidate) => candidate.id === values.profilePersona,
|
||||
);
|
||||
if (persona) {
|
||||
return { kind: "persona", persona };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [personasQuery.data, values.profile, values.profilePersona]);
|
||||
const threadPanelWidth = useThreadPanelWidth();
|
||||
const openDmMutation = useOpenDmMutation();
|
||||
const { goChannel } = useAppNavigation();
|
||||
|
||||
const handleOpenProfilePanel = React.useCallback(
|
||||
(pubkey: string) => {
|
||||
applyPatch({
|
||||
profile: pubkey,
|
||||
profilePersona: null,
|
||||
profileTab: null,
|
||||
profileView: null,
|
||||
});
|
||||
},
|
||||
[applyPatch],
|
||||
);
|
||||
|
||||
const handleOpenPersonaProfilePanel = React.useCallback(
|
||||
(persona: AgentPersona) => {
|
||||
applyPatch({
|
||||
profile: null,
|
||||
profilePersona: persona.id,
|
||||
profileTab: null,
|
||||
profileView: null,
|
||||
});
|
||||
},
|
||||
[applyPatch],
|
||||
);
|
||||
const handleCloseProfilePanel = React.useCallback(() => {
|
||||
applyPatch({
|
||||
profile: null,
|
||||
profilePersona: null,
|
||||
profileTab: null,
|
||||
profileView: null,
|
||||
});
|
||||
}, [applyPatch]);
|
||||
const handleProfilePanelViewChange = React.useCallback(
|
||||
(view: ProfilePanelView, options?: { replace?: boolean }) =>
|
||||
applyPatch({ profileView: view === "summary" ? null : view }, options),
|
||||
[applyPatch],
|
||||
);
|
||||
const handleProfilePanelTabChange = React.useCallback(
|
||||
(tab: ProfilePanelTab, options?: { replace?: boolean }) =>
|
||||
applyPatch({ profileTab: tab === "info" ? null : tab }, options),
|
||||
[applyPatch],
|
||||
);
|
||||
|
||||
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={handleOpenPersonaProfilePanel}
|
||||
onOpenProfilePanel={handleOpenProfilePanel}
|
||||
>
|
||||
<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={handleCloseProfilePanel}
|
||||
onOpenDm={handleOpenDm}
|
||||
onOpenProfile={handleOpenProfilePanel}
|
||||
onResetWidth={threadPanelWidth.onResetWidth}
|
||||
onResizeStart={threadPanelWidth.onResizeStart}
|
||||
onTabChange={handleProfilePanelTabChange}
|
||||
onViewChange={handleProfilePanelViewChange}
|
||||
persona={
|
||||
profilePanelTarget.kind === "persona"
|
||||
? profilePanelTarget.persona
|
||||
: undefined
|
||||
}
|
||||
pubkey={
|
||||
profilePanelTarget.kind === "pubkey"
|
||||
? profilePanelTarget.pubkey
|
||||
: undefined
|
||||
}
|
||||
tab={profilePanelTab}
|
||||
view={profilePanelView}
|
||||
widthPx={threadPanelWidth.widthPx}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</ProfilePanelProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
@@ -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();
|
||||
}}
|
||||
@@ -97,22 +94,13 @@ export function AgentsView() {
|
||||
onCreateAgent={() => {
|
||||
agents.setIsCreateOpen(true);
|
||||
}}
|
||||
onDeleteAgent={(pubkey) => {
|
||||
void agents.handleDelete(pubkey);
|
||||
onOpenAgentProfile={(pubkey) => {
|
||||
openProfilePanel?.(pubkey);
|
||||
}}
|
||||
onOpenPersonaProfile={(persona) => {
|
||||
openPersonaProfilePanel?.(persona);
|
||||
}}
|
||||
onSelectLogAgent={agents.setLogAgentPubkey}
|
||||
onStartAgent={(pubkey) => {
|
||||
void agents.handleStart(pubkey);
|
||||
}}
|
||||
onStopAgent={(pubkey) => {
|
||||
void agents.handleStop(pubkey);
|
||||
}}
|
||||
onToggleStartOnAppLaunch={(pubkey, startOnAppLaunch) => {
|
||||
void agents.handleToggleStartOnAppLaunch(
|
||||
pubkey,
|
||||
startOnAppLaunch,
|
||||
);
|
||||
}}
|
||||
selectedLogAgentPubkey={agents.logAgentPubkey}
|
||||
// Persona props
|
||||
canChooseCatalog={personas.catalogPersonas.length > 0}
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
import { Copy } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Button, type ButtonProps } from "@/shared/ui/button";
|
||||
|
||||
export function CopyButton({
|
||||
value,
|
||||
className,
|
||||
iconOnly = false,
|
||||
label,
|
||||
size = "sm",
|
||||
value,
|
||||
variant = "outline",
|
||||
}: {
|
||||
value: string;
|
||||
className?: string;
|
||||
iconOnly?: boolean;
|
||||
label?: string;
|
||||
size?: ButtonProps["size"];
|
||||
value: string;
|
||||
variant?: ButtonProps["variant"];
|
||||
}) {
|
||||
const resolvedLabel = label ?? "Copy";
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success("Copied to clipboard");
|
||||
}}
|
||||
size="sm"
|
||||
size={size}
|
||||
type="button"
|
||||
variant="outline"
|
||||
variant={variant}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
<span>{label ?? "Copy"}</span>
|
||||
<span className={iconOnly ? "sr-only" : undefined}>{resolvedLabel}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@ import { CopyButton } from "./CopyButton";
|
||||
import { describeLogFile } from "./agentUi";
|
||||
|
||||
export function ManagedAgentLogPanel({
|
||||
chrome = "framed",
|
||||
error,
|
||||
isLoading,
|
||||
logContent,
|
||||
selectedAgent,
|
||||
variant = "section",
|
||||
}: {
|
||||
chrome?: "bare" | "framed";
|
||||
error: Error | null;
|
||||
isLoading: boolean;
|
||||
logContent: string | null;
|
||||
@@ -20,6 +22,10 @@ export function ManagedAgentLogPanel({
|
||||
variant?: "inline" | "section";
|
||||
}) {
|
||||
const isInline = variant === "inline";
|
||||
const isBare = chrome === "bare";
|
||||
const logFileLabel = selectedAgent
|
||||
? describeLogFile(selectedAgent.logPath)
|
||||
: null;
|
||||
|
||||
if (!selectedAgent && isInline) {
|
||||
return null;
|
||||
@@ -28,27 +34,32 @@ export function ManagedAgentLogPanel({
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
isInline
|
||||
? ""
|
||||
? "h-full min-h-0"
|
||||
: "rounded-[28px] border border-border/70 bg-card/90 p-5 shadow-xs",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold tracking-tight">Harness log</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedAgent
|
||||
? `${selectedAgent.name} · ${describeLogFile(selectedAgent.logPath)}`
|
||||
: "Select a local agent to inspect recent output."}
|
||||
</p>
|
||||
{!selectedAgent ? (
|
||||
<div className="flex shrink-0 flex-col gap-2 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold tracking-tight">
|
||||
Harness Log
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select a local agent to inspect recent output.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{selectedAgent ? (
|
||||
<CopyButton label="Copy log" value={logContent ?? ""} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!selectedAgent ? (
|
||||
<div className="mt-4 rounded-xl border border-dashed border-border/80 bg-background/70 px-6 py-10 text-center">
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 rounded-xl border border-dashed border-border/80 bg-background/70 px-6 py-10 text-center",
|
||||
isInline && "flex min-h-0 flex-1 flex-col justify-center",
|
||||
)}
|
||||
>
|
||||
<p className="text-sm font-semibold tracking-tight">
|
||||
No local agent selected
|
||||
</p>
|
||||
@@ -57,22 +68,67 @@ export function ManagedAgentLogPanel({
|
||||
</p>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="mt-4 rounded-xl border border-border/70 bg-background/80 p-4">
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="mt-3 h-4 w-full" />
|
||||
<Skeleton className="mt-2 h-4 w-full" />
|
||||
<Skeleton className="mt-2 h-4 w-3/4" />
|
||||
<div
|
||||
className={cn(
|
||||
isBare
|
||||
? "overflow-hidden rounded-2xl bg-muted/20 text-xs text-foreground"
|
||||
: "overflow-hidden rounded-xl border border-border/70 bg-[#17171d] text-xs text-zinc-100",
|
||||
isInline ? "flex min-h-0 flex-1 flex-col" : "mt-4",
|
||||
)}
|
||||
>
|
||||
{!isBare ? (
|
||||
<HarnessLogHeader
|
||||
logContent={logContent ?? ""}
|
||||
logFileLabel={logFileLabel ?? ""}
|
||||
selectedAgent={selectedAgent}
|
||||
/>
|
||||
) : null}
|
||||
<div className="p-4">
|
||||
<Skeleton
|
||||
className={cn("h-4 w-48", isBare ? "bg-muted" : "bg-white/10")}
|
||||
/>
|
||||
<Skeleton
|
||||
className={cn(
|
||||
"mt-3 h-4 w-full",
|
||||
isBare ? "bg-muted" : "bg-white/10",
|
||||
)}
|
||||
/>
|
||||
<Skeleton
|
||||
className={cn(
|
||||
"mt-2 h-4 w-full",
|
||||
isBare ? "bg-muted" : "bg-white/10",
|
||||
)}
|
||||
/>
|
||||
<Skeleton
|
||||
className={cn(
|
||||
"mt-2 h-4 w-3/4",
|
||||
isBare ? "bg-muted" : "bg-white/10",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 overflow-hidden rounded-xl border border-border/70 bg-[#17171d] text-xs text-zinc-100">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2 text-2xs uppercase tracking-[0.18em] text-zinc-400">
|
||||
<span>{selectedAgent.name}</span>
|
||||
<span>{selectedAgent.status}</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
isBare
|
||||
? "overflow-hidden rounded-2xl bg-muted/20 text-xs text-foreground"
|
||||
: "overflow-hidden rounded-xl border border-border/70 bg-[#17171d] text-xs text-zinc-100",
|
||||
isInline && "flex min-h-0 flex-1 flex-col",
|
||||
!isInline && "mt-4",
|
||||
)}
|
||||
>
|
||||
{!isBare ? (
|
||||
<HarnessLogHeader
|
||||
logContent={logContent ?? ""}
|
||||
logFileLabel={logFileLabel ?? ""}
|
||||
selectedAgent={selectedAgent}
|
||||
/>
|
||||
) : null}
|
||||
<pre
|
||||
className={cn(
|
||||
"overflow-auto whitespace-pre-wrap px-4 py-4",
|
||||
isInline ? "max-h-[18rem]" : "max-h-[22rem]",
|
||||
isInline ? "min-h-0 flex-1" : "max-h-88",
|
||||
isBare && "font-mono",
|
||||
)}
|
||||
data-testid="managed-agent-log-content"
|
||||
>
|
||||
@@ -90,3 +146,38 @@ export function ManagedAgentLogPanel({
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function HarnessLogHeader({
|
||||
logContent,
|
||||
logFileLabel,
|
||||
selectedAgent,
|
||||
}: {
|
||||
logContent: string;
|
||||
logFileLabel: string;
|
||||
selectedAgent: ManagedAgent;
|
||||
}) {
|
||||
const fileTitle = `${selectedAgent.name} · ${logFileLabel}`;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-12 items-center justify-between gap-3 border-b border-white/10 px-3 py-2">
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="min-w-0 truncate text-2xs font-semibold uppercase tracking-[0.18em] text-zinc-300">
|
||||
Harness Log
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 truncate font-mono text-2xs text-zinc-500"
|
||||
title={fileTitle}
|
||||
>
|
||||
{selectedAgent.name} · {logFileLabel}
|
||||
</span>
|
||||
</div>
|
||||
<CopyButton
|
||||
className="h-6 rounded-md bg-black/40 px-2 text-zinc-300 hover:bg-black/70 hover:text-white"
|
||||
label="Copy log"
|
||||
size="xs"
|
||||
value={logContent}
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Clipboard,
|
||||
Ellipsis,
|
||||
FileText,
|
||||
Pencil,
|
||||
Play,
|
||||
Power,
|
||||
Square,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { AlertTriangle, ChevronDown, ChevronRight } from "lucide-react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
|
||||
@@ -29,24 +15,15 @@ import type {
|
||||
PresenceStatus,
|
||||
} from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { EditAgentDialog } from "./EditAgentDialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError";
|
||||
import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel";
|
||||
import { ModelPicker } from "./ModelPicker";
|
||||
import { truncatePubkey } from "./agentUi";
|
||||
|
||||
export function ManagedAgentRow({
|
||||
agent,
|
||||
channelIdToName,
|
||||
channelNames,
|
||||
isActionPending,
|
||||
isLogSelected,
|
||||
logContent,
|
||||
logError,
|
||||
@@ -54,17 +31,12 @@ export function ManagedAgentRow({
|
||||
personaLabelsById,
|
||||
presenceLoaded,
|
||||
presenceLookup,
|
||||
onAddToChannel,
|
||||
onDelete,
|
||||
onOpenProfile,
|
||||
onSelectLogAgent,
|
||||
onStart,
|
||||
onStop,
|
||||
onToggleStartOnAppLaunch,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
channelIdToName: Record<string, string>;
|
||||
channelNames: { id: string; name: string }[];
|
||||
isActionPending: boolean;
|
||||
isLogSelected: boolean;
|
||||
logContent: string | null;
|
||||
logError: Error | null;
|
||||
@@ -72,14 +44,9 @@ export function ManagedAgentRow({
|
||||
personaLabelsById: Record<string, string>;
|
||||
presenceLoaded: boolean;
|
||||
presenceLookup: PresenceLookup;
|
||||
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;
|
||||
}) {
|
||||
const isActive = agent.status === "running" || agent.status === "deployed";
|
||||
const isLocal = agent.backend.type === "local";
|
||||
const runtimeSource =
|
||||
agent.backend.type === "provider" ? `Remote (${agent.backend.id})` : null;
|
||||
@@ -181,18 +148,14 @@ export function ManagedAgentRow({
|
||||
)}
|
||||
|
||||
<div className="flex shrink-0 items-start gap-2 lg:pt-0.5">
|
||||
<ModelPicker agent={agent} />
|
||||
<AgentActionsMenu
|
||||
agent={agent}
|
||||
isActionPending={isActionPending}
|
||||
isActive={isActive}
|
||||
onAddToChannel={onAddToChannel}
|
||||
onDelete={onDelete}
|
||||
onOpenLogs={(pubkey) => onSelectLogAgent(pubkey)}
|
||||
onStart={onStart}
|
||||
onStop={onStop}
|
||||
onToggleStartOnAppLaunch={onToggleStartOnAppLaunch}
|
||||
/>
|
||||
<Button
|
||||
onClick={() => onOpenProfile(agent.pubkey)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -414,151 +377,6 @@ function RuntimeBlock({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentActionsMenu({
|
||||
agent,
|
||||
isActionPending,
|
||||
isActive,
|
||||
onAddToChannel,
|
||||
onDelete,
|
||||
onOpenLogs,
|
||||
onStart,
|
||||
onStop,
|
||||
onToggleStartOnAppLaunch,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
isActionPending: boolean;
|
||||
isActive: boolean;
|
||||
onAddToChannel: (agent: ManagedAgent) => void;
|
||||
onDelete: (pubkey: string) => void;
|
||||
onOpenLogs: (pubkey: string) => void;
|
||||
onStart: (pubkey: string) => void;
|
||||
onStop: (pubkey: string) => void;
|
||||
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
|
||||
}) {
|
||||
const [editOpen, setEditOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
aria-label={`Agent actions for ${agent.name}`}
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
data-testid={`managed-agent-actions-${agent.pubkey}`}
|
||||
type="button"
|
||||
>
|
||||
<Ellipsis className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{agent.backend.type === "provider" ? (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() => onStart(agent.pubkey)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
{isActive ? "Redeploy" : "Deploy"}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() => onStop(agent.pubkey)}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Shutdown
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : isActive ? (
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() => onStop(agent.pubkey)}
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() => onStart(agent.pubkey)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
Spawn
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{agent.backend.type !== "provider" ? (
|
||||
<DropdownMenuItem onClick={() => setEditOpen(true)}>
|
||||
<Pencil className="h-4 w-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() => onAddToChannel(agent)}
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Add to channel
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(agent.pubkey);
|
||||
toast.success("Copied pubkey to clipboard");
|
||||
}}
|
||||
>
|
||||
<Clipboard className="h-4 w-4" />
|
||||
Copy pubkey
|
||||
</DropdownMenuItem>
|
||||
|
||||
{agent.backend.type === "local" ? (
|
||||
<DropdownMenuItem onClick={() => onOpenLogs(agent.pubkey)}>
|
||||
<FileText className="h-4 w-4" />
|
||||
View logs
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
||||
{agent.backend.type === "local" ? (
|
||||
<DropdownMenuItem
|
||||
disabled={isActionPending}
|
||||
onClick={() =>
|
||||
onToggleStartOnAppLaunch(agent.pubkey, !agent.startOnAppLaunch)
|
||||
}
|
||||
>
|
||||
<Power className="h-4 w-4" />
|
||||
{agent.startOnAppLaunch
|
||||
? "Disable auto-start"
|
||||
: "Enable auto-start"}
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isActionPending}
|
||||
onClick={() => onDelete(agent.pubkey)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{editOpen ? (
|
||||
<EditAgentDialog
|
||||
agent={agent}
|
||||
onOpenChange={setEditOpen}
|
||||
open={editOpen}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentOriginBadge({ agent }: { agent: ManagedAgent }) {
|
||||
return (
|
||||
<Badge variant="outline">
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
import { AgentGroupRows } from "./AgentGroupRows";
|
||||
import { PersonaActionsMenu } from "./PersonaActionsMenu";
|
||||
import { PersonaIdentity } from "./PersonaIdentity";
|
||||
import { PersonaLibraryEntryPoints } from "./PersonaLibraryEntryPoints";
|
||||
|
||||
@@ -47,15 +46,12 @@ type UnifiedAgentsSectionProps = {
|
||||
personaLabelsById: Record<string, string>;
|
||||
presenceLoaded: boolean;
|
||||
presenceLookup: PresenceLookup;
|
||||
onAddToChannel: (agent: ManagedAgent) => void;
|
||||
onBulkRemoveStopped: () => void;
|
||||
onBulkStopRunning: () => void;
|
||||
onCreateAgent: () => void;
|
||||
onDeleteAgent: (pubkey: string) => void;
|
||||
onOpenAgentProfile: (pubkey: string) => void;
|
||||
onOpenPersonaProfile: (persona: AgentPersona) => void;
|
||||
onSelectLogAgent: (pubkey: string | null) => void;
|
||||
onStartAgent: (pubkey: string) => void;
|
||||
onStopAgent: (pubkey: string) => void;
|
||||
onToggleStartOnAppLaunch: (pubkey: string, startOnAppLaunch: boolean) => void;
|
||||
selectedLogAgentPubkey: string | null;
|
||||
canChooseCatalog: boolean;
|
||||
personas: AgentPersona[];
|
||||
@@ -66,11 +62,6 @@ type UnifiedAgentsSectionProps = {
|
||||
isPersonasPending: boolean;
|
||||
onCreatePersona: () => void;
|
||||
onChooseCatalog: () => void;
|
||||
onDuplicatePersona: (persona: AgentPersona) => void;
|
||||
onEditPersona: (persona: AgentPersona) => void;
|
||||
onExportPersona: (persona: AgentPersona) => void;
|
||||
onDeactivatePersona: (persona: AgentPersona) => void;
|
||||
onDeletePersona: (persona: AgentPersona) => void;
|
||||
onImportPersonaFile: (fileBytes: number[], fileName: string) => void;
|
||||
};
|
||||
|
||||
@@ -120,15 +111,12 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
personaLabelsById,
|
||||
presenceLoaded,
|
||||
presenceLookup,
|
||||
onAddToChannel,
|
||||
onBulkRemoveStopped,
|
||||
onBulkStopRunning,
|
||||
onCreateAgent,
|
||||
onDeleteAgent,
|
||||
onOpenAgentProfile,
|
||||
onOpenPersonaProfile,
|
||||
onSelectLogAgent,
|
||||
onStartAgent,
|
||||
onStopAgent,
|
||||
onToggleStartOnAppLaunch,
|
||||
selectedLogAgentPubkey,
|
||||
canChooseCatalog,
|
||||
personas,
|
||||
@@ -139,11 +127,6 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
isPersonasPending,
|
||||
onCreatePersona,
|
||||
onChooseCatalog,
|
||||
onDuplicatePersona,
|
||||
onEditPersona,
|
||||
onExportPersona,
|
||||
onDeactivatePersona,
|
||||
onDeletePersona,
|
||||
onImportPersonaFile,
|
||||
} = props;
|
||||
|
||||
@@ -188,12 +171,8 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
presenceLoaded,
|
||||
presenceLookup,
|
||||
selectedLogAgentPubkey,
|
||||
onAddToChannel,
|
||||
onDelete: onDeleteAgent,
|
||||
onOpenProfile: onOpenAgentProfile,
|
||||
onSelectLogAgent,
|
||||
onStart: onStartAgent,
|
||||
onStop: onStopAgent,
|
||||
onToggleStartOnAppLaunch,
|
||||
} as const;
|
||||
|
||||
return (
|
||||
@@ -277,16 +256,15 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
) : !hasAgents ? (
|
||||
<Badge variant="outline">Inactive</Badge>
|
||||
) : null}
|
||||
<PersonaActionsMenu
|
||||
isActionPending={isActionPending}
|
||||
isPending={isPersonasPending}
|
||||
persona={g.persona}
|
||||
onDuplicate={onDuplicatePersona}
|
||||
onEdit={onEditPersona}
|
||||
onExport={onExportPersona}
|
||||
onDeactivate={onDeactivatePersona}
|
||||
onDelete={onDeletePersona}
|
||||
/>
|
||||
<Button
|
||||
disabled={isPersonasPending}
|
||||
onClick={() => onOpenPersonaProfile(g.persona)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!isCollapsed && hasAgents ? (
|
||||
|
||||
@@ -30,7 +30,7 @@ import type { ChannelAgentSessionAgent } from "./useChannelAgentSessions";
|
||||
|
||||
type AgentSessionThreadPanelProps = {
|
||||
agent: ChannelAgentSessionAgent;
|
||||
channel: Channel;
|
||||
channel: Channel | null;
|
||||
canInterruptTurn: boolean;
|
||||
isWorking: boolean;
|
||||
layout?: "standalone" | "split";
|
||||
@@ -62,6 +62,10 @@ export function AgentSessionThreadPanel({
|
||||
const { ref: scrollRef, onScroll } = useStickToBottom<HTMLDivElement>();
|
||||
|
||||
async function handleInterruptTurn() {
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await cancelManagedAgentTurn(agent.pubkey, channel.id);
|
||||
toast.success(
|
||||
@@ -155,9 +159,13 @@ export function AgentSessionThreadPanel({
|
||||
>
|
||||
<ManagedAgentSessionPanel
|
||||
agent={agent}
|
||||
channelId={channel.id}
|
||||
channelId={channel?.id ?? null}
|
||||
className="border-0 bg-transparent p-0 shadow-none"
|
||||
emptyDescription={`Mention ${agent.name} in the channel to see its work here.`}
|
||||
emptyDescription={
|
||||
channel
|
||||
? `Mention ${agent.name} in the channel to see its work here.`
|
||||
: `Mention ${agent.name} in any channel to see its work here.`
|
||||
}
|
||||
profiles={profiles}
|
||||
showHeader={false}
|
||||
showRaw={false}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeig
|
||||
import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow";
|
||||
import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping";
|
||||
import {
|
||||
type ProfilePanelTab,
|
||||
type ProfilePanelView,
|
||||
UserProfilePanel,
|
||||
} from "@/features/profile/ui/UserProfilePanel";
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
isWelcomeSetupSystemMessage,
|
||||
mentionsKnownAgent,
|
||||
} from "@/features/channels/ui/ChannelPane.helpers";
|
||||
import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection";
|
||||
import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import type { useChannelFind } from "@/features/search/useChannelFind";
|
||||
@@ -107,7 +109,7 @@ type ChannelPaneProps = {
|
||||
onExpandThreadReplies: (message: TimelineMessage) => void;
|
||||
onJoinChannel?: () => Promise<void>;
|
||||
onOpenAgentSession: (pubkey: string) => void;
|
||||
onOpenDm?: (pubkeys: string[]) => void;
|
||||
onOpenDm?: (pubkeys: string[]) => Promise<void> | void;
|
||||
onOpenMembers?: () => void;
|
||||
onOpenProfilePanel: (pubkey: string) => void;
|
||||
onOpenThread: (message: TimelineMessage) => void;
|
||||
@@ -149,7 +151,12 @@ type ChannelPaneProps = {
|
||||
view: ProfilePanelView,
|
||||
options?: { replace?: boolean },
|
||||
) => void;
|
||||
onProfilePanelTabChange: (
|
||||
tab: ProfilePanelTab,
|
||||
options?: { replace?: boolean },
|
||||
) => void;
|
||||
profilePanelPubkey?: string | null;
|
||||
profilePanelTab: ProfilePanelTab;
|
||||
profilePanelView: ProfilePanelView;
|
||||
threadHeadMessage: TimelineMessage | null;
|
||||
threadMessages: MainTimelineEntry[];
|
||||
@@ -235,7 +242,9 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
shouldShowThreadSkeleton,
|
||||
openAgentSessionPubkey,
|
||||
onProfilePanelViewChange,
|
||||
onProfilePanelTabChange,
|
||||
profilePanelPubkey,
|
||||
profilePanelTab,
|
||||
profilePanelView,
|
||||
targetMessageId,
|
||||
threadHeadMessage,
|
||||
@@ -307,9 +316,6 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
isActiveWelcomeChannel,
|
||||
]);
|
||||
|
||||
// Scope the edit target to the correct composer: if the message being edited
|
||||
// lives inside the open thread (thread head or a reply), show the editing UI
|
||||
// only in the thread panel; otherwise show it in the main channel composer.
|
||||
const isEditInThread =
|
||||
editTarget != null &&
|
||||
threadHeadMessage != null &&
|
||||
@@ -318,15 +324,6 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
const mainEditTarget = editTarget && !isEditInThread ? editTarget : null;
|
||||
const threadEditTarget = editTarget && isEditInThread ? editTarget : null;
|
||||
|
||||
// ↑-to-edit resolvers. Find the most recent message authored by the current
|
||||
// user in the relevant scope and enter edit mode via `onEdit`. Editability
|
||||
// mirrors the action bar's gate (`message.pubkey === currentPubkey`); we
|
||||
// also skip optimistic `pending` messages, which have no persisted event id
|
||||
// to target. Both scopes are passed in chronological (oldest→newest) order,
|
||||
// so we select by newest `createdAt` and break ties toward the later array
|
||||
// position (`>=`) — `createdAt` is second-granularity, so a reply sent in
|
||||
// the same second as the message before it must still win. Returns true when
|
||||
// a target was found so MessageComposer can swallow the ArrowUp.
|
||||
const findLastOwnEditable = React.useCallback(
|
||||
(candidates: TimelineMessage[]): TimelineMessage | null => {
|
||||
if (!onEdit || !currentPubkey) return null;
|
||||
@@ -357,8 +354,6 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
|
||||
const handleEditLastOwnThreadMessage = React.useCallback((): boolean => {
|
||||
if (!onEdit) return false;
|
||||
// Thread scope = the open thread head plus its replies, in chronological
|
||||
// order. The head is oldest, so append it first.
|
||||
const scope: TimelineMessage[] = [];
|
||||
if (threadHeadMessage) scope.push(threadHeadMessage);
|
||||
for (const entry of threadMessages) scope.push(entry.message);
|
||||
@@ -608,16 +603,30 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
|
||||
const isOverlay = useIsThreadPanelOverlay();
|
||||
const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay;
|
||||
|
||||
const selectedAgent = React.useMemo(
|
||||
() =>
|
||||
openAgentSessionPubkey
|
||||
? (agentSessionAgents.find(
|
||||
(agent) => agent.pubkey === openAgentSessionPubkey,
|
||||
) ?? null)
|
||||
: null,
|
||||
[agentSessionAgents, openAgentSessionPubkey],
|
||||
agentSessionSelection.resolveSelectedAgentSession({
|
||||
agentSessionAgents,
|
||||
openAgentSessionPubkey,
|
||||
profilePanelPubkey,
|
||||
profiles,
|
||||
}),
|
||||
[agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles],
|
||||
);
|
||||
const wrapAux = (panel: React.ReactNode, testId: string) =>
|
||||
useSplitAuxiliaryPane ? (
|
||||
<RightAuxiliaryPane
|
||||
canResetWidth={canResetThreadPanelWidth}
|
||||
onResetWidth={onResetThreadPanelWidth}
|
||||
onResizeStart={onThreadPanelResizeStart}
|
||||
testId={testId}
|
||||
widthPx={threadPanelWidthPx}
|
||||
>
|
||||
{panel}
|
||||
</RightAuxiliaryPane>
|
||||
) : (
|
||||
panel
|
||||
);
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-row overflow-hidden">
|
||||
{!isSinglePanelView ? (
|
||||
@@ -881,19 +890,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
}
|
||||
/>
|
||||
);
|
||||
return useSplitAuxiliaryPane ? (
|
||||
<RightAuxiliaryPane
|
||||
canResetWidth={canResetThreadPanelWidth}
|
||||
onResetWidth={onResetThreadPanelWidth}
|
||||
onResizeStart={onThreadPanelResizeStart}
|
||||
testId="message-thread-panel"
|
||||
widthPx={threadPanelWidthPx}
|
||||
>
|
||||
{panel}
|
||||
</RightAuxiliaryPane>
|
||||
) : (
|
||||
panel
|
||||
);
|
||||
return wrapAux(panel, "message-thread-panel");
|
||||
})()
|
||||
) : shouldShowThreadSkeleton ? (
|
||||
(() => {
|
||||
@@ -907,19 +904,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
widthPx={threadPanelWidthPx}
|
||||
/>
|
||||
);
|
||||
return useSplitAuxiliaryPane ? (
|
||||
<RightAuxiliaryPane
|
||||
canResetWidth={canResetThreadPanelWidth}
|
||||
onResetWidth={onResetThreadPanelWidth}
|
||||
onResizeStart={onThreadPanelResizeStart}
|
||||
testId="message-thread-panel"
|
||||
widthPx={threadPanelWidthPx}
|
||||
>
|
||||
{panel}
|
||||
</RightAuxiliaryPane>
|
||||
) : (
|
||||
panel
|
||||
);
|
||||
return wrapAux(panel, "message-thread-panel");
|
||||
})()
|
||||
) : activeChannel && selectedAgent ? (
|
||||
(() => {
|
||||
@@ -927,7 +912,14 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
<AgentSessionThreadPanel
|
||||
agent={selectedAgent}
|
||||
canInterruptTurn={selectedAgent.canInterruptTurn}
|
||||
channel={activeChannel}
|
||||
channel={
|
||||
agentSessionSelection.isAgentInActivityList({
|
||||
activityAgents,
|
||||
selectedAgent,
|
||||
})
|
||||
? activeChannel
|
||||
: null
|
||||
}
|
||||
isWorking={botTypingEntries.some(
|
||||
(entry) =>
|
||||
entry.pubkey.toLowerCase() ===
|
||||
@@ -943,19 +935,7 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
widthPx={threadPanelWidthPx}
|
||||
/>
|
||||
);
|
||||
return useSplitAuxiliaryPane ? (
|
||||
<RightAuxiliaryPane
|
||||
canResetWidth={canResetThreadPanelWidth}
|
||||
onResetWidth={onResetThreadPanelWidth}
|
||||
onResizeStart={onThreadPanelResizeStart}
|
||||
testId="agent-session-thread-panel"
|
||||
widthPx={threadPanelWidthPx}
|
||||
>
|
||||
{panel}
|
||||
</RightAuxiliaryPane>
|
||||
) : (
|
||||
panel
|
||||
);
|
||||
return wrapAux(panel, "agent-session-thread-panel");
|
||||
})()
|
||||
) : profilePanelPubkey ? (
|
||||
(() => {
|
||||
@@ -969,26 +949,16 @@ export const ChannelPane = React.memo(function ChannelPane({
|
||||
onClose={onCloseProfilePanel}
|
||||
onOpenDm={onOpenDm}
|
||||
onOpenProfile={onOpenProfilePanel}
|
||||
onTabChange={onProfilePanelTabChange}
|
||||
onViewChange={onProfilePanelViewChange}
|
||||
pubkey={profilePanelPubkey}
|
||||
splitPaneClamp
|
||||
tab={profilePanelTab}
|
||||
view={profilePanelView}
|
||||
widthPx={threadPanelWidthPx}
|
||||
/>
|
||||
);
|
||||
return useSplitAuxiliaryPane ? (
|
||||
<RightAuxiliaryPane
|
||||
canResetWidth={canResetThreadPanelWidth}
|
||||
onResetWidth={onResetThreadPanelWidth}
|
||||
onResizeStart={onThreadPanelResizeStart}
|
||||
testId="user-profile-panel"
|
||||
widthPx={threadPanelWidthPx}
|
||||
>
|
||||
{panel}
|
||||
</RightAuxiliaryPane>
|
||||
) : (
|
||||
panel
|
||||
);
|
||||
return wrapAux(panel, "user-profile-panel");
|
||||
})()
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -111,10 +111,12 @@ export function ChannelScreen({
|
||||
openAgentSessionPubkey,
|
||||
openThreadHeadId,
|
||||
profilePanelPubkey,
|
||||
profilePanelTab,
|
||||
profilePanelView,
|
||||
setChannelManagementOpen,
|
||||
setOpenAgentSessionPubkey,
|
||||
setOpenThreadHeadId,
|
||||
setProfilePanelTab,
|
||||
setProfilePanelPubkey,
|
||||
setProfilePanelView,
|
||||
} = useChannelPanelHistoryState();
|
||||
@@ -312,8 +314,8 @@ export function ChannelScreen({
|
||||
return pubkeys;
|
||||
}, [channelMembers, managedAgents, relayAgents]);
|
||||
const {
|
||||
agentSessionCandidates,
|
||||
botTypingEntries,
|
||||
channelAgentSessionAgents: activeChannelAgentSessionAgents,
|
||||
humanTypingPubkeys,
|
||||
threadTypingPubkeys,
|
||||
} = useChannelActivityTyping({
|
||||
@@ -325,7 +327,29 @@ export function ChannelScreen({
|
||||
relayAgents,
|
||||
typingEntries,
|
||||
});
|
||||
useManagedAgentObserverBridge(activeChannelAgentSessionAgents);
|
||||
const observerBridgeAgents = React.useMemo(() => {
|
||||
if (
|
||||
!profilePanelPubkey ||
|
||||
!openAgentSessionPubkey ||
|
||||
normalizePubkey(profilePanelPubkey) !==
|
||||
normalizePubkey(openAgentSessionPubkey) ||
|
||||
managedAgents.some(
|
||||
(agent) =>
|
||||
normalizePubkey(agent.pubkey) === normalizePubkey(profilePanelPubkey),
|
||||
)
|
||||
) {
|
||||
return managedAgents;
|
||||
}
|
||||
|
||||
return [
|
||||
...managedAgents,
|
||||
{
|
||||
pubkey: profilePanelPubkey,
|
||||
status: "deployed" as const,
|
||||
},
|
||||
];
|
||||
}, [managedAgents, openAgentSessionPubkey, profilePanelPubkey]);
|
||||
useManagedAgentObserverBridge(observerBridgeAgents);
|
||||
const messageProfiles = React.useMemo(() => {
|
||||
const base =
|
||||
mergeCurrentProfileIntoLookup(
|
||||
@@ -488,6 +512,7 @@ export function ChannelScreen({
|
||||
? handleSendVideoReviewComment
|
||||
: undefined;
|
||||
const {
|
||||
agentSessionAgents,
|
||||
channelAgentSessionAgents,
|
||||
closeAgentSession: handleCloseAgentSession,
|
||||
openAgentSession: handleOpenAgentSession,
|
||||
@@ -505,8 +530,9 @@ export function ChannelScreen({
|
||||
!relayAgentsQuery.isLoading,
|
||||
channelMembers,
|
||||
handleOpenThread,
|
||||
managedAgents: activeChannelAgentSessionAgents,
|
||||
managedAgents: agentSessionCandidates,
|
||||
openAgentSessionPubkey,
|
||||
profilePanelPubkey,
|
||||
setChannelManagementOpen,
|
||||
setExpandedThreadReplyIds,
|
||||
setOpenAgentSessionPubkey,
|
||||
@@ -730,8 +756,9 @@ export function ChannelScreen({
|
||||
>
|
||||
<ChannelPane
|
||||
activeChannel={activeChannel}
|
||||
activityAgents={channelAgentSessionAgents}
|
||||
agentPubkeys={agentPubkeys}
|
||||
agentSessionAgents={channelAgentSessionAgents}
|
||||
agentSessionAgents={agentSessionAgents}
|
||||
botTypingEntries={botTypingEntries}
|
||||
channelFind={channelFind}
|
||||
channelManagementOpen={channelManagementOpen}
|
||||
@@ -818,7 +845,9 @@ export function ChannelScreen({
|
||||
openThreadHeadId={effectiveOpenThreadHeadId}
|
||||
shouldShowThreadSkeleton={shouldShowThreadSkeleton}
|
||||
onProfilePanelViewChange={setProfilePanelView}
|
||||
onProfilePanelTabChange={setProfilePanelTab}
|
||||
profilePanelPubkey={profilePanelPubkey}
|
||||
profilePanelTab={profilePanelTab}
|
||||
profilePanelView={profilePanelView}
|
||||
personaLookup={personaLookup}
|
||||
profiles={messageProfiles}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import type { BotActivityAgent } from "@/features/channels/ui/BotActivityBar";
|
||||
import type { ChannelAgentSessionAgent } from "@/features/channels/ui/useChannelAgentSessions";
|
||||
|
||||
export function resolveSelectedAgentSession({
|
||||
agentSessionAgents,
|
||||
openAgentSessionPubkey,
|
||||
profilePanelPubkey,
|
||||
profiles,
|
||||
}: {
|
||||
agentSessionAgents: ChannelAgentSessionAgent[];
|
||||
openAgentSessionPubkey: string | null;
|
||||
profilePanelPubkey?: string | null;
|
||||
profiles?: UserProfileLookup;
|
||||
}): ChannelAgentSessionAgent | null {
|
||||
if (!openAgentSessionPubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const listedAgent = agentSessionAgents.find(
|
||||
(agent) =>
|
||||
agent.pubkey.toLowerCase() === openAgentSessionPubkey.toLowerCase(),
|
||||
);
|
||||
if (listedAgent) {
|
||||
return listedAgent;
|
||||
}
|
||||
|
||||
if (
|
||||
!profilePanelPubkey ||
|
||||
profilePanelPubkey.toLowerCase() !== openAgentSessionPubkey.toLowerCase()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profile = profiles?.[openAgentSessionPubkey.toLowerCase()];
|
||||
return {
|
||||
pubkey: openAgentSessionPubkey,
|
||||
name: profile?.displayName?.trim() || "Agent",
|
||||
status: "deployed",
|
||||
agentSource: "relay",
|
||||
canInterruptTurn: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function isAgentInActivityList({
|
||||
activityAgents,
|
||||
selectedAgent,
|
||||
}: {
|
||||
activityAgents: BotActivityAgent[];
|
||||
selectedAgent: ChannelAgentSessionAgent | null;
|
||||
}) {
|
||||
if (!selectedAgent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return activityAgents.some(
|
||||
(agent) =>
|
||||
agent.pubkey.toLowerCase() === selectedAgent.pubkey.toLowerCase(),
|
||||
);
|
||||
}
|
||||
@@ -85,6 +85,7 @@ export function useChannelActivityTyping({
|
||||
}, [channelAgentPubkeys, typingEntries]);
|
||||
|
||||
return {
|
||||
agentSessionCandidates: agentCandidates,
|
||||
botTypingEntries,
|
||||
channelAgentSessionAgents,
|
||||
humanTypingPubkeys,
|
||||
|
||||
@@ -28,6 +28,7 @@ type UseChannelAgentSessionsOptions = {
|
||||
handleOpenThread: (message: TimelineMessage) => void;
|
||||
managedAgents: ChannelAgentSessionAgent[];
|
||||
openAgentSessionPubkey: string | null;
|
||||
profilePanelPubkey?: string | null;
|
||||
setChannelManagementOpen: (open: boolean) => void;
|
||||
setExpandedThreadReplyIds: (value: Set<string>) => void;
|
||||
setOpenAgentSessionPubkey: PanelValueSetter;
|
||||
@@ -160,6 +161,7 @@ export function useChannelAgentSessions({
|
||||
handleOpenThread,
|
||||
managedAgents,
|
||||
openAgentSessionPubkey,
|
||||
profilePanelPubkey = null,
|
||||
setChannelManagementOpen,
|
||||
setExpandedThreadReplyIds,
|
||||
setOpenAgentSessionPubkey,
|
||||
@@ -178,6 +180,7 @@ export function useChannelAgentSessions({
|
||||
}),
|
||||
[activeChannel, activeChannelId, channelMembers, managedAgents],
|
||||
);
|
||||
const agentSessionAgents = managedAgents;
|
||||
|
||||
const closeAgentSession = React.useCallback(() => {
|
||||
setOpenAgentSessionPubkey(null);
|
||||
@@ -189,7 +192,6 @@ export function useChannelAgentSessions({
|
||||
setExpandedThreadReplyIds(new Set());
|
||||
setThreadScrollTargetId(null);
|
||||
setThreadReplyTargetId(null);
|
||||
setProfilePanelPubkey(null);
|
||||
setChannelManagementOpen(false);
|
||||
setOpenAgentSessionPubkey(pubkey);
|
||||
},
|
||||
@@ -198,7 +200,6 @@ export function useChannelAgentSessions({
|
||||
setExpandedThreadReplyIds,
|
||||
setOpenAgentSessionPubkey,
|
||||
setOpenThreadHeadId,
|
||||
setProfilePanelPubkey,
|
||||
setThreadReplyTargetId,
|
||||
setThreadScrollTargetId,
|
||||
],
|
||||
@@ -234,7 +235,9 @@ export function useChannelAgentSessions({
|
||||
if (
|
||||
openAgentSessionPubkey &&
|
||||
agentsLoaded &&
|
||||
!channelAgentSessionAgents.some(
|
||||
normalizePubkey(profilePanelPubkey ?? "") !==
|
||||
normalizePubkey(openAgentSessionPubkey) &&
|
||||
!agentSessionAgents.some(
|
||||
(agent) =>
|
||||
normalizePubkey(agent.pubkey) ===
|
||||
normalizePubkey(openAgentSessionPubkey),
|
||||
@@ -243,13 +246,15 @@ export function useChannelAgentSessions({
|
||||
setOpenAgentSessionPubkey(null, { replace: true });
|
||||
}
|
||||
}, [
|
||||
agentSessionAgents,
|
||||
agentsLoaded,
|
||||
channelAgentSessionAgents,
|
||||
openAgentSessionPubkey,
|
||||
profilePanelPubkey,
|
||||
setOpenAgentSessionPubkey,
|
||||
]);
|
||||
|
||||
return {
|
||||
agentSessionAgents,
|
||||
channelAgentSessionAgents,
|
||||
closeAgentSession,
|
||||
openAgentSession,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { ProfilePanelView } from "@/features/profile/ui/UserProfilePanel";
|
||||
import {
|
||||
profilePanelTabFromSearch,
|
||||
type ProfilePanelTab,
|
||||
profilePanelViewFromSearch,
|
||||
type ProfilePanelView,
|
||||
} from "@/features/profile/ui/UserProfilePanelUtils";
|
||||
import {
|
||||
type HistorySearchSetterOptions,
|
||||
useHistorySearchState,
|
||||
@@ -12,9 +17,10 @@ import {
|
||||
* was showing, and reloads restore the panel from the URL.
|
||||
*
|
||||
* Params: `thread` (open thread head id), `profile` (profile panel pubkey),
|
||||
* `profileView` (profile panel sub-view), `agentSession` (agent session
|
||||
* panel pubkey), `channelManagement` (presence flag for the channel-management
|
||||
* panel — open/closed only, so it carries a sentinel `"1"` rather than an id).
|
||||
* `profileView` (profile panel focused view), `profileTab` (profile summary
|
||||
* tab), `agentSession` (agent session panel pubkey), `channelManagement`
|
||||
* (presence flag for the channel-management panel — open/closed only, so it
|
||||
* carries a sentinel `"1"` rather than an id).
|
||||
*/
|
||||
|
||||
export type PanelSetterOptions = HistorySearchSetterOptions;
|
||||
@@ -29,6 +35,7 @@ const CHANNEL_SEARCH_KEYS = [
|
||||
"channelManagement",
|
||||
"messageId",
|
||||
"profile",
|
||||
"profileTab",
|
||||
"profileView",
|
||||
"thread",
|
||||
"threadRootId",
|
||||
@@ -36,10 +43,6 @@ const CHANNEL_SEARCH_KEYS = [
|
||||
|
||||
const CHANNEL_MANAGEMENT_OPEN_VALUE = "1";
|
||||
|
||||
function asProfilePanelView(value: string | null): ProfilePanelView {
|
||||
return value === "memories" || value === "channels" ? value : "summary";
|
||||
}
|
||||
|
||||
export function useChannelPanelHistoryState() {
|
||||
const { applyPatch, values } = useHistorySearchState(CHANNEL_SEARCH_KEYS);
|
||||
|
||||
@@ -52,7 +55,10 @@ export function useChannelPanelHistoryState() {
|
||||
// the carried `profileView` would otherwise leak onto the next profile.
|
||||
const setProfilePanelPubkey = React.useCallback<PanelValueSetter>(
|
||||
(value, options) =>
|
||||
applyPatch({ profile: value, profileView: null }, options),
|
||||
applyPatch(
|
||||
{ profile: value, profileTab: null, profileView: null },
|
||||
options,
|
||||
),
|
||||
[applyPatch],
|
||||
);
|
||||
|
||||
@@ -62,6 +68,12 @@ export function useChannelPanelHistoryState() {
|
||||
[applyPatch],
|
||||
);
|
||||
|
||||
const setProfilePanelTab = React.useCallback(
|
||||
(value: ProfilePanelTab, options?: PanelSetterOptions) =>
|
||||
applyPatch({ profileTab: value === "info" ? null : value }, options),
|
||||
[applyPatch],
|
||||
);
|
||||
|
||||
const setOpenAgentSessionPubkey = React.useCallback<PanelValueSetter>(
|
||||
(value, options) => applyPatch({ agentSession: value }, options),
|
||||
[applyPatch],
|
||||
@@ -88,10 +100,12 @@ export function useChannelPanelHistoryState() {
|
||||
openAgentSessionPubkey: values.agentSession,
|
||||
openThreadHeadId: values.thread,
|
||||
profilePanelPubkey: values.profile,
|
||||
profilePanelView: asProfilePanelView(values.profileView),
|
||||
profilePanelTab: profilePanelTabFromSearch(values.profileTab),
|
||||
profilePanelView: profilePanelViewFromSearch(values.profileView),
|
||||
setChannelManagementOpen,
|
||||
setOpenAgentSessionPubkey,
|
||||
setOpenThreadHeadId,
|
||||
setProfilePanelTab,
|
||||
setProfilePanelPubkey,
|
||||
setProfilePanelView,
|
||||
};
|
||||
|
||||
@@ -116,11 +116,15 @@ export type IdentityArchiveActions = {
|
||||
* dedupes the underlying subscriptions by queryKey, so a second hook call costs
|
||||
* a render, not a second network round-trip.
|
||||
*/
|
||||
export function useIdentityArchive(pubkey: string): IdentityArchiveActions {
|
||||
export function useIdentityArchive(
|
||||
pubkey: string | null,
|
||||
): IdentityArchiveActions {
|
||||
const identityQuery = useIdentityQuery();
|
||||
const currentPubkey = identityQuery.data?.pubkey;
|
||||
|
||||
const pubkeyLower = pubkey.toLowerCase();
|
||||
const targetPubkey = pubkey?.trim() ?? "";
|
||||
const hasTargetPubkey = targetPubkey.length > 0;
|
||||
const pubkeyLower = targetPubkey.toLowerCase();
|
||||
const isSelf =
|
||||
currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase();
|
||||
|
||||
@@ -129,11 +133,11 @@ export function useIdentityArchive(pubkey: string): IdentityArchiveActions {
|
||||
// archiving *other* identities you own. Also defer until our own identity
|
||||
// resolves so we never fire the lookup against an unknown viewer.
|
||||
const oaOwnerQuery = useOaOwnerQuery(
|
||||
pubkey,
|
||||
currentPubkey !== undefined && !isSelf,
|
||||
targetPubkey,
|
||||
hasTargetPubkey && currentPubkey !== undefined && !isSelf,
|
||||
);
|
||||
|
||||
const isArchived = useIsIdentityArchived(pubkey);
|
||||
const isArchived = useIsIdentityArchived(targetPubkey);
|
||||
|
||||
const archiveMutation = useArchiveIdentityMutation();
|
||||
const unarchiveMutation = useUnarchiveIdentityMutation();
|
||||
@@ -141,11 +145,13 @@ export function useIdentityArchive(pubkey: string): IdentityArchiveActions {
|
||||
const myRole = myMembershipQuery.data?.role;
|
||||
const isRelayAdminOrOwner = myRole === "owner" || myRole === "admin";
|
||||
const isOaOwnerOfViewee = oaOwnerQuery.data?.isMe === true;
|
||||
const canArchive = isSelf || isRelayAdminOrOwner || isOaOwnerOfViewee;
|
||||
const canArchive =
|
||||
hasTargetPubkey && (isSelf || isRelayAdminOrOwner || isOaOwnerOfViewee);
|
||||
|
||||
const archive = React.useCallback(() => {
|
||||
if (!hasTargetPubkey) return;
|
||||
archiveMutation.mutate(
|
||||
{ targetPubkey: pubkey },
|
||||
{ targetPubkey },
|
||||
{
|
||||
onSuccess: () => toast.success("Archived on this relay"),
|
||||
onError: (error) =>
|
||||
@@ -154,11 +160,12 @@ export function useIdentityArchive(pubkey: string): IdentityArchiveActions {
|
||||
),
|
||||
},
|
||||
);
|
||||
}, [archiveMutation, pubkey]);
|
||||
}, [archiveMutation, hasTargetPubkey, targetPubkey]);
|
||||
|
||||
const unarchive = React.useCallback(() => {
|
||||
if (!hasTargetPubkey) return;
|
||||
unarchiveMutation.mutate(
|
||||
{ targetPubkey: pubkey },
|
||||
{ targetPubkey },
|
||||
{
|
||||
onSuccess: () => toast.success("Unarchived on this relay"),
|
||||
onError: (error) =>
|
||||
@@ -167,7 +174,7 @@ export function useIdentityArchive(pubkey: string): IdentityArchiveActions {
|
||||
),
|
||||
},
|
||||
);
|
||||
}, [pubkey, unarchiveMutation]);
|
||||
}, [hasTargetPubkey, targetPubkey, unarchiveMutation]);
|
||||
|
||||
return {
|
||||
canArchive,
|
||||
|
||||
@@ -21,14 +21,12 @@ export function ArchiveConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
onGoToAgents,
|
||||
isBot,
|
||||
isPending,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
onGoToAgents: () => void;
|
||||
isBot: boolean;
|
||||
isPending: boolean;
|
||||
}) {
|
||||
@@ -41,7 +39,7 @@ export function ArchiveConfirmDialog({
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Archiving removes {subject} from this space.
|
||||
Archiving hides {subject} from the space.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
{/* The list + closing paragraph sit outside AlertDialogDescription on
|
||||
@@ -60,19 +58,8 @@ export function ArchiveConfirmDialog({
|
||||
</ul>
|
||||
{isBot ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
To permanently remove this agent instead, delete it in the{" "}
|
||||
<Button
|
||||
className="h-auto p-0 align-baseline text-sm"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
onGoToAgents();
|
||||
}}
|
||||
type="button"
|
||||
variant="link"
|
||||
>
|
||||
Agents tab
|
||||
</Button>
|
||||
.
|
||||
You can also delete this agent from the profile settings menu if you
|
||||
want to remove the agent instead of hiding it.
|
||||
</p>
|
||||
) : null}
|
||||
<AlertDialogFooter>
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import * as React from "react";
|
||||
import { Archive, ArchiveRestore } from "lucide-react";
|
||||
|
||||
import type { IdentityArchiveActions } from "@/features/identity-archive/hooks";
|
||||
import { ArchiveConfirmDialog } from "@/features/profile/ui/ArchiveConfirmDialog";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
export function ProfileManageArchiveSection({
|
||||
archiveActions,
|
||||
isBot,
|
||||
onGoToAgents,
|
||||
}: {
|
||||
archiveActions: IdentityArchiveActions;
|
||||
isBot: boolean;
|
||||
onGoToAgents: () => void;
|
||||
}) {
|
||||
const [confirmOpen, setConfirmOpen] = React.useState(false);
|
||||
|
||||
const archiveLabel = isBot ? "Archive agent" : "Archive identity";
|
||||
const unarchiveLabel = isBot ? "Unarchive agent" : "Unarchive identity";
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-2">
|
||||
<h4 className="text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
|
||||
Manage
|
||||
</h4>
|
||||
{archiveActions.isArchived ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
data-testid="user-profile-unarchive-identity"
|
||||
disabled={archiveActions.isPending}
|
||||
onClick={archiveActions.unarchive}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<ArchiveRestore className="h-4 w-4" />
|
||||
{archiveActions.isPending ? "Unarchiving…" : unarchiveLabel}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className="w-full"
|
||||
data-testid="user-profile-archive-identity"
|
||||
disabled={archiveActions.isPending}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
{archiveActions.isPending ? "Archiving…" : archiveLabel}
|
||||
</Button>
|
||||
)}
|
||||
<ArchiveConfirmDialog
|
||||
isBot={isBot}
|
||||
isPending={archiveActions.isPending}
|
||||
onConfirm={() => {
|
||||
archiveActions.archive();
|
||||
setConfirmOpen(false);
|
||||
}}
|
||||
onGoToAgents={onGoToAgents}
|
||||
onOpenChange={setConfirmOpen}
|
||||
open={confirmOpen}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
CopyPlus,
|
||||
Download,
|
||||
Power,
|
||||
Settings,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import type { IdentityArchiveActions } from "@/features/identity-archive/hooks";
|
||||
import { ArchiveConfirmDialog } from "@/features/profile/ui/ArchiveConfirmDialog";
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import { Button, buttonVariants } from "@/shared/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
|
||||
export function UserProfileAgentSettingsMenu({
|
||||
archiveActions,
|
||||
isPending,
|
||||
isBot = false,
|
||||
managedAgent,
|
||||
onDelete,
|
||||
onDuplicatePersona,
|
||||
onExportPersona,
|
||||
onToggleAutoStart,
|
||||
personaActionKey,
|
||||
}: {
|
||||
archiveActions?: IdentityArchiveActions;
|
||||
isPending: boolean;
|
||||
isBot?: boolean;
|
||||
managedAgent?: ManagedAgent;
|
||||
onDelete?: () => void;
|
||||
onDuplicatePersona?: () => void;
|
||||
onExportPersona?: () => void;
|
||||
onToggleAutoStart?: () => void;
|
||||
personaActionKey?: string;
|
||||
}) {
|
||||
const [archiveConfirmOpen, setArchiveConfirmOpen] = React.useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false);
|
||||
const actionKey = managedAgent?.pubkey ?? "persona-draft";
|
||||
const personaKey = personaActionKey ?? actionKey;
|
||||
const canToggleAutoStart =
|
||||
managedAgent !== undefined &&
|
||||
managedAgent.backend.type === "local" &&
|
||||
onToggleAutoStart !== undefined;
|
||||
const autoStartSwitchId = `user-profile-agent-auto-start-${actionKey}`;
|
||||
const hasPrimaryActions = Boolean(onDuplicatePersona || onExportPersona);
|
||||
const hasArchiveAction =
|
||||
archiveActions?.canArchive === true &&
|
||||
archiveActions.isArchived !== undefined;
|
||||
const shouldConfirmAgentDelete =
|
||||
managedAgent !== undefined && onDelete !== undefined;
|
||||
const hasManageActions = hasArchiveAction || Boolean(onDelete);
|
||||
const hasActions =
|
||||
canToggleAutoStart || hasPrimaryActions || hasManageActions;
|
||||
|
||||
if (!hasActions) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const archiveLabel = isBot ? "Archive agent" : "Archive identity";
|
||||
const unarchiveLabel = isBot ? "Unarchive agent" : "Unarchive identity";
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label="Open profile settings"
|
||||
data-testid="user-profile-settings-menu-trigger"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Settings />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="min-w-56"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
{canToggleAutoStart ? (
|
||||
<DropdownMenuItem
|
||||
className="gap-3 pr-2"
|
||||
disabled={isPending}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onToggleAutoStart();
|
||||
}}
|
||||
>
|
||||
<Power className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 text-sm font-medium">
|
||||
Auto-start
|
||||
</span>
|
||||
<Switch
|
||||
aria-label="Auto-start"
|
||||
checked={managedAgent.startOnAppLaunch}
|
||||
data-testid={autoStartSwitchId}
|
||||
disabled={isPending}
|
||||
id={autoStartSwitchId}
|
||||
onCheckedChange={onToggleAutoStart}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onDuplicatePersona ? (
|
||||
<DropdownMenuItem
|
||||
data-testid={`user-profile-persona-duplicate-${personaKey}`}
|
||||
disabled={isPending}
|
||||
onClick={onDuplicatePersona}
|
||||
>
|
||||
<CopyPlus className="h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{onExportPersona ? (
|
||||
<DropdownMenuItem
|
||||
data-testid={`user-profile-persona-export-${personaKey}`}
|
||||
disabled={isPending}
|
||||
onClick={onExportPersona}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Export
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
{hasManageActions && (canToggleAutoStart || hasPrimaryActions) ? (
|
||||
<DropdownMenuSeparator />
|
||||
) : null}
|
||||
{hasArchiveAction && archiveActions ? (
|
||||
archiveActions.isArchived ? (
|
||||
<DropdownMenuItem
|
||||
data-testid="user-profile-unarchive-identity"
|
||||
disabled={isPending}
|
||||
onClick={archiveActions.unarchive}
|
||||
>
|
||||
<ArchiveRestore className="h-4 w-4" />
|
||||
{archiveActions.isPending ? "Unarchiving…" : unarchiveLabel}
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
data-testid="user-profile-archive-identity"
|
||||
disabled={isPending}
|
||||
onSelect={() => setArchiveConfirmOpen(true)}
|
||||
>
|
||||
<Archive className="h-4 w-4" />
|
||||
{archiveActions.isPending ? "Archiving…" : archiveLabel}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
) : null}
|
||||
{onDelete && hasArchiveAction ? <DropdownMenuSeparator /> : null}
|
||||
{onDelete ? (
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
data-testid={`user-profile-agent-delete-${actionKey}`}
|
||||
disabled={isPending}
|
||||
onSelect={() => {
|
||||
if (shouldConfirmAgentDelete) {
|
||||
setDeleteConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete agent
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{hasArchiveAction && archiveActions ? (
|
||||
<ArchiveConfirmDialog
|
||||
isBot={isBot}
|
||||
isPending={archiveActions.isPending}
|
||||
onConfirm={() => {
|
||||
archiveActions.archive();
|
||||
setArchiveConfirmOpen(false);
|
||||
}}
|
||||
onOpenChange={setArchiveConfirmOpen}
|
||||
open={archiveConfirmOpen}
|
||||
/>
|
||||
) : null}
|
||||
{shouldConfirmAgentDelete ? (
|
||||
<AgentDeleteConfirmDialog
|
||||
agent={managedAgent}
|
||||
isPending={isPending}
|
||||
onConfirm={() => {
|
||||
setDeleteConfirmOpen(false);
|
||||
onDelete();
|
||||
}}
|
||||
onOpenChange={setDeleteConfirmOpen}
|
||||
open={deleteConfirmOpen}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserProfileAgentSettingsMenuSlot({
|
||||
archiveActions,
|
||||
canDeletePersona,
|
||||
canInstantiateAgent,
|
||||
canManagePersona,
|
||||
isAgentActionPending,
|
||||
isBot,
|
||||
managedAgent,
|
||||
onDeleteAgent,
|
||||
onDeletePersona,
|
||||
onDuplicatePersona,
|
||||
onExportPersona,
|
||||
onToggleAutoStart,
|
||||
personaActionKey,
|
||||
viewerIsOwner,
|
||||
}: {
|
||||
archiveActions: IdentityArchiveActions;
|
||||
canDeletePersona: boolean;
|
||||
canInstantiateAgent: boolean;
|
||||
canManagePersona: boolean;
|
||||
isAgentActionPending: boolean;
|
||||
isBot: boolean;
|
||||
managedAgent?: ManagedAgent;
|
||||
onDeleteAgent: () => void;
|
||||
onDeletePersona: () => void;
|
||||
onDuplicatePersona: () => void;
|
||||
onExportPersona: () => void;
|
||||
onToggleAutoStart: () => void;
|
||||
personaActionKey?: string;
|
||||
viewerIsOwner: boolean;
|
||||
}) {
|
||||
const canShowArchiveAction =
|
||||
archiveActions.canArchive && archiveActions.isArchived !== undefined;
|
||||
const settingsActionPending =
|
||||
isAgentActionPending || archiveActions.isPending;
|
||||
const sharedProps = {
|
||||
archiveActions: canShowArchiveAction ? archiveActions : undefined,
|
||||
isBot,
|
||||
isPending: settingsActionPending,
|
||||
onDuplicatePersona: canManagePersona ? onDuplicatePersona : undefined,
|
||||
onExportPersona: canManagePersona ? onExportPersona : undefined,
|
||||
personaActionKey,
|
||||
};
|
||||
|
||||
if (viewerIsOwner && managedAgent) {
|
||||
return (
|
||||
<UserProfileAgentSettingsMenu
|
||||
{...sharedProps}
|
||||
managedAgent={managedAgent}
|
||||
onDelete={onDeleteAgent}
|
||||
onToggleAutoStart={onToggleAutoStart}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (canInstantiateAgent) {
|
||||
return (
|
||||
<UserProfileAgentSettingsMenu
|
||||
{...sharedProps}
|
||||
onDelete={canDeletePersona ? onDeletePersona : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (canShowArchiveAction) {
|
||||
return (
|
||||
<UserProfileAgentSettingsMenu
|
||||
archiveActions={archiveActions}
|
||||
isBot={isBot}
|
||||
isPending={settingsActionPending}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function AgentDeleteConfirmDialog({
|
||||
agent,
|
||||
isPending,
|
||||
onConfirm,
|
||||
onOpenChange,
|
||||
open,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
isPending: boolean;
|
||||
onConfirm: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean;
|
||||
}) {
|
||||
const isProviderAgent = agent.backend.type === "provider";
|
||||
|
||||
return (
|
||||
<AlertDialog onOpenChange={onOpenChange} open={open}>
|
||||
<AlertDialogContent data-testid="agent-delete-confirm-dialog">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete this agent?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Deleting this agent stops and removes the agent from this workspace.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<ul className="list-disc space-y-1.5 pl-5 text-sm text-muted-foreground">
|
||||
<li>Removes the local management record and saved agent key</li>
|
||||
<li>Removes the agent from every channel it belongs to</li>
|
||||
<li>
|
||||
{isProviderAgent
|
||||
? "Requests remote deletion; if it is online, Buzz first sends a shutdown command when possible. If the deployment cannot be reached through a channel, the remote process may keep running without local management."
|
||||
: "Stops any local agent process before deleting the record"}
|
||||
</li>
|
||||
</ul>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You can also archive this agent from the profile settings menu if you
|
||||
want to hide the agent instead of removing it.
|
||||
</p>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel asChild>
|
||||
<Button type="button" variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
data-testid="agent-delete-confirm-action"
|
||||
disabled={isPending}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{isPending ? "Deleting..." : "Delete agent"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,264 @@
|
||||
import { ChevronRight, Cpu, MessageSquare } from "lucide-react";
|
||||
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import {
|
||||
type ProfileField,
|
||||
ProfileFieldRows,
|
||||
} from "@/features/profile/ui/UserProfilePanelFields";
|
||||
|
||||
export const AGENT_DETAILS_FIELD_LABELS = new Set([
|
||||
"Runtime",
|
||||
"ACP command",
|
||||
"MCP command",
|
||||
]);
|
||||
|
||||
export function AgentConfigurationFocusedView({
|
||||
fields,
|
||||
managedAgent,
|
||||
modelLabel,
|
||||
}: {
|
||||
fields: ProfileField[];
|
||||
managedAgent: ManagedAgent | undefined;
|
||||
modelLabel: string;
|
||||
}) {
|
||||
const runtimeConfigurationFields = fields.filter((field) =>
|
||||
AGENT_DETAILS_FIELD_LABELS.has(field.label),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pt-4">
|
||||
<AgentConfigurationRows
|
||||
fields={runtimeConfigurationFields}
|
||||
managedAgent={managedAgent}
|
||||
modelLabel={modelLabel}
|
||||
showModel={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentConfigurationRows({
|
||||
fields,
|
||||
instruction,
|
||||
managedAgent,
|
||||
modelLabel,
|
||||
showInstructionPlaceholder,
|
||||
showModel,
|
||||
}: {
|
||||
fields: ProfileField[];
|
||||
instruction?: string | null;
|
||||
managedAgent: ManagedAgent | undefined;
|
||||
modelLabel: string;
|
||||
showInstructionPlaceholder?: boolean;
|
||||
showModel: boolean;
|
||||
}) {
|
||||
const hasRows = hasAgentConfigurationRows({
|
||||
fields,
|
||||
instruction,
|
||||
managedAgent,
|
||||
modelLabel,
|
||||
showInstructionPlaceholder,
|
||||
showModel,
|
||||
});
|
||||
|
||||
if (!hasRows) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl bg-muted/20">
|
||||
<AgentDetailsRows
|
||||
fields={fields}
|
||||
instruction={instruction}
|
||||
managedAgent={managedAgent}
|
||||
modelLabel={modelLabel}
|
||||
showInstructionPlaceholder={showInstructionPlaceholder}
|
||||
showModel={showModel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentDetailsRows({
|
||||
fields,
|
||||
instruction,
|
||||
managedAgent,
|
||||
modelLabel,
|
||||
showInstructionPlaceholder,
|
||||
showModel = false,
|
||||
}: {
|
||||
fields: ProfileField[];
|
||||
instruction?: string | null;
|
||||
managedAgent?: ManagedAgent | undefined;
|
||||
modelLabel?: string;
|
||||
showInstructionPlaceholder?: boolean;
|
||||
showModel?: boolean;
|
||||
}) {
|
||||
const trimmedInstruction = instruction?.trim() ?? "";
|
||||
const showInstructions =
|
||||
trimmedInstruction.length > 0 || showInstructionPlaceholder === true;
|
||||
const showModelRow =
|
||||
showModel === true &&
|
||||
(managedAgent !== undefined || (modelLabel?.trim().length ?? 0) > 0);
|
||||
|
||||
if (!showInstructions && !showModelRow && fields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{showInstructions ? (
|
||||
<AgentInstructionRow instruction={instruction ?? null} />
|
||||
) : null}
|
||||
|
||||
{showModelRow ? (
|
||||
<AgentModelRow modelLabel={modelLabel ?? "Auto"} />
|
||||
) : null}
|
||||
|
||||
{fields.length > 0 ? <ProfileFieldRows fields={fields} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function hasAgentConfigurationRows({
|
||||
fields,
|
||||
instruction,
|
||||
managedAgent,
|
||||
modelLabel,
|
||||
showInstructionPlaceholder,
|
||||
showModel,
|
||||
}: {
|
||||
fields: ProfileField[];
|
||||
instruction?: string | null;
|
||||
managedAgent: ManagedAgent | undefined;
|
||||
modelLabel: string;
|
||||
showInstructionPlaceholder?: boolean;
|
||||
showModel: boolean;
|
||||
}) {
|
||||
const trimmedInstruction = instruction?.trim() ?? "";
|
||||
|
||||
return (
|
||||
trimmedInstruction.length > 0 ||
|
||||
showInstructionPlaceholder === true ||
|
||||
(showModel === true &&
|
||||
(managedAgent !== undefined || modelLabel.trim().length > 0)) ||
|
||||
fields.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentInstructionRow({
|
||||
instruction,
|
||||
onOpenInstructions,
|
||||
}: {
|
||||
instruction: string | null;
|
||||
onOpenInstructions?: () => void;
|
||||
}) {
|
||||
const trimmedInstruction = instruction?.trim() ?? "";
|
||||
const canOpenInstructions =
|
||||
trimmedInstruction.length > 0 && onOpenInstructions !== undefined;
|
||||
const rowContent = (
|
||||
<>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted/60">
|
||||
<MessageSquare className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="text-xs font-medium text-foreground">Instructions</div>
|
||||
{trimmedInstruction ? (
|
||||
canOpenInstructions ? (
|
||||
<span
|
||||
className="mt-1 line-clamp-2 whitespace-pre-wrap pr-1 text-sm leading-6"
|
||||
data-testid="user-profile-agent-instruction"
|
||||
>
|
||||
{trimmedInstruction}
|
||||
</span>
|
||||
) : (
|
||||
<div
|
||||
className="mt-1 pr-1"
|
||||
data-testid="user-profile-agent-instruction"
|
||||
>
|
||||
<Markdown
|
||||
className="text-sm leading-6"
|
||||
content={trimmedInstruction}
|
||||
interactive={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<p
|
||||
className="mt-0.5 text-sm leading-6 text-muted-foreground"
|
||||
data-testid="user-profile-agent-instruction-empty"
|
||||
>
|
||||
No instruction set.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{canOpenInstructions ? (
|
||||
<ChevronRight className="mt-2.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (canOpenInstructions) {
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-start gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/40"
|
||||
data-testid="user-profile-agent-instruction-row"
|
||||
onClick={onOpenInstructions}
|
||||
type="button"
|
||||
>
|
||||
{rowContent}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className="flex items-start gap-3 px-4 py-3">{rowContent}</div>;
|
||||
}
|
||||
|
||||
export function AgentInstructionsFocusedView({
|
||||
instruction,
|
||||
}: {
|
||||
instruction: string | null;
|
||||
}) {
|
||||
const trimmedInstruction = instruction?.trim() ?? "";
|
||||
|
||||
return (
|
||||
<div className="pt-4">
|
||||
<div
|
||||
className="rounded-2xl bg-muted/20 px-4 py-3"
|
||||
data-testid="user-profile-agent-instructions-view"
|
||||
>
|
||||
{trimmedInstruction ? (
|
||||
<Markdown
|
||||
className="text-sm leading-6"
|
||||
content={trimmedInstruction}
|
||||
interactive={false}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
No instruction set.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentModelRow({ modelLabel }: { modelLabel: string }) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3"
|
||||
data-testid="user-profile-model"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted/60">
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-xs font-medium text-foreground">Model</span>
|
||||
<span className="mt-0.5 block truncate text-sm text-muted-foreground">
|
||||
{modelLabel}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
deleteManagedAgentWithRules,
|
||||
type ManagedAgentActionResult,
|
||||
} from "@/features/agents/lib/managedAgentControlActions";
|
||||
import { removeChannelMember } from "@/shared/api/tauri";
|
||||
import type {
|
||||
AgentPersona,
|
||||
Channel,
|
||||
ManagedAgent,
|
||||
PresenceLookup,
|
||||
RelayAgent,
|
||||
} from "@/shared/api/types";
|
||||
import { getRelayAgentChannelIds } from "@/features/profile/ui/UserProfilePanelUtils";
|
||||
|
||||
type DeleteManagedAgentRulesContext = Omit<
|
||||
Parameters<typeof deleteManagedAgentWithRules>[0],
|
||||
"agent"
|
||||
>;
|
||||
|
||||
type DeleteProfileManagedAgentContext = DeleteManagedAgentRulesContext & {
|
||||
removeAgentFromAllChannels: (pubkey: string) => Promise<void>;
|
||||
};
|
||||
|
||||
type DeleteProfileManagedAgentsForPersonaContext =
|
||||
DeleteProfileManagedAgentContext & {
|
||||
managedAgents: readonly ManagedAgent[];
|
||||
selectedAgent?: ManagedAgent;
|
||||
};
|
||||
|
||||
type UseProfileAgentDeletionInput = {
|
||||
channels?: readonly Channel[];
|
||||
deleteManagedAgent: DeleteManagedAgentRulesContext["deleteManagedAgent"];
|
||||
managedAgent?: ManagedAgent;
|
||||
managedAgents?: readonly ManagedAgent[];
|
||||
presenceLookup?: PresenceLookup | null;
|
||||
relayAgents?: readonly RelayAgent[];
|
||||
};
|
||||
|
||||
export function useProfileAgentDeletion({
|
||||
channels,
|
||||
deleteManagedAgent,
|
||||
managedAgent,
|
||||
managedAgents,
|
||||
presenceLookup,
|
||||
relayAgents,
|
||||
}: UseProfileAgentDeletionInput) {
|
||||
const removeAgentFromAllChannels = React.useCallback(
|
||||
async (agentPubkey: string) => {
|
||||
const normalizedPubkey = agentPubkey.toLowerCase();
|
||||
const channelIds = new Set(
|
||||
getRelayAgentChannelIds(relayAgents, agentPubkey),
|
||||
);
|
||||
for (const channel of channels ?? []) {
|
||||
if (
|
||||
channel.memberPubkeys.some(
|
||||
(memberPubkey) => memberPubkey.toLowerCase() === normalizedPubkey,
|
||||
)
|
||||
) {
|
||||
channelIds.add(channel.id);
|
||||
}
|
||||
}
|
||||
if (channelIds.size === 0) return;
|
||||
await Promise.allSettled(
|
||||
[...channelIds].map((channelId) =>
|
||||
removeChannelMember(channelId, agentPubkey),
|
||||
),
|
||||
);
|
||||
},
|
||||
[channels, relayAgents],
|
||||
);
|
||||
|
||||
const deleteManagedAgentRecord = React.useCallback(
|
||||
(agentToDelete: ManagedAgent) =>
|
||||
deleteProfileManagedAgent(agentToDelete, {
|
||||
channels: channels ?? [],
|
||||
deleteManagedAgent,
|
||||
presenceLookup,
|
||||
relayAgents: relayAgents ?? [],
|
||||
removeAgentFromAllChannels,
|
||||
skipRemoteDeleteConfirm: true,
|
||||
}),
|
||||
[
|
||||
channels,
|
||||
deleteManagedAgent,
|
||||
presenceLookup,
|
||||
relayAgents,
|
||||
removeAgentFromAllChannels,
|
||||
],
|
||||
);
|
||||
|
||||
const deleteManagedAgentsForPersona = React.useCallback(
|
||||
(persona: AgentPersona) =>
|
||||
deleteProfileManagedAgentsForPersona(persona, {
|
||||
channels: channels ?? [],
|
||||
deleteManagedAgent,
|
||||
managedAgents: managedAgents ?? [],
|
||||
presenceLookup,
|
||||
relayAgents: relayAgents ?? [],
|
||||
removeAgentFromAllChannels,
|
||||
selectedAgent: managedAgent,
|
||||
}),
|
||||
[
|
||||
channels,
|
||||
deleteManagedAgent,
|
||||
managedAgent,
|
||||
managedAgents,
|
||||
presenceLookup,
|
||||
relayAgents,
|
||||
removeAgentFromAllChannels,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
deleteManagedAgentRecord,
|
||||
deleteManagedAgentsForPersona,
|
||||
removeAgentFromAllChannels,
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteProfileManagedAgent(
|
||||
agent: ManagedAgent,
|
||||
context: DeleteProfileManagedAgentContext,
|
||||
): Promise<ManagedAgentActionResult> {
|
||||
const { removeAgentFromAllChannels, ...deleteContext } = context;
|
||||
const result = await deleteManagedAgentWithRules({
|
||||
agent,
|
||||
...deleteContext,
|
||||
});
|
||||
if (result.cancelled) return result;
|
||||
|
||||
await removeAgentFromAllChannels(agent.pubkey);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function deleteProfileManagedAgentsForPersona(
|
||||
persona: AgentPersona,
|
||||
context: DeleteProfileManagedAgentsForPersonaContext,
|
||||
): Promise<ManagedAgentActionResult> {
|
||||
const { managedAgents, selectedAgent, ...deleteContext } = context;
|
||||
const agentsByPubkey = new Map<string, ManagedAgent>();
|
||||
|
||||
for (const agent of managedAgents) {
|
||||
if (agent.personaId === persona.id) {
|
||||
agentsByPubkey.set(agent.pubkey, agent);
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedAgent?.personaId === persona.id) {
|
||||
agentsByPubkey.set(selectedAgent.pubkey, selectedAgent);
|
||||
}
|
||||
|
||||
for (const agent of agentsByPubkey.values()) {
|
||||
const result = await deleteProfileManagedAgent(agent, deleteContext);
|
||||
if (result.cancelled) return result;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Activity,
|
||||
ArrowUpRight,
|
||||
Copy,
|
||||
Cpu,
|
||||
Ear,
|
||||
Fingerprint,
|
||||
Server,
|
||||
Terminal,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge";
|
||||
import { truncatePubkey as truncatePubkeyShort } from "@/features/profile/lib/identity";
|
||||
import { UserAvatar } from "@/shared/ui/UserAvatar";
|
||||
import type {
|
||||
AgentPersona,
|
||||
ManagedAgent,
|
||||
Profile,
|
||||
RelayAgent,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
const RUNTIME_LABELS: Record<string, string> = {
|
||||
goose: "Goose",
|
||||
"claude-code": "Claude Code",
|
||||
"codex-acp": "Codex",
|
||||
aider: "Aider",
|
||||
};
|
||||
|
||||
function runtimeLabel(command: string): string {
|
||||
return RUNTIME_LABELS[command] ?? command;
|
||||
}
|
||||
|
||||
async function copyToClipboard(value: string, label?: string) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast.success(label ? `Copied ${label}` : "Copied to clipboard");
|
||||
}
|
||||
|
||||
export type ProfileField = {
|
||||
copyValue?: string;
|
||||
displayValue: string;
|
||||
displayNode?: React.ReactNode;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
testId?: string;
|
||||
trailingNode?: React.ReactNode;
|
||||
};
|
||||
|
||||
const AGENT_INFO_LABELS = new Set([
|
||||
"Public key",
|
||||
"Owned by",
|
||||
"NIP-05",
|
||||
"Agent type",
|
||||
"Capabilities",
|
||||
"Backend",
|
||||
]);
|
||||
const AGENT_SETTINGS_LABELS = new Set([
|
||||
"Runtime",
|
||||
"Respond to",
|
||||
"ACP command",
|
||||
"MCP command",
|
||||
"Start on launch",
|
||||
]);
|
||||
const DIAGNOSTICS_LABELS = new Set(["Status", "Last error"]);
|
||||
|
||||
export function bucketProfileFields(fields: ProfileField[]) {
|
||||
return {
|
||||
agentInfoFields: fields.filter((field) =>
|
||||
AGENT_INFO_LABELS.has(field.label),
|
||||
),
|
||||
agentSettingsFields: fields.filter((field) =>
|
||||
AGENT_SETTINGS_LABELS.has(field.label),
|
||||
),
|
||||
diagnosticsFields: fields.filter((field) =>
|
||||
DIAGNOSTICS_LABELS.has(field.label),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function useProfileFieldBuckets({
|
||||
isBot,
|
||||
isOwner,
|
||||
managedAgent,
|
||||
onOpenProfile,
|
||||
ownerAvatarUrl,
|
||||
ownerDisplayName,
|
||||
ownerHandle,
|
||||
ownerProfilePubkey,
|
||||
ownerPubkey,
|
||||
persona,
|
||||
presenceLoaded,
|
||||
presenceStatus,
|
||||
profile,
|
||||
pubkey,
|
||||
relayAgent,
|
||||
}: {
|
||||
isBot: boolean;
|
||||
isOwner: boolean | undefined;
|
||||
managedAgent: ManagedAgent | undefined;
|
||||
onOpenProfile?: (pubkey: string) => void;
|
||||
ownerAvatarUrl: string | null;
|
||||
ownerDisplayName: string | null;
|
||||
ownerHandle: string | null;
|
||||
ownerProfilePubkey: string | null;
|
||||
ownerPubkey: string | null;
|
||||
persona: AgentPersona | undefined;
|
||||
presenceLoaded: boolean;
|
||||
presenceStatus: "online" | "away" | "offline" | undefined;
|
||||
profile: Profile | undefined;
|
||||
pubkey: string | null;
|
||||
relayAgent: RelayAgent | undefined;
|
||||
}) {
|
||||
return React.useMemo(() => {
|
||||
const metadataFields = [
|
||||
...buildPublicFields({ pubkey, profile, relayAgent, isBot, persona }),
|
||||
...(ownerDisplayName || isOwner === true
|
||||
? buildOwnerFields({
|
||||
includeOperationalFields: isOwner === true,
|
||||
managedAgent,
|
||||
onOpenProfile,
|
||||
ownerAvatarUrl,
|
||||
ownerDisplayName,
|
||||
ownerHandle,
|
||||
ownerProfilePubkey,
|
||||
ownerPubkey,
|
||||
persona,
|
||||
presenceLoaded,
|
||||
presenceStatus,
|
||||
relayAgent,
|
||||
})
|
||||
: []),
|
||||
];
|
||||
return {
|
||||
...bucketProfileFields(metadataFields),
|
||||
modelLabel: managedAgent?.model ?? persona?.model ?? "Auto",
|
||||
};
|
||||
}, [
|
||||
isBot,
|
||||
isOwner,
|
||||
managedAgent,
|
||||
onOpenProfile,
|
||||
ownerAvatarUrl,
|
||||
ownerDisplayName,
|
||||
ownerHandle,
|
||||
ownerProfilePubkey,
|
||||
ownerPubkey,
|
||||
persona,
|
||||
presenceLoaded,
|
||||
presenceStatus,
|
||||
profile,
|
||||
pubkey,
|
||||
relayAgent,
|
||||
]);
|
||||
}
|
||||
|
||||
export function buildPublicFields({
|
||||
isBot,
|
||||
persona,
|
||||
profile,
|
||||
pubkey,
|
||||
relayAgent,
|
||||
}: {
|
||||
isBot: boolean;
|
||||
persona?: AgentPersona;
|
||||
profile: Profile | undefined;
|
||||
pubkey: string | null;
|
||||
relayAgent: RelayAgent | undefined;
|
||||
}): ProfileField[] {
|
||||
const fields: ProfileField[] = [];
|
||||
|
||||
if (pubkey) {
|
||||
fields.push({
|
||||
copyValue: pubkey,
|
||||
displayValue: truncatePubkeyShort(pubkey),
|
||||
icon: Fingerprint,
|
||||
label: "Public key",
|
||||
testId: "user-profile-copy-pubkey",
|
||||
});
|
||||
}
|
||||
|
||||
if (profile?.nip05Handle) {
|
||||
fields.push({
|
||||
copyValue: profile.nip05Handle,
|
||||
displayValue: profile.nip05Handle,
|
||||
icon: UserRound,
|
||||
label: "NIP-05",
|
||||
testId: "user-profile-nip05",
|
||||
});
|
||||
}
|
||||
|
||||
if (isBot && relayAgent?.agentType) {
|
||||
fields.push({
|
||||
copyValue: relayAgent.agentType,
|
||||
displayValue: runtimeLabel(relayAgent.agentType),
|
||||
icon: Cpu,
|
||||
label: "Agent type",
|
||||
testId: "user-profile-agent-type",
|
||||
});
|
||||
}
|
||||
|
||||
if (!pubkey && persona) {
|
||||
fields.push({
|
||||
displayValue: "Not deployed",
|
||||
icon: Activity,
|
||||
label: "Status",
|
||||
testId: "user-profile-agent-status",
|
||||
});
|
||||
}
|
||||
|
||||
if (relayAgent?.capabilities.length) {
|
||||
fields.push({
|
||||
copyValue: relayAgent.capabilities.join(", "),
|
||||
displayValue: relayAgent.capabilities.join(", "),
|
||||
icon: Server,
|
||||
label: "Capabilities",
|
||||
testId: "user-profile-capabilities",
|
||||
});
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
export function buildOwnerFields({
|
||||
includeOperationalFields,
|
||||
managedAgent,
|
||||
onOpenProfile,
|
||||
ownerAvatarUrl,
|
||||
ownerDisplayName,
|
||||
ownerHandle,
|
||||
ownerProfilePubkey,
|
||||
ownerPubkey,
|
||||
persona,
|
||||
presenceLoaded,
|
||||
presenceStatus,
|
||||
relayAgent,
|
||||
}: {
|
||||
includeOperationalFields: boolean;
|
||||
managedAgent: ManagedAgent | undefined;
|
||||
onOpenProfile?: (pubkey: string) => void;
|
||||
ownerAvatarUrl: string | null;
|
||||
ownerDisplayName: string | null;
|
||||
ownerHandle: string | null;
|
||||
ownerProfilePubkey: string | null;
|
||||
ownerPubkey: string | null;
|
||||
persona?: AgentPersona;
|
||||
presenceLoaded: boolean;
|
||||
presenceStatus: "online" | "away" | "offline" | undefined;
|
||||
relayAgent: RelayAgent | undefined;
|
||||
}): ProfileField[] {
|
||||
const fields: ProfileField[] = [];
|
||||
const respondToDisplayValue = managedAgent
|
||||
? managedAgent.respondTo === "owner-only" && ownerDisplayName
|
||||
? ownerDisplayName
|
||||
: managedAgent.respondTo.replace(/-/g, " ")
|
||||
: null;
|
||||
|
||||
const ownerClickable = Boolean(onOpenProfile && ownerProfilePubkey);
|
||||
const ownerContent = (
|
||||
<>
|
||||
<UserAvatar
|
||||
avatarUrl={ownerAvatarUrl}
|
||||
className="shrink-0"
|
||||
displayName={ownerHandle ?? ownerDisplayName ?? ""}
|
||||
size="xs"
|
||||
testId="user-profile-owner-avatar"
|
||||
/>
|
||||
<span className="truncate">{ownerDisplayName}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (ownerDisplayName) {
|
||||
fields.push({
|
||||
copyValue: ownerClickable
|
||||
? undefined
|
||||
: (ownerProfilePubkey ?? ownerPubkey ?? ownerHandle ?? undefined),
|
||||
displayValue: ownerDisplayName,
|
||||
displayNode: (
|
||||
<span className="inline-flex max-w-full items-center gap-2">
|
||||
{ownerContent}
|
||||
</span>
|
||||
),
|
||||
icon: UserRound,
|
||||
label: "Owned by",
|
||||
onClick:
|
||||
ownerClickable && ownerProfilePubkey
|
||||
? () => onOpenProfile?.(ownerProfilePubkey)
|
||||
: undefined,
|
||||
testId: "user-profile-owned-by",
|
||||
});
|
||||
}
|
||||
|
||||
if (!includeOperationalFields) {
|
||||
return fields;
|
||||
}
|
||||
|
||||
if (managedAgent?.agentCommand) {
|
||||
fields.push({
|
||||
copyValue: managedAgent.agentCommand,
|
||||
displayValue: runtimeLabel(managedAgent.agentCommand),
|
||||
icon: Terminal,
|
||||
label: "Runtime",
|
||||
testId: "user-profile-runtime",
|
||||
});
|
||||
} else if (relayAgent?.agentType) {
|
||||
fields.push({
|
||||
copyValue: relayAgent.agentType,
|
||||
displayValue: runtimeLabel(relayAgent.agentType),
|
||||
icon: Terminal,
|
||||
label: "Runtime",
|
||||
testId: "user-profile-runtime",
|
||||
});
|
||||
} else if (persona?.runtime) {
|
||||
fields.push({
|
||||
copyValue: persona.runtime,
|
||||
displayValue: runtimeLabel(persona.runtime),
|
||||
icon: Terminal,
|
||||
label: "Runtime",
|
||||
testId: "user-profile-runtime",
|
||||
});
|
||||
}
|
||||
|
||||
if (managedAgent) {
|
||||
fields.push({
|
||||
displayValue: managedAgent.status
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (char: string) => char.toUpperCase()),
|
||||
displayNode: (
|
||||
<AgentStatusBadge
|
||||
presenceLoaded={presenceLoaded}
|
||||
presenceStatus={presenceStatus}
|
||||
status={managedAgent.status}
|
||||
/>
|
||||
),
|
||||
icon: Activity,
|
||||
label: "Status",
|
||||
testId: "user-profile-agent-status",
|
||||
});
|
||||
}
|
||||
|
||||
if (managedAgent?.model) {
|
||||
fields.push({
|
||||
copyValue: managedAgent.model,
|
||||
displayValue: managedAgent.model,
|
||||
icon: Cpu,
|
||||
label: "Model",
|
||||
testId: "user-profile-model",
|
||||
});
|
||||
} else if (persona?.model) {
|
||||
fields.push({
|
||||
copyValue: persona.model,
|
||||
displayValue: persona.model,
|
||||
icon: Cpu,
|
||||
label: "Model",
|
||||
testId: "user-profile-model",
|
||||
});
|
||||
}
|
||||
|
||||
if (managedAgent?.acpCommand) {
|
||||
fields.push({
|
||||
copyValue: managedAgent.acpCommand,
|
||||
displayValue: managedAgent.acpCommand,
|
||||
icon: Terminal,
|
||||
label: "ACP command",
|
||||
testId: "user-profile-acp",
|
||||
});
|
||||
}
|
||||
|
||||
if (managedAgent?.mcpCommand) {
|
||||
fields.push({
|
||||
copyValue: managedAgent.mcpCommand,
|
||||
displayValue: managedAgent.mcpCommand,
|
||||
icon: Terminal,
|
||||
label: "MCP command",
|
||||
testId: "user-profile-mcp",
|
||||
});
|
||||
}
|
||||
|
||||
if (managedAgent?.backend.type === "provider") {
|
||||
const backendLabel = managedAgent.backend.id;
|
||||
fields.push({
|
||||
copyValue: backendLabel,
|
||||
displayValue: backendLabel,
|
||||
icon: Server,
|
||||
label: "Backend",
|
||||
testId: "user-profile-backend",
|
||||
});
|
||||
}
|
||||
|
||||
if (managedAgent) {
|
||||
fields.push({
|
||||
displayValue: managedAgent.startOnAppLaunch ? "Yes" : "No",
|
||||
icon: Server,
|
||||
label: "Start on launch",
|
||||
testId: "user-profile-start-on-launch",
|
||||
});
|
||||
if (respondToDisplayValue) {
|
||||
fields.push({
|
||||
displayValue: respondToDisplayValue,
|
||||
icon: Ear,
|
||||
label: "Respond to",
|
||||
testId: "user-profile-respond-to",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (managedAgent?.lastError) {
|
||||
fields.push({
|
||||
copyValue: managedAgent.lastError,
|
||||
displayValue: managedAgent.lastError,
|
||||
icon: Activity,
|
||||
label: "Last error",
|
||||
testId: "user-profile-last-error",
|
||||
});
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
function orderProfileFields(fields: ProfileField[]) {
|
||||
const visibilityLabel = "Visibility";
|
||||
const publicKeyLabel = "Public key";
|
||||
const ownedByLabel = "Owned by";
|
||||
const statusLabel = "Status";
|
||||
return [
|
||||
...fields.filter((field) => field.label === visibilityLabel),
|
||||
...fields.filter((field) => field.label === publicKeyLabel),
|
||||
...fields.filter((field) => field.label === ownedByLabel),
|
||||
...fields.filter(
|
||||
(field) =>
|
||||
field.label !== visibilityLabel &&
|
||||
field.label !== publicKeyLabel &&
|
||||
field.label !== ownedByLabel &&
|
||||
field.copyValue,
|
||||
),
|
||||
...fields.filter((field) => field.label === statusLabel),
|
||||
...fields.filter((field) => {
|
||||
if (
|
||||
field.label === visibilityLabel ||
|
||||
field.label === publicKeyLabel ||
|
||||
field.label === ownedByLabel ||
|
||||
field.label === statusLabel
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return !field.copyValue;
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
export function ProfileFieldRows({ fields }: { fields: ProfileField[] }) {
|
||||
return (
|
||||
<>
|
||||
{orderProfileFields(fields).map((field) => (
|
||||
<ProfileFieldRow field={field} key={field.testId ?? field.label} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileFieldGroup({ fields }: { fields: ProfileField[] }) {
|
||||
return (
|
||||
<section>
|
||||
<div className="overflow-hidden rounded-2xl bg-muted/20">
|
||||
<ProfileFieldRows fields={fields} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileFieldRow({ field }: { field: ProfileField }) {
|
||||
const Icon = field.icon;
|
||||
const isCopyable = Boolean(field.copyValue);
|
||||
const isActionable = Boolean(field.onClick);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted/60">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-left">
|
||||
<span className="block text-xs font-medium text-foreground">
|
||||
{field.label}
|
||||
</span>
|
||||
<span
|
||||
className="mt-0.5 block truncate text-sm text-muted-foreground"
|
||||
title={field.displayValue}
|
||||
>
|
||||
{field.displayNode ?? field.displayValue}
|
||||
</span>
|
||||
</span>
|
||||
{field.trailingNode}
|
||||
{isActionable ? (
|
||||
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : isCopyable ? (
|
||||
<Copy className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isActionable) {
|
||||
return (
|
||||
<button
|
||||
aria-label={`Open ${field.label}`}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/40"
|
||||
data-testid={field.testId}
|
||||
onClick={field.onClick}
|
||||
title={`Open ${field.label}`}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isCopyable && field.copyValue) {
|
||||
return (
|
||||
<button
|
||||
aria-label={`Copy ${field.label}`}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/40"
|
||||
data-testid={field.testId}
|
||||
onClick={() => void copyToClipboard(field.copyValue ?? "", field.label)}
|
||||
title={`Copy ${field.label}`}
|
||||
type="button"
|
||||
>
|
||||
{content}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3"
|
||||
data-testid={field.testId}
|
||||
>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type * as React from "react";
|
||||
|
||||
import { THREAD_PANEL_MIN_WIDTH_PX } from "@/shared/hooks/useThreadPanelWidth";
|
||||
import { AuxiliaryPanelHeader } from "@/shared/layout/AuxiliaryPanelHeader";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
OverlayPanelBackdrop,
|
||||
PANEL_ENTER_BASE_CLASS,
|
||||
PANEL_OVERLAY_CLASS,
|
||||
PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS,
|
||||
} from "@/shared/ui/OverlayPanelBackdrop";
|
||||
|
||||
type UserProfilePanelFrameProps = {
|
||||
addAgentToChannelDialog: React.ReactNode;
|
||||
canResetWidth?: boolean;
|
||||
editAgentDialog: React.ReactNode;
|
||||
headerActions: React.ReactNode;
|
||||
headerLeftContent: React.ReactNode;
|
||||
isFloatingOverlay: boolean;
|
||||
isOverlay: boolean;
|
||||
isSinglePanelView: boolean;
|
||||
isSplitLayout: boolean;
|
||||
onClose: () => void;
|
||||
onResetWidth?: () => void;
|
||||
onResizeStart?: React.PointerEventHandler<HTMLButtonElement>;
|
||||
personaDialogs: React.ReactNode;
|
||||
profileBody: React.ReactNode;
|
||||
splitPaneClamp: boolean;
|
||||
widthPx: number;
|
||||
};
|
||||
|
||||
export function UserProfilePanelFrame({
|
||||
addAgentToChannelDialog,
|
||||
canResetWidth,
|
||||
editAgentDialog,
|
||||
headerActions,
|
||||
headerLeftContent,
|
||||
isFloatingOverlay,
|
||||
isOverlay,
|
||||
isSinglePanelView,
|
||||
isSplitLayout,
|
||||
onClose,
|
||||
onResetWidth,
|
||||
onResizeStart,
|
||||
personaDialogs,
|
||||
profileBody,
|
||||
splitPaneClamp,
|
||||
widthPx,
|
||||
}: UserProfilePanelFrameProps) {
|
||||
if (isSplitLayout) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<AuxiliaryPanelHeader>
|
||||
{headerLeftContent}
|
||||
{headerActions}
|
||||
</AuxiliaryPanelHeader>
|
||||
{profileBody}
|
||||
</div>
|
||||
{editAgentDialog}
|
||||
{addAgentToChannelDialog}
|
||||
{personaDialogs}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFloatingOverlay && <OverlayPanelBackdrop onClose={onClose} />}
|
||||
<aside
|
||||
className={cn(
|
||||
PANEL_ENTER_BASE_CLASS,
|
||||
isSinglePanelView && "border-l-0",
|
||||
isFloatingOverlay && PANEL_OVERLAY_CLASS,
|
||||
)}
|
||||
data-testid="user-profile-panel"
|
||||
style={{
|
||||
width: isSinglePanelView
|
||||
? "100%"
|
||||
: splitPaneClamp
|
||||
? `min(${widthPx}px, calc(100% - ${THREAD_PANEL_MIN_WIDTH_PX}px))`
|
||||
: `${widthPx}px`,
|
||||
}}
|
||||
>
|
||||
{!isOverlay && !isSinglePanelView && onResizeStart && (
|
||||
<button
|
||||
aria-label="Resize profile panel"
|
||||
className="peer/profile-resize group/profile-resize absolute inset-y-0 left-0 z-40 w-3 -translate-x-1/2 cursor-col-resize"
|
||||
data-testid="user-profile-resize-handle"
|
||||
onDoubleClick={canResetWidth ? onResetWidth : undefined}
|
||||
onPointerDown={onResizeStart}
|
||||
title={
|
||||
canResetWidth
|
||||
? "Drag to resize. Double-click to reset width."
|
||||
: "Drag to resize."
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<span className="absolute bottom-0 left-1/2 top-10 w-px -translate-x-1/2 bg-transparent transition-colors group-hover/profile-resize:bg-border/80 group-focus-visible/profile-resize:bg-border/80" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isOverlay ? (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-40 h-13 bg-background/80 backdrop-blur-md supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center",
|
||||
isSinglePanelView
|
||||
? `relative ${PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS} -mb-13 min-h-13 shrink-0 gap-2.5 bg-transparent px-4 py-2 sm:pl-6 sm:pr-3`
|
||||
: isOverlay
|
||||
? "relative z-50 min-h-13 shrink-0 gap-3 bg-background/80 px-5 py-2 backdrop-blur-md supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55"
|
||||
: "absolute inset-x-0 top-0 z-50 min-h-13 gap-3 bg-transparent px-3 py-2 after:absolute after:bottom-0 after:-left-px after:top-0 after:w-px after:bg-border/45 after:transition-colors peer-hover/profile-resize:after:bg-border/80 peer-focus-visible/profile-resize:after:bg-border/80",
|
||||
)}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
{headerLeftContent}
|
||||
{headerActions}
|
||||
</div>
|
||||
|
||||
{profileBody}
|
||||
</aside>
|
||||
{editAgentDialog}
|
||||
{addAgentToChannelDialog}
|
||||
{personaDialogs}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { ArrowLeft, X } from "lucide-react";
|
||||
|
||||
import { CopyButton } from "@/features/agents/ui/CopyButton";
|
||||
import { MemoryRefreshButton } from "@/features/agent-memory/ui/MemorySection";
|
||||
import {
|
||||
PROFILE_PANEL_VIEW_TITLES,
|
||||
type ProfilePanelView,
|
||||
} from "@/features/profile/ui/UserProfilePanelUtils";
|
||||
import {
|
||||
AuxiliaryPanelHeaderGroup,
|
||||
AuxiliaryPanelTitle,
|
||||
} from "@/shared/layout/AuxiliaryPanelHeader";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
export function getUserProfilePanelHeaderContent({
|
||||
agentSettingsMenu,
|
||||
effectivePubkey,
|
||||
logCopyValue,
|
||||
logSubtitle,
|
||||
onBack,
|
||||
onClose,
|
||||
view,
|
||||
viewerIsOwner,
|
||||
}: {
|
||||
agentSettingsMenu: ReactNode;
|
||||
effectivePubkey: string | null;
|
||||
logCopyValue?: string | null;
|
||||
logSubtitle?: string | null;
|
||||
onBack: () => void;
|
||||
onClose: () => void;
|
||||
view: ProfilePanelView;
|
||||
viewerIsOwner: boolean;
|
||||
}) {
|
||||
const title = PROFILE_PANEL_VIEW_TITLES[view];
|
||||
const shouldShowLogDetails =
|
||||
(view === "diagnostics" || view === "logs") && Boolean(logSubtitle);
|
||||
const headerLeftContent = (
|
||||
<AuxiliaryPanelHeaderGroup
|
||||
className={shouldShowLogDetails ? "items-start" : undefined}
|
||||
>
|
||||
{view !== "summary" ? (
|
||||
<Button
|
||||
aria-label="Back to profile"
|
||||
className={shouldShowLogDetails ? "mt-0.5 shrink-0" : "shrink-0"}
|
||||
data-testid="user-profile-panel-back"
|
||||
onClick={onBack}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
) : null}
|
||||
{shouldShowLogDetails ? (
|
||||
<div className="min-w-0 flex-1">
|
||||
<AuxiliaryPanelTitle className="translate-y-0 leading-5">
|
||||
{title}
|
||||
</AuxiliaryPanelTitle>
|
||||
<p
|
||||
className="min-w-0 truncate font-mono text-2xs text-muted-foreground"
|
||||
title={logSubtitle ?? undefined}
|
||||
>
|
||||
{logSubtitle}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<AuxiliaryPanelTitle>{title}</AuxiliaryPanelTitle>
|
||||
)}
|
||||
</AuxiliaryPanelHeaderGroup>
|
||||
);
|
||||
const headerActions = (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{view === "memories" && viewerIsOwner && effectivePubkey ? (
|
||||
<MemoryRefreshButton
|
||||
agentPubkey={effectivePubkey}
|
||||
variant="outline"
|
||||
viewerIsOwner={viewerIsOwner}
|
||||
/>
|
||||
) : null}
|
||||
{view === "summary" ? agentSettingsMenu : null}
|
||||
{shouldShowLogDetails ? (
|
||||
<CopyButton
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
iconOnly
|
||||
label="Copy log"
|
||||
size="icon"
|
||||
value={logCopyValue ?? ""}
|
||||
variant="ghost"
|
||||
/>
|
||||
) : null}
|
||||
<Button
|
||||
aria-label="Close profile"
|
||||
data-testid="user-profile-panel-close"
|
||||
onClick={onClose}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return { headerActions, headerLeftContent };
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { validateLinkedAgentRuntimeEdit } from "./UserProfilePanelPersonaSubmit.ts";
|
||||
|
||||
function agent(overrides = {}) {
|
||||
return {
|
||||
pubkey: "deadbeef".repeat(8),
|
||||
name: "Fizz",
|
||||
personaId: "persona-1",
|
||||
relayUrl: "ws://localhost:3000",
|
||||
acpCommand: "buzz-acp",
|
||||
agentCommand: "goose",
|
||||
agentArgs: [],
|
||||
mcpCommand: "",
|
||||
turnTimeoutSeconds: 320,
|
||||
idleTimeoutSeconds: null,
|
||||
maxTurnDurationSeconds: null,
|
||||
parallelism: 1,
|
||||
systemPrompt: "Prompt",
|
||||
avatarUrl: null,
|
||||
model: null,
|
||||
mcpToolsets: null,
|
||||
envVars: {},
|
||||
status: "stopped",
|
||||
pid: null,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
lastStartedAt: null,
|
||||
lastStoppedAt: null,
|
||||
lastExitCode: null,
|
||||
lastError: null,
|
||||
logPath: null,
|
||||
startOnAppLaunch: true,
|
||||
backend: { type: "local" },
|
||||
backendAgentId: null,
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function persona(overrides = {}) {
|
||||
return {
|
||||
id: "persona-1",
|
||||
displayName: "Fizz",
|
||||
avatarUrl: null,
|
||||
systemPrompt: "Prompt",
|
||||
runtime: "goose",
|
||||
model: null,
|
||||
provider: null,
|
||||
namePool: [],
|
||||
isBuiltIn: false,
|
||||
isActive: true,
|
||||
envVars: {},
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function updateInput(overrides = {}) {
|
||||
return {
|
||||
id: "persona-1",
|
||||
displayName: "Fizz",
|
||||
avatarUrl: undefined,
|
||||
systemPrompt: "Prompt",
|
||||
runtime: "claude",
|
||||
model: undefined,
|
||||
provider: undefined,
|
||||
namePool: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function runtime(overrides = {}) {
|
||||
return {
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
avatarUrl: "",
|
||||
availability: "available",
|
||||
command: "claude",
|
||||
binaryPath: "/usr/local/bin/claude",
|
||||
defaultArgs: [],
|
||||
mcpCommand: null,
|
||||
installHint: "",
|
||||
installInstructionsUrl: "",
|
||||
canAutoInstall: false,
|
||||
underlyingCliPath: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("validateLinkedAgentRuntimeEdit allows available runtime changes", () => {
|
||||
assert.equal(
|
||||
validateLinkedAgentRuntimeEdit({
|
||||
input: updateInput({ runtime: "claude" }),
|
||||
managedAgent: agent(),
|
||||
previousPersona: persona({ runtime: "goose" }),
|
||||
runtimes: [runtime()],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("validateLinkedAgentRuntimeEdit rejects unavailable linked-agent runtime changes", () => {
|
||||
assert.equal(
|
||||
validateLinkedAgentRuntimeEdit({
|
||||
input: updateInput({ runtime: "claude" }),
|
||||
managedAgent: agent(),
|
||||
previousPersona: persona({ runtime: "goose" }),
|
||||
runtimes: [runtime({ availability: "cli_missing", command: null })],
|
||||
}),
|
||||
"Claude Code is not available. Install it before saving this linked agent.",
|
||||
);
|
||||
});
|
||||
|
||||
test("validateLinkedAgentRuntimeEdit allows unchanged or unlinked runtime preferences", () => {
|
||||
assert.equal(
|
||||
validateLinkedAgentRuntimeEdit({
|
||||
input: updateInput({ runtime: "goose" }),
|
||||
managedAgent: agent(),
|
||||
previousPersona: persona({ runtime: "goose" }),
|
||||
runtimes: [],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
validateLinkedAgentRuntimeEdit({
|
||||
input: updateInput({ runtime: "claude" }),
|
||||
managedAgent: undefined,
|
||||
previousPersona: persona({ runtime: "goose" }),
|
||||
runtimes: [],
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { personaManagedAgentUpdate } from "@/features/profile/ui/UserProfilePanelUtils";
|
||||
import type {
|
||||
AcpRuntimeCatalogEntry,
|
||||
AgentPersona,
|
||||
CreateManagedAgentResponse,
|
||||
CreatePersonaInput,
|
||||
ManagedAgent,
|
||||
UpdateManagedAgentInput,
|
||||
UpdatePersonaInput,
|
||||
} from "@/shared/api/types";
|
||||
|
||||
type SubmitProfilePersonaDialogOptions = {
|
||||
createManagedAgentForPersona: (
|
||||
persona: AgentPersona,
|
||||
) => Promise<CreateManagedAgentResponse>;
|
||||
createPersona: (input: CreatePersonaInput) => Promise<AgentPersona>;
|
||||
input: CreatePersonaInput | UpdatePersonaInput;
|
||||
managedAgent: ManagedAgent | undefined;
|
||||
onDone: () => void;
|
||||
previousPersona?: AgentPersona;
|
||||
runtimes?: readonly AcpRuntimeCatalogEntry[];
|
||||
updateManagedAgent: (
|
||||
input: UpdateManagedAgentInput,
|
||||
) => Promise<{ agent: ManagedAgent; profileSyncError: string | null }>;
|
||||
updatePersona: (input: UpdatePersonaInput) => Promise<AgentPersona>;
|
||||
};
|
||||
|
||||
type ValidateLinkedAgentRuntimeEditOptions = {
|
||||
input: UpdatePersonaInput;
|
||||
managedAgent: ManagedAgent | undefined;
|
||||
previousPersona?: AgentPersona;
|
||||
runtimes?: readonly AcpRuntimeCatalogEntry[];
|
||||
};
|
||||
|
||||
function normalizeRuntimePreference(value: string | null | undefined): string {
|
||||
return value?.trim() ?? "";
|
||||
}
|
||||
|
||||
export function validateLinkedAgentRuntimeEdit({
|
||||
input,
|
||||
managedAgent,
|
||||
previousPersona,
|
||||
runtimes,
|
||||
}: ValidateLinkedAgentRuntimeEditOptions): string | null {
|
||||
if (!managedAgent || !previousPersona) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previousRuntime = normalizeRuntimePreference(previousPersona.runtime);
|
||||
const nextRuntime = normalizeRuntimePreference(input.runtime);
|
||||
if (previousRuntime === nextRuntime) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtime = runtimes?.find((candidate) => candidate.id === nextRuntime);
|
||||
if (runtime?.availability === "available" && runtime.command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const runtimeLabel = runtime?.label ?? "This provider";
|
||||
return `${runtimeLabel} is not available. Install it before saving this linked agent.`;
|
||||
}
|
||||
|
||||
export async function submitProfilePersonaDialog({
|
||||
createManagedAgentForPersona,
|
||||
createPersona,
|
||||
input,
|
||||
managedAgent,
|
||||
onDone,
|
||||
previousPersona,
|
||||
runtimes,
|
||||
updateManagedAgent,
|
||||
updatePersona,
|
||||
}: SubmitProfilePersonaDialogOptions) {
|
||||
try {
|
||||
if ("id" in input) {
|
||||
const runtimeEditError = validateLinkedAgentRuntimeEdit({
|
||||
input,
|
||||
managedAgent,
|
||||
previousPersona,
|
||||
runtimes,
|
||||
});
|
||||
if (runtimeEditError) {
|
||||
toast.error(runtimeEditError);
|
||||
return;
|
||||
}
|
||||
|
||||
const persona = await updatePersona(input);
|
||||
const agentUpdate = managedAgent
|
||||
? personaManagedAgentUpdate(managedAgent, persona, {
|
||||
previousPersona,
|
||||
runtimes,
|
||||
})
|
||||
: null;
|
||||
const result = agentUpdate ? await updateManagedAgent(agentUpdate) : null;
|
||||
if (result?.profileSyncError) {
|
||||
toast.warning(
|
||||
`${result.agent.name} was updated, but profile sync failed: ${result.profileSyncError}`,
|
||||
);
|
||||
}
|
||||
toast.success(`Updated ${input.displayName}.`);
|
||||
} else {
|
||||
const persona = await createPersona(input);
|
||||
try {
|
||||
const created = await createManagedAgentForPersona(persona);
|
||||
if (created.spawnError) {
|
||||
toast.error(
|
||||
`${persona.displayName} was created, but it did not start: ${created.spawnError}`,
|
||||
);
|
||||
} else {
|
||||
toast.success(`Created and started ${created.agent.name}.`);
|
||||
}
|
||||
if (created.profileSyncError) {
|
||||
toast.warning(
|
||||
`${created.agent.name} was created, but profile sync failed: ${created.profileSyncError}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? `${persona.displayName} was created, but the agent instance could not be created: ${error.message}`
|
||||
: `${persona.displayName} was created, but the agent instance could not be created.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
onDone();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to save agent.",
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,415 @@
|
||||
import * as React from "react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Activity, Archive, ChevronRight, Info, Wrench } from "lucide-react";
|
||||
|
||||
import {
|
||||
AgentDetailsRows,
|
||||
AgentInstructionRow,
|
||||
} from "@/features/profile/ui/UserProfilePanelAgentDetails";
|
||||
import {
|
||||
type ProfileField,
|
||||
ProfileFieldGroup,
|
||||
ProfileFieldRows,
|
||||
} from "@/features/profile/ui/UserProfilePanelFields";
|
||||
import type { ProfilePanelTab } from "@/features/profile/ui/UserProfilePanelUtils";
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
|
||||
export function ProfileIngressRow({
|
||||
disabled,
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
testId,
|
||||
trailing,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
testId: string;
|
||||
trailing?: React.ReactNode;
|
||||
}) {
|
||||
const trailingTitle = typeof trailing === "string" ? trailing : undefined;
|
||||
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center gap-3 rounded-2xl bg-muted/20 px-4 py-2 text-left transition-colors hover:bg-muted/40 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
data-testid={testId}
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
type="button"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted/60">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 text-sm font-medium text-foreground">
|
||||
{label}
|
||||
</span>
|
||||
{trailing ? (
|
||||
<span
|
||||
className="max-w-[45%] truncate text-right text-sm text-muted-foreground"
|
||||
title={trailingTitle}
|
||||
>
|
||||
{trailing}
|
||||
</span>
|
||||
) : null}
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function useHorizontalDragScroll() {
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null);
|
||||
const didDragRef = React.useRef(false);
|
||||
const momentumFrameRef = React.useRef<number | null>(null);
|
||||
const activeListenersRef = React.useRef<{
|
||||
move: (event: PointerEvent) => void;
|
||||
up: (event: PointerEvent) => void;
|
||||
} | null>(null);
|
||||
|
||||
const stopMomentum = React.useCallback(() => {
|
||||
if (momentumFrameRef.current !== null) {
|
||||
cancelAnimationFrame(momentumFrameRef.current);
|
||||
momentumFrameRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const cleanupListeners = React.useCallback(() => {
|
||||
const active = activeListenersRef.current;
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.removeEventListener("pointermove", active.move);
|
||||
window.removeEventListener("pointerup", active.up);
|
||||
window.removeEventListener("pointercancel", active.up);
|
||||
activeListenersRef.current = null;
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
cleanupListeners();
|
||||
stopMomentum();
|
||||
};
|
||||
}, [cleanupListeners, stopMomentum]);
|
||||
|
||||
const handlePointerDown = React.useCallback(
|
||||
(event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const element = scrollRef.current;
|
||||
if (!element || event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
cleanupListeners();
|
||||
stopMomentum();
|
||||
|
||||
const startX = event.clientX;
|
||||
const startScrollLeft = element.scrollLeft;
|
||||
let lastX = event.clientX;
|
||||
let lastTime = performance.now();
|
||||
let velocity = 0;
|
||||
didDragRef.current = false;
|
||||
|
||||
const handleMove = (moveEvent: PointerEvent) => {
|
||||
const now = performance.now();
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
if (!didDragRef.current && Math.abs(deltaX) > 4) {
|
||||
didDragRef.current = true;
|
||||
}
|
||||
|
||||
if (didDragRef.current) {
|
||||
moveEvent.preventDefault();
|
||||
element.scrollLeft = startScrollLeft - deltaX;
|
||||
|
||||
const dt = now - lastTime;
|
||||
if (dt > 0) {
|
||||
velocity = -(moveEvent.clientX - lastX) / dt;
|
||||
}
|
||||
lastX = moveEvent.clientX;
|
||||
lastTime = now;
|
||||
}
|
||||
};
|
||||
|
||||
const handleUp = () => {
|
||||
cleanupListeners();
|
||||
window.setTimeout(() => {
|
||||
didDragRef.current = false;
|
||||
}, 0);
|
||||
|
||||
const minVelocity = 0.02;
|
||||
if (!didDragRef.current || Math.abs(velocity) < minVelocity) {
|
||||
return;
|
||||
}
|
||||
|
||||
let frameTime = performance.now();
|
||||
const frictionPerMs = 0.004;
|
||||
|
||||
const step = (now: number) => {
|
||||
const dt = now - frameTime;
|
||||
frameTime = now;
|
||||
|
||||
const maxScroll = element.scrollWidth - element.clientWidth;
|
||||
element.scrollLeft = Math.max(
|
||||
0,
|
||||
Math.min(maxScroll, element.scrollLeft + velocity * dt),
|
||||
);
|
||||
|
||||
if (element.scrollLeft <= 0 || element.scrollLeft >= maxScroll) {
|
||||
momentumFrameRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
velocity *= Math.exp(-frictionPerMs * dt);
|
||||
if (Math.abs(velocity) >= minVelocity) {
|
||||
momentumFrameRef.current = requestAnimationFrame(step);
|
||||
} else {
|
||||
momentumFrameRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
momentumFrameRef.current = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
activeListenersRef.current = { move: handleMove, up: handleUp };
|
||||
window.addEventListener("pointermove", handleMove);
|
||||
window.addEventListener("pointerup", handleUp);
|
||||
window.addEventListener("pointercancel", handleUp);
|
||||
},
|
||||
[cleanupListeners, stopMomentum],
|
||||
);
|
||||
|
||||
return {
|
||||
didDragRef,
|
||||
onPointerDown: handlePointerDown,
|
||||
scrollRef,
|
||||
};
|
||||
}
|
||||
|
||||
export function ProfileTabBar({
|
||||
activeTab,
|
||||
onTabChange,
|
||||
tabs,
|
||||
}: {
|
||||
activeTab: ProfilePanelTab;
|
||||
onTabChange: (tab: ProfilePanelTab) => void;
|
||||
tabs: Array<{
|
||||
id: ProfilePanelTab;
|
||||
label: string;
|
||||
trailing?: React.ReactNode;
|
||||
}>;
|
||||
}) {
|
||||
const { didDragRef, onPointerDown, scrollRef } = useHorizontalDragScroll();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="-mx-4 cursor-grab select-none overflow-x-auto px-4 scrollbar-none active:cursor-grabbing [&::-webkit-scrollbar]:hidden"
|
||||
onPointerDown={onPointerDown}
|
||||
ref={scrollRef}
|
||||
>
|
||||
<div
|
||||
aria-label="Profile sections"
|
||||
className="flex w-max min-w-full justify-center gap-1.5"
|
||||
role="tablist"
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id;
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-selected={isActive}
|
||||
className="shrink-0 rounded-full"
|
||||
data-testid={`user-profile-tab-${tab.id}`}
|
||||
key={tab.id}
|
||||
onClick={() => {
|
||||
if (didDragRef.current) {
|
||||
return;
|
||||
}
|
||||
onTabChange(tab.id);
|
||||
}}
|
||||
role="tab"
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={isActive ? "secondary" : "ghost"}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.trailing ? (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center leading-none text-2xs",
|
||||
isActive
|
||||
? "text-secondary-foreground/80"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{tab.trailing}
|
||||
</span>
|
||||
) : null}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileInfoTabContent({
|
||||
agentInfoFields,
|
||||
isArchived,
|
||||
onOpenActivity,
|
||||
pubkey,
|
||||
showActivityIngress,
|
||||
}: {
|
||||
agentInfoFields: ProfileField[];
|
||||
isArchived: boolean;
|
||||
onOpenActivity: () => void;
|
||||
pubkey: string | null;
|
||||
showActivityIngress: boolean;
|
||||
}) {
|
||||
const infoFields: ProfileField[] = isArchived
|
||||
? [
|
||||
...agentInfoFields,
|
||||
{
|
||||
displayValue: "Archived",
|
||||
icon: Archive,
|
||||
label: "Visibility",
|
||||
testId: "user-profile-archived-flair",
|
||||
trailingNode: <ArchiveStatusTooltip />,
|
||||
},
|
||||
]
|
||||
: agentInfoFields;
|
||||
const hasInfoFields = infoFields.length > 0;
|
||||
|
||||
if (!hasInfoFields && !showActivityIngress) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{showActivityIngress ? (
|
||||
<ProfileIngressRow
|
||||
icon={Wrench}
|
||||
label="Activity log"
|
||||
onClick={onOpenActivity}
|
||||
testId={`user-profile-view-activity-${pubkey}`}
|
||||
trailing="View"
|
||||
/>
|
||||
) : null}
|
||||
{hasInfoFields ? <ProfileFieldGroup fields={infoFields} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ArchiveStatusTooltip() {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
aria-label="What archived means"
|
||||
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
data-testid="user-profile-archived-info"
|
||||
type="button"
|
||||
>
|
||||
<Info className="h-4 w-4" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="end" className="max-w-72 text-left" side="top">
|
||||
<p className="text-sm">
|
||||
Archived agents do not appear in search, autocomplete, or member-add
|
||||
flows in this space. You can unarchive them at any time.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileRuntimeTabContent({
|
||||
agentInstruction,
|
||||
diagnosticsFields,
|
||||
diagnosticsSummary,
|
||||
managedAgent,
|
||||
modelLabel,
|
||||
onOpenDiagnostics,
|
||||
onOpenInstructions,
|
||||
runtimeConfigurationFields,
|
||||
runtimeSettingsFields,
|
||||
showDiagnosticsIngress,
|
||||
showInstructionBlock,
|
||||
}: {
|
||||
agentInstruction: string | null;
|
||||
diagnosticsFields: ProfileField[];
|
||||
diagnosticsSummary: React.ReactNode;
|
||||
managedAgent: ManagedAgent | undefined;
|
||||
modelLabel: string;
|
||||
onOpenDiagnostics: () => void;
|
||||
onOpenInstructions: () => void;
|
||||
runtimeConfigurationFields: ProfileField[];
|
||||
runtimeSettingsFields: ProfileField[];
|
||||
showDiagnosticsIngress: boolean;
|
||||
showInstructionBlock: boolean;
|
||||
}) {
|
||||
const statusDiagnosticsFields = diagnosticsFields.filter(
|
||||
(field) => field.label === "Status",
|
||||
);
|
||||
const detailDiagnosticsFields = diagnosticsFields.filter(
|
||||
(field) => field.label !== "Last error" && field.label !== "Status",
|
||||
);
|
||||
const hasRuntimeRows =
|
||||
runtimeConfigurationFields.length > 0 ||
|
||||
runtimeSettingsFields.length > 0 ||
|
||||
managedAgent !== undefined ||
|
||||
modelLabel.trim().length > 0;
|
||||
|
||||
if (
|
||||
!hasRuntimeRows &&
|
||||
statusDiagnosticsFields.length === 0 &&
|
||||
detailDiagnosticsFields.length === 0 &&
|
||||
!showDiagnosticsIngress &&
|
||||
!showInstructionBlock
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{showInstructionBlock ? (
|
||||
<div className="overflow-hidden rounded-2xl bg-muted/20">
|
||||
<AgentInstructionRow
|
||||
instruction={agentInstruction}
|
||||
onOpenInstructions={onOpenInstructions}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{statusDiagnosticsFields.length > 0 ? (
|
||||
<ProfileFieldGroup fields={statusDiagnosticsFields} />
|
||||
) : null}
|
||||
{showDiagnosticsIngress ? (
|
||||
<ProfileIngressRow
|
||||
icon={Activity}
|
||||
label="Harness Log"
|
||||
onClick={onOpenDiagnostics}
|
||||
testId="user-profile-diagnostics-ingress"
|
||||
trailing={diagnosticsSummary}
|
||||
/>
|
||||
) : null}
|
||||
{hasRuntimeRows ? (
|
||||
<div className="overflow-hidden rounded-2xl bg-muted/20">
|
||||
<AgentDetailsRows
|
||||
fields={runtimeConfigurationFields}
|
||||
managedAgent={managedAgent}
|
||||
modelLabel={modelLabel}
|
||||
showModel={true}
|
||||
/>
|
||||
{runtimeSettingsFields.length > 0 ? (
|
||||
<ProfileFieldRows fields={runtimeSettingsFields} />
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{detailDiagnosticsFields.length > 0 ? (
|
||||
<ProfileFieldGroup fields={detailDiagnosticsFields} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
parseProfilePanelTab,
|
||||
parseProfilePanelView,
|
||||
personaManagedAgentUpdate,
|
||||
profilePanelTabFromSearch,
|
||||
profilePanelViewFromSearch,
|
||||
} from "./UserProfilePanelUtils.ts";
|
||||
|
||||
function agent(overrides = {}) {
|
||||
return {
|
||||
pubkey: "deadbeef".repeat(8),
|
||||
name: "Fizz",
|
||||
personaId: "persona-1",
|
||||
relayUrl: "ws://localhost:3000",
|
||||
acpCommand: "buzz-acp",
|
||||
agentCommand: "goose",
|
||||
agentArgs: [],
|
||||
mcpCommand: "",
|
||||
turnTimeoutSeconds: 320,
|
||||
idleTimeoutSeconds: null,
|
||||
maxTurnDurationSeconds: null,
|
||||
parallelism: 1,
|
||||
systemPrompt: "Old prompt",
|
||||
avatarUrl: "app-avatar://old",
|
||||
model: "old-model",
|
||||
mcpToolsets: null,
|
||||
envVars: { OLD_KEY: "1" },
|
||||
status: "stopped",
|
||||
pid: null,
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
lastStartedAt: null,
|
||||
lastStoppedAt: null,
|
||||
lastExitCode: null,
|
||||
lastError: null,
|
||||
logPath: null,
|
||||
startOnAppLaunch: true,
|
||||
backend: { type: "local" },
|
||||
backendAgentId: null,
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function persona(overrides = {}) {
|
||||
return {
|
||||
id: "persona-1",
|
||||
displayName: "Fizz Prime",
|
||||
avatarUrl: null,
|
||||
systemPrompt: "New prompt",
|
||||
runtime: "goose",
|
||||
model: "new-model",
|
||||
provider: null,
|
||||
namePool: [],
|
||||
isBuiltIn: false,
|
||||
isActive: true,
|
||||
envVars: { NEW_KEY: "2" },
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
updatedAt: "2026-01-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function runtime(overrides = {}) {
|
||||
return {
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
avatarUrl: "app-avatar://claude",
|
||||
availability: "available",
|
||||
command: "claude",
|
||||
binaryPath: "/usr/local/bin/claude",
|
||||
defaultArgs: ["mcp", "serve"],
|
||||
mcpCommand: "claude-mcp",
|
||||
installHint: "",
|
||||
installInstructionsUrl: "",
|
||||
canAutoInstall: false,
|
||||
underlyingCliPath: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("personaManagedAgentUpdate syncs edited persona identity to linked agent", () => {
|
||||
assert.deepEqual(personaManagedAgentUpdate(agent(), persona()), {
|
||||
pubkey: "deadbeef".repeat(8),
|
||||
name: "Fizz Prime",
|
||||
systemPrompt: "New prompt",
|
||||
model: "new-model",
|
||||
envVars: { NEW_KEY: "2" },
|
||||
});
|
||||
});
|
||||
|
||||
test("personaManagedAgentUpdate skips unrelated or unchanged agents", () => {
|
||||
assert.equal(
|
||||
personaManagedAgentUpdate(agent({ personaId: "persona-2" }), persona()),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
personaManagedAgentUpdate(
|
||||
agent({
|
||||
name: "Fizz Prime",
|
||||
avatarUrl: null,
|
||||
systemPrompt: "New prompt",
|
||||
model: "new-model",
|
||||
envVars: { NEW_KEY: "2" },
|
||||
}),
|
||||
persona(),
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("personaManagedAgentUpdate maps changed persona runtime to linked agent commands", () => {
|
||||
assert.deepEqual(
|
||||
personaManagedAgentUpdate(agent(), persona({ runtime: "claude" }), {
|
||||
previousPersona: persona({ runtime: "goose" }),
|
||||
runtimes: [runtime()],
|
||||
}),
|
||||
{
|
||||
pubkey: "deadbeef".repeat(8),
|
||||
name: "Fizz Prime",
|
||||
systemPrompt: "New prompt",
|
||||
model: "new-model",
|
||||
envVars: { NEW_KEY: "2" },
|
||||
agentCommand: "claude",
|
||||
agentArgs: ["mcp", "serve"],
|
||||
mcpCommand: "claude-mcp",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("personaManagedAgentUpdate leaves runtime fields alone when runtime is unchanged", () => {
|
||||
assert.equal(
|
||||
personaManagedAgentUpdate(
|
||||
agent({
|
||||
name: "Fizz Prime",
|
||||
avatarUrl: null,
|
||||
systemPrompt: "New prompt",
|
||||
model: "new-model",
|
||||
envVars: { NEW_KEY: "2" },
|
||||
agentArgs: ["custom"],
|
||||
}),
|
||||
persona({ runtime: "goose" }),
|
||||
{
|
||||
previousPersona: persona({ runtime: "goose" }),
|
||||
runtimes: [runtime({ id: "goose", command: "goose" })],
|
||||
},
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseProfilePanelView accepts all profile panel subviews", () => {
|
||||
for (const view of [
|
||||
"summary",
|
||||
"info",
|
||||
"configuration",
|
||||
"diagnostics",
|
||||
"memories",
|
||||
"channels",
|
||||
"logs",
|
||||
]) {
|
||||
assert.equal(parseProfilePanelView(view), view);
|
||||
}
|
||||
});
|
||||
|
||||
test("parseProfilePanelView maps legacy agent config subviews to configuration", () => {
|
||||
for (const view of ["model", "settings"]) {
|
||||
assert.equal(parseProfilePanelView(view), "configuration");
|
||||
}
|
||||
});
|
||||
|
||||
test("profilePanelViewFromSearch falls back to summary for invalid values", () => {
|
||||
assert.equal(parseProfilePanelView("missing"), null);
|
||||
assert.equal(profilePanelViewFromSearch("missing"), "summary");
|
||||
assert.equal(profilePanelViewFromSearch(null), "summary");
|
||||
});
|
||||
|
||||
test("parseProfilePanelTab accepts profile summary tabs", () => {
|
||||
for (const tab of ["info", "runtime", "channels", "memories"]) {
|
||||
assert.equal(parseProfilePanelTab(tab), tab);
|
||||
}
|
||||
});
|
||||
|
||||
test("profilePanelTabFromSearch falls back to info for invalid values", () => {
|
||||
assert.equal(parseProfilePanelTab("missing"), null);
|
||||
assert.equal(profilePanelTabFromSearch("missing"), "info");
|
||||
assert.equal(profilePanelTabFromSearch(null), "info");
|
||||
});
|
||||
@@ -0,0 +1,356 @@
|
||||
import * as React from "react";
|
||||
import type {
|
||||
AcpRuntimeCatalogEntry,
|
||||
AgentPersona,
|
||||
Channel,
|
||||
ManagedAgent,
|
||||
Profile,
|
||||
RelayAgent,
|
||||
UpdateManagedAgentInput,
|
||||
} from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { truncatePubkey } from "@/features/profile/lib/identity";
|
||||
|
||||
export { truncatePubkey };
|
||||
|
||||
export type ProfileChannelLink = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type ProfilePanelView =
|
||||
| "summary"
|
||||
| "instructions"
|
||||
| "info"
|
||||
| "configuration"
|
||||
| "diagnostics"
|
||||
| "memories"
|
||||
| "channels"
|
||||
| "logs";
|
||||
|
||||
export type ProfilePanelTab = "info" | "runtime" | "channels" | "memories";
|
||||
|
||||
export const PROFILE_PANEL_VIEW_TITLES: Record<ProfilePanelView, string> = {
|
||||
summary: "Profile",
|
||||
instructions: "Instructions",
|
||||
info: "Agent info",
|
||||
configuration: "Runtime",
|
||||
diagnostics: "Harness Log",
|
||||
memories: "Memories",
|
||||
channels: "Channels",
|
||||
logs: "Harness Log",
|
||||
};
|
||||
|
||||
const PROFILE_PANEL_VIEWS = new Set<ProfilePanelView>(
|
||||
Object.keys(PROFILE_PANEL_VIEW_TITLES) as ProfilePanelView[],
|
||||
);
|
||||
|
||||
const PROFILE_PANEL_TABS = new Set<ProfilePanelTab>([
|
||||
"info",
|
||||
"runtime",
|
||||
"channels",
|
||||
"memories",
|
||||
]);
|
||||
|
||||
const LEGACY_PROFILE_PANEL_VIEW_ALIASES: Record<string, ProfilePanelView> = {
|
||||
model: "configuration",
|
||||
settings: "configuration",
|
||||
};
|
||||
|
||||
export function parseProfilePanelView(value: unknown): ProfilePanelView | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (PROFILE_PANEL_VIEWS.has(value as ProfilePanelView)) {
|
||||
return value as ProfilePanelView;
|
||||
}
|
||||
|
||||
return LEGACY_PROFILE_PANEL_VIEW_ALIASES[value] ?? null;
|
||||
}
|
||||
|
||||
export function profilePanelViewFromSearch(value: unknown): ProfilePanelView {
|
||||
return parseProfilePanelView(value) ?? "summary";
|
||||
}
|
||||
|
||||
export function parseProfilePanelTab(value: unknown): ProfilePanelTab | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (PROFILE_PANEL_TABS.has(value as ProfilePanelTab)) {
|
||||
return value as ProfilePanelTab;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function profilePanelTabFromSearch(value: unknown): ProfilePanelTab {
|
||||
return parseProfilePanelTab(value) ?? "info";
|
||||
}
|
||||
|
||||
export type UserProfilePanelProps = {
|
||||
canResetWidth?: boolean;
|
||||
currentPubkey?: string;
|
||||
isSinglePanelView?: boolean;
|
||||
layout?: "standalone" | "split";
|
||||
onClose: () => void;
|
||||
onOpenDm?: (pubkeys: string[]) => Promise<void> | void;
|
||||
onOpenProfile?: (pubkey: string) => void;
|
||||
onResetWidth?: () => void;
|
||||
onResizeStart?: (event: React.PointerEvent<HTMLButtonElement>) => void;
|
||||
onTabChange?: (tab: ProfilePanelTab, options?: { replace?: boolean }) => void;
|
||||
onViewChange?: (
|
||||
view: ProfilePanelView,
|
||||
options?: { replace?: boolean },
|
||||
) => void;
|
||||
persona?: AgentPersona;
|
||||
pubkey?: string;
|
||||
splitPaneClamp?: boolean;
|
||||
tab?: ProfilePanelTab;
|
||||
view?: ProfilePanelView;
|
||||
widthPx: number;
|
||||
};
|
||||
|
||||
export function deriveProfileChannels(
|
||||
pubkeyLower: string,
|
||||
relayAgent: RelayAgent | undefined,
|
||||
managedAgent: ManagedAgent | undefined,
|
||||
channels: Channel[] | undefined,
|
||||
): ProfileChannelLink[] {
|
||||
const links = new Map<string, ProfileChannelLink>();
|
||||
const channelsByName = new Map(
|
||||
channels?.map((channel) => [channel.name, channel]) ?? [],
|
||||
);
|
||||
|
||||
relayAgent?.channels.forEach((name, index) => {
|
||||
const channel = channelsByName.get(name);
|
||||
const id = relayAgent.channelIds[index] ?? channel?.id ?? name;
|
||||
links.set(id, { id, name });
|
||||
});
|
||||
|
||||
if (managedAgent && channels) {
|
||||
for (const channel of channels) {
|
||||
const isMember = channel.memberPubkeys.some(
|
||||
(memberPubkey) => memberPubkey.toLowerCase() === pubkeyLower,
|
||||
);
|
||||
if (isMember) {
|
||||
links.set(channel.id, { id: channel.id, name: channel.name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...links.values()].sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
}
|
||||
|
||||
export function getRelayAgentChannelIds(
|
||||
relayAgents: readonly RelayAgent[] | undefined,
|
||||
agentPubkey: string,
|
||||
): string[] {
|
||||
const normalized = normalizePubkey(agentPubkey);
|
||||
const agent = (relayAgents ?? []).find(
|
||||
(candidate) => normalizePubkey(candidate.pubkey) === normalized,
|
||||
);
|
||||
return agent?.channelIds ?? [];
|
||||
}
|
||||
|
||||
export function buildPersonaDraftProfile(persona: AgentPersona): Profile {
|
||||
return {
|
||||
pubkey: "",
|
||||
displayName: persona.displayName,
|
||||
avatarUrl: persona.avatarUrl,
|
||||
about: null,
|
||||
nip05Handle: null,
|
||||
ownerPubkey: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePanelProfile({
|
||||
persona,
|
||||
profile,
|
||||
}: {
|
||||
managedAgent: ManagedAgent | undefined;
|
||||
persona: AgentPersona | undefined;
|
||||
profile: Profile | undefined;
|
||||
}): Profile | undefined {
|
||||
const baseProfile =
|
||||
profile ?? (persona ? buildPersonaDraftProfile(persona) : undefined);
|
||||
return withProfileAvatarFallback(baseProfile, [persona?.avatarUrl]);
|
||||
}
|
||||
|
||||
export function resolveProfileAvatarUrl(
|
||||
...candidates: Array<string | null | undefined>
|
||||
): string | null {
|
||||
for (const candidate of candidates) {
|
||||
const trimmed = candidate?.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function withProfileAvatarFallback(
|
||||
profile: Profile | undefined,
|
||||
fallbackAvatarUrls: Array<string | null | undefined>,
|
||||
): Profile | undefined {
|
||||
const profileAvatarUrl = normalizeProfileFallbackAvatarUrl(
|
||||
profile?.avatarUrl,
|
||||
);
|
||||
const avatarUrl = resolveProfileAvatarUrl(
|
||||
profileAvatarUrl,
|
||||
...fallbackAvatarUrls.map((avatarUrl) =>
|
||||
normalizeProfileFallbackAvatarUrl(avatarUrl),
|
||||
),
|
||||
);
|
||||
return profile && avatarUrl !== profile.avatarUrl
|
||||
? { ...profile, avatarUrl }
|
||||
: profile;
|
||||
}
|
||||
|
||||
function normalizeProfileFallbackAvatarUrl(
|
||||
avatarUrl: string | null | undefined,
|
||||
): string | null {
|
||||
const trimmed = avatarUrl?.trim();
|
||||
if (!trimmed) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function resolveProfileDisplayName({
|
||||
persona,
|
||||
profile,
|
||||
pubkey,
|
||||
}: {
|
||||
persona: AgentPersona | undefined;
|
||||
profile: Profile | undefined;
|
||||
pubkey: string | null;
|
||||
}) {
|
||||
return (
|
||||
profile?.displayName ??
|
||||
persona?.displayName ??
|
||||
(pubkey ? truncatePubkey(pubkey) : "Agent")
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveOwnerHandle(
|
||||
profile: Profile | undefined,
|
||||
currentPubkey: string | undefined,
|
||||
) {
|
||||
if (currentPubkey === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
profile?.nip05Handle?.trim() ||
|
||||
profile?.displayName?.trim() ||
|
||||
truncatePubkey(currentPubkey)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveAgentInstruction(
|
||||
managedAgent: ManagedAgent | undefined,
|
||||
persona: AgentPersona | undefined,
|
||||
) {
|
||||
return (
|
||||
managedAgent?.systemPrompt?.trim() || persona?.systemPrompt.trim() || null
|
||||
);
|
||||
}
|
||||
|
||||
export function personaManagedAgentUpdate(
|
||||
agent: ManagedAgent,
|
||||
persona: AgentPersona,
|
||||
options: {
|
||||
previousPersona?: AgentPersona;
|
||||
runtimes?: readonly AcpRuntimeCatalogEntry[];
|
||||
} = {},
|
||||
): UpdateManagedAgentInput | null {
|
||||
if (agent.personaId !== persona.id) return null;
|
||||
|
||||
const input: UpdateManagedAgentInput = { pubkey: agent.pubkey };
|
||||
let hasChanges = false;
|
||||
|
||||
if (persona.displayName !== agent.name) {
|
||||
input.name = persona.displayName;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (persona.systemPrompt !== (agent.systemPrompt ?? "")) {
|
||||
input.systemPrompt = persona.systemPrompt;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if ((persona.model ?? null) !== (agent.model ?? null)) {
|
||||
input.model = persona.model;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (!stringRecordEqual(persona.envVars, agent.envVars)) {
|
||||
input.envVars = persona.envVars;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
const runtimeChanged =
|
||||
options.previousPersona !== undefined &&
|
||||
options.previousPersona.runtime !== persona.runtime;
|
||||
const runtime = runtimeChanged
|
||||
? options.runtimes?.find((candidate) => candidate.id === persona.runtime)
|
||||
: undefined;
|
||||
if (runtime?.command) {
|
||||
if (runtime.command !== agent.agentCommand) {
|
||||
input.agentCommand = runtime.command;
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
if (!stringArrayEqual(runtime.defaultArgs, agent.agentArgs)) {
|
||||
input.agentArgs = [...runtime.defaultArgs];
|
||||
hasChanges = true;
|
||||
}
|
||||
|
||||
const mcpCommand = runtime.mcpCommand ?? "";
|
||||
if (mcpCommand !== agent.mcpCommand) {
|
||||
input.mcpCommand = mcpCommand;
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasChanges ? input : null;
|
||||
}
|
||||
|
||||
function stringArrayEqual(left: readonly string[], right: readonly string[]) {
|
||||
if (left.length !== right.length) return false;
|
||||
|
||||
return left.every((value, index) => value === right[index]);
|
||||
}
|
||||
|
||||
function stringRecordEqual(
|
||||
left: Record<string, string>,
|
||||
right: Record<string, string>,
|
||||
) {
|
||||
const leftKeys = Object.keys(left);
|
||||
const rightKeys = Object.keys(right);
|
||||
if (leftKeys.length !== rightKeys.length) return false;
|
||||
|
||||
return leftKeys.every((key) => left[key] === right[key]);
|
||||
}
|
||||
|
||||
export function useRetainedPersona(
|
||||
sourcePersona: AgentPersona | undefined,
|
||||
profileIdentityKey: string,
|
||||
) {
|
||||
const [retainedPersona, setRetainedPersona] = React.useState<{
|
||||
key: string;
|
||||
persona: AgentPersona;
|
||||
} | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!sourcePersona) return;
|
||||
setRetainedPersona({ key: profileIdentityKey, persona: sourcePersona });
|
||||
}, [profileIdentityKey, sourcePersona]);
|
||||
|
||||
return (
|
||||
sourcePersona ??
|
||||
(retainedPersona?.key === profileIdentityKey
|
||||
? retainedPersona.persona
|
||||
: undefined)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type {
|
||||
AcpRuntimeCatalogEntry,
|
||||
AgentPersona,
|
||||
CreatePersonaInput,
|
||||
UpdatePersonaInput,
|
||||
} from "@/shared/api/types";
|
||||
import { PersonaDeleteDialog } from "@/features/agents/ui/PersonaDeleteDialog";
|
||||
import { PersonaDialog } from "@/features/agents/ui/PersonaDialog";
|
||||
import type { PersonaDialogState } from "@/features/agents/ui/personaDialogState";
|
||||
|
||||
export function UserProfilePersonaDialogs({
|
||||
createError,
|
||||
isPending,
|
||||
personaDialogState,
|
||||
personaToDelete,
|
||||
runtimes,
|
||||
runtimesLoading,
|
||||
updateError,
|
||||
onCloseDelete,
|
||||
onCloseDialog,
|
||||
onConfirmDelete,
|
||||
onSubmit,
|
||||
}: {
|
||||
createError: Error | null;
|
||||
isPending: boolean;
|
||||
personaDialogState: PersonaDialogState | null;
|
||||
personaToDelete: AgentPersona | null;
|
||||
runtimes: AcpRuntimeCatalogEntry[];
|
||||
runtimesLoading: boolean;
|
||||
updateError: Error | null;
|
||||
onCloseDelete: () => void;
|
||||
onCloseDialog: () => void;
|
||||
onConfirmDelete: (persona: AgentPersona) => void;
|
||||
onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<PersonaDialog
|
||||
description={personaDialogState?.description ?? ""}
|
||||
error={updateError ?? createError}
|
||||
initialValues={personaDialogState?.initialValues ?? null}
|
||||
isPending={isPending}
|
||||
runtimes={runtimes}
|
||||
runtimesLoading={runtimesLoading}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onCloseDialog();
|
||||
}
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
open={personaDialogState !== null}
|
||||
submitLabel={personaDialogState?.submitLabel ?? "Save"}
|
||||
title={personaDialogState?.title ?? "Agent"}
|
||||
/>
|
||||
<PersonaDeleteDialog
|
||||
onConfirm={onConfirmDelete}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onCloseDelete();
|
||||
}
|
||||
}}
|
||||
open={personaToDelete !== null}
|
||||
persona={personaToDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { useIsManagedAgent } from "@/features/agent-memory/hooks";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore";
|
||||
import { truncatePubkey } from "@/features/profile/lib/identity";
|
||||
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
|
||||
import { usePresenceQuery } from "@/features/presence/hooks";
|
||||
import { useUserStatusQuery } from "@/features/user-status/hooks";
|
||||
@@ -56,14 +57,6 @@ function InfoBadge({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function truncatePubkey(pubkey: string) {
|
||||
if (pubkey.length <= 16) {
|
||||
return pubkey;
|
||||
}
|
||||
|
||||
return `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}`;
|
||||
}
|
||||
|
||||
export function UserProfilePopover({
|
||||
children,
|
||||
pubkey,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as React from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type UseProfileDmActionOptions = {
|
||||
effectivePubkey: string | null;
|
||||
onClose: () => void;
|
||||
onOpenDm?: (pubkeys: string[]) => Promise<void> | void;
|
||||
};
|
||||
|
||||
export function useProfileDmAction({
|
||||
effectivePubkey,
|
||||
onClose,
|
||||
onOpenDm,
|
||||
}: UseProfileDmActionOptions) {
|
||||
const isMountedRef = React.useRef(false);
|
||||
const [isOpeningDm, setIsOpeningDm] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleMessage = React.useCallback(async () => {
|
||||
if (!effectivePubkey || !onOpenDm || isOpeningDm) return;
|
||||
|
||||
setIsOpeningDm(true);
|
||||
|
||||
try {
|
||||
await onOpenDm([effectivePubkey]);
|
||||
} catch (error) {
|
||||
if (!isMountedRef.current) return;
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to open direct message.",
|
||||
);
|
||||
setIsOpeningDm(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
setIsOpeningDm(false);
|
||||
onClose();
|
||||
}, [effectivePubkey, isOpeningDm, onClose, onOpenDm]);
|
||||
|
||||
return { handleMessage, isOpeningDm };
|
||||
}
|
||||
@@ -3,31 +3,39 @@ import * as React from "react";
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useOpenDmMutation } from "@/features/channels/hooks";
|
||||
import {
|
||||
type ProfilePanelTab,
|
||||
type ProfilePanelView,
|
||||
UserProfilePanel,
|
||||
} from "@/features/profile/ui/UserProfilePanel";
|
||||
import {
|
||||
profilePanelTabFromSearch,
|
||||
profilePanelViewFromSearch,
|
||||
} from "@/features/profile/ui/UserProfilePanelUtils";
|
||||
import { PulseView } from "@/features/pulse/ui/PulseView";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext";
|
||||
import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState";
|
||||
import { useThreadPanelWidth } from "@/shared/hooks/useThreadPanelWidth";
|
||||
|
||||
const PULSE_PANEL_SEARCH_KEYS = ["profile", "profileView"] as const;
|
||||
const PULSE_PANEL_SEARCH_KEYS = [
|
||||
"profile",
|
||||
"profileTab",
|
||||
"profileView",
|
||||
] as const;
|
||||
|
||||
export function PulseScreen() {
|
||||
const identityQuery = useIdentityQuery();
|
||||
const { applyPatch, values } = useHistorySearchState(PULSE_PANEL_SEARCH_KEYS);
|
||||
const profilePanelPubkey = values.profile;
|
||||
const profilePanelView: ProfilePanelView =
|
||||
values.profileView === "memories" || values.profileView === "channels"
|
||||
? values.profileView
|
||||
: "summary";
|
||||
const profilePanelTab = profilePanelTabFromSearch(values.profileTab);
|
||||
const profilePanelView = profilePanelViewFromSearch(values.profileView);
|
||||
const handleOpenProfilePanel = React.useCallback(
|
||||
(pubkey: string) => applyPatch({ profile: pubkey, profileView: null }),
|
||||
(pubkey: string) =>
|
||||
applyPatch({ profile: pubkey, profileTab: null, profileView: null }),
|
||||
[applyPatch],
|
||||
);
|
||||
const handleCloseProfilePanel = React.useCallback(
|
||||
() => applyPatch({ profile: null, profileView: null }),
|
||||
() => applyPatch({ profile: null, profileTab: null, profileView: null }),
|
||||
[applyPatch],
|
||||
);
|
||||
const handleProfilePanelViewChange = React.useCallback(
|
||||
@@ -35,6 +43,11 @@ export function PulseScreen() {
|
||||
applyPatch({ profileView: view === "summary" ? null : view }, options),
|
||||
[applyPatch],
|
||||
);
|
||||
const handleProfilePanelTabChange = React.useCallback(
|
||||
(tab: ProfilePanelTab, options?: { replace?: boolean }) =>
|
||||
applyPatch({ profileTab: tab === "info" ? null : tab }, options),
|
||||
[applyPatch],
|
||||
);
|
||||
const threadPanelWidth = useThreadPanelWidth();
|
||||
const openDmMutation = useOpenDmMutation();
|
||||
const { goChannel } = useAppNavigation();
|
||||
@@ -62,8 +75,10 @@ export function PulseScreen() {
|
||||
onOpenProfile={handleOpenProfilePanel}
|
||||
onResetWidth={threadPanelWidth.onResetWidth}
|
||||
onResizeStart={threadPanelWidth.onResizeStart}
|
||||
onTabChange={handleProfilePanelTabChange}
|
||||
onViewChange={handleProfilePanelViewChange}
|
||||
pubkey={profilePanelPubkey}
|
||||
tab={profilePanelTab}
|
||||
view={profilePanelView}
|
||||
widthPx={threadPanelWidth.widthPx}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
type ActiveChannelTurnSummary,
|
||||
useActiveAgentTurnsBridge,
|
||||
useActiveAgentTurnsByChannel,
|
||||
} from "@/features/agents/activeAgentTurnsStore";
|
||||
import { useManagedAgentsQuery } from "@/features/agents/hooks";
|
||||
import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore";
|
||||
|
||||
export function useActiveWorkingChannelsById(): ReadonlyMap<
|
||||
string,
|
||||
ActiveChannelTurnSummary
|
||||
> {
|
||||
const managedAgentsQuery = useManagedAgentsQuery();
|
||||
const managedAgents = React.useMemo(
|
||||
() => managedAgentsQuery.data ?? [],
|
||||
[managedAgentsQuery.data],
|
||||
);
|
||||
|
||||
useManagedAgentObserverBridge(managedAgents);
|
||||
useActiveAgentTurnsBridge(managedAgents);
|
||||
|
||||
const activeWorkingChannels = useActiveAgentTurnsByChannel();
|
||||
return React.useMemo(
|
||||
() =>
|
||||
new Map(
|
||||
activeWorkingChannels.map((summary) => [summary.channelId, summary]),
|
||||
),
|
||||
[activeWorkingChannels],
|
||||
);
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
useChannelSections,
|
||||
type ChannelSection,
|
||||
} from "@/features/sidebar/lib/useChannelSections";
|
||||
import { useActiveWorkingChannelsById } from "@/features/sidebar/lib/useActiveWorkingChannelsById";
|
||||
import { useDmSidebarMetadata } from "@/features/sidebar/useDmSidebarMetadata";
|
||||
import { sortDmChannelsByLabel } from "@/features/sidebar/lib/dmSidebarSort";
|
||||
import { useSidebarScrollLock } from "@/features/sidebar/lib/useSidebarScrollLock";
|
||||
@@ -232,6 +233,7 @@ export function AppSidebar({
|
||||
onStarChannel,
|
||||
onUnstarChannel,
|
||||
}: AppSidebarProps) {
|
||||
const activeWorkingByChannelId = useActiveWorkingChannelsById();
|
||||
const { status: updateStatus } = useUpdaterContext();
|
||||
const canShowSidebarUpdateCard = shouldShowSidebarUpdateCard(updateStatus);
|
||||
const sidebarRelayConnectionCard = useSidebarRelayConnectionCard(
|
||||
@@ -656,6 +658,7 @@ export function AppSidebar({
|
||||
)}
|
||||
isCollapsed={collapsedGroups.starred}
|
||||
isActiveChannel={selectedView === "channel"}
|
||||
activeWorkingByChannelId={activeWorkingByChannelId}
|
||||
items={starredChannels}
|
||||
listTestId="starred-list"
|
||||
onMarkAllRead={() => {
|
||||
@@ -700,6 +703,7 @@ export function AppSidebar({
|
||||
}
|
||||
isCollapsed={collapsedSections[section.id] ?? false}
|
||||
isActiveChannel={selectedView === "channel"}
|
||||
activeWorkingByChannelId={activeWorkingByChannelId}
|
||||
selectedChannelId={selectedChannelId}
|
||||
unreadChannelCounts={unreadChannelCounts}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
@@ -741,6 +745,7 @@ export function AppSidebar({
|
||||
hasUnread={unreadChannelIds.size > 0}
|
||||
isCollapsed={collapsedGroups.channels}
|
||||
isActiveChannel={selectedView === "channel"}
|
||||
activeWorkingByChannelId={activeWorkingByChannelId}
|
||||
items={sectionBuckets.unassigned}
|
||||
listTestId="stream-list"
|
||||
onBrowseClick={onBrowseChannels}
|
||||
@@ -774,6 +779,7 @@ export function AppSidebar({
|
||||
hasUnread={unreadChannelIds.size > 0}
|
||||
isCollapsed={collapsedGroups.forums}
|
||||
isActiveChannel={selectedView === "channel"}
|
||||
activeWorkingByChannelId={activeWorkingByChannelId}
|
||||
items={forumChannels}
|
||||
listTestId="forum-list"
|
||||
onCreateClick={() => openCreateDialog("forum")}
|
||||
@@ -812,6 +818,7 @@ export function AppSidebar({
|
||||
dmParticipantsByChannelId={dmParticipantsByChannelId}
|
||||
isCollapsed={collapsedGroups.directMessages}
|
||||
isActiveChannel={selectedView === "channel"}
|
||||
activeWorkingByChannelId={activeWorkingByChannelId}
|
||||
items={sortedDirectMessages}
|
||||
channelLabels={dmChannelLabels}
|
||||
onHideDm={onHideDm}
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
SECTION_ACTION_VISIBILITY_CLASS,
|
||||
SECTION_ICON_BUTTON_CLASS,
|
||||
} from "@/features/sidebar/ui/sidebarSectionStyles";
|
||||
import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore";
|
||||
import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
@@ -328,6 +329,7 @@ export function ChannelGroupSection({
|
||||
hasUnread,
|
||||
isCollapsed,
|
||||
isActiveChannel,
|
||||
activeWorkingByChannelId,
|
||||
items,
|
||||
listTestId,
|
||||
onBrowseClick,
|
||||
@@ -360,6 +362,7 @@ export function ChannelGroupSection({
|
||||
groupClassName?: string;
|
||||
isCollapsed: boolean;
|
||||
isActiveChannel: boolean;
|
||||
activeWorkingByChannelId?: ReadonlyMap<string, ActiveChannelTurnSummary>;
|
||||
items: Channel[];
|
||||
listTestId: string;
|
||||
onBrowseClick?: () => void;
|
||||
@@ -403,6 +406,7 @@ export function ChannelGroupSection({
|
||||
<DraggableChannelRow channelId={channel.id}>
|
||||
<ChannelMenuButton
|
||||
channel={channel}
|
||||
activeWorking={activeWorkingByChannelId?.get(channel.id)}
|
||||
hasUnread={unreadChannelIds.has(channel.id)}
|
||||
unreadCount={unreadChannelCounts.get(channel.id) ?? 0}
|
||||
isMuted={mutedChannelIds?.has(channel.id)}
|
||||
@@ -415,6 +419,7 @@ export function ChannelGroupSection({
|
||||
) : (
|
||||
<ChannelMenuButton
|
||||
channel={channel}
|
||||
activeWorking={activeWorkingByChannelId?.get(channel.id)}
|
||||
hasUnread={unreadChannelIds.has(channel.id)}
|
||||
unreadCount={unreadChannelCounts.get(channel.id) ?? 0}
|
||||
isMuted={mutedChannelIds?.has(channel.id)}
|
||||
@@ -503,6 +508,7 @@ export function CustomChannelSection({
|
||||
hasUnread,
|
||||
isCollapsed,
|
||||
isActiveChannel,
|
||||
activeWorkingByChannelId,
|
||||
selectedChannelId,
|
||||
unreadChannelCounts,
|
||||
unreadChannelIds,
|
||||
@@ -535,6 +541,7 @@ export function CustomChannelSection({
|
||||
hasUnread: boolean;
|
||||
isCollapsed: boolean;
|
||||
isActiveChannel: boolean;
|
||||
activeWorkingByChannelId?: ReadonlyMap<string, ActiveChannelTurnSummary>;
|
||||
selectedChannelId: string | null;
|
||||
unreadChannelCounts: ReadonlyMap<string, number>;
|
||||
unreadChannelIds: ReadonlySet<string>;
|
||||
@@ -684,6 +691,9 @@ export function CustomChannelSection({
|
||||
<DraggableChannelRow channelId={channel.id}>
|
||||
<ChannelMenuButton
|
||||
channel={channel}
|
||||
activeWorking={activeWorkingByChannelId?.get(
|
||||
channel.id,
|
||||
)}
|
||||
hasUnread={unreadChannelIds.has(channel.id)}
|
||||
unreadCount={
|
||||
unreadChannelCounts.get(channel.id) ?? 0
|
||||
|
||||
@@ -16,11 +16,14 @@ import {
|
||||
} from "@/shared/ui/context-menu";
|
||||
|
||||
import { ChannelContextMenuItems } from "@/features/sidebar/ui/CustomChannelSection";
|
||||
import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore";
|
||||
import { formatElapsed } from "@/features/agents/ui/agentSessionUtils";
|
||||
import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel";
|
||||
import { EphemeralChannelBadge } from "@/features/channels/ui/EphemeralChannelBadge";
|
||||
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
|
||||
import type { Channel, PresenceStatus } from "@/shared/api/types";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { useNow } from "@/shared/lib/useNow";
|
||||
import {
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
@@ -89,6 +92,38 @@ function UnreadDotBadge({
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelWorkingBadge({
|
||||
channelName,
|
||||
isActive,
|
||||
summary,
|
||||
}: {
|
||||
channelName: string;
|
||||
isActive: boolean;
|
||||
summary: ActiveChannelTurnSummary;
|
||||
}) {
|
||||
const now = useNow(1000);
|
||||
const elapsed = formatElapsed(now - summary.anchorAt);
|
||||
const label =
|
||||
summary.agentCount > 1
|
||||
? `${summary.agentCount} working · ${elapsed}`
|
||||
: `Working · ${elapsed}`;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"hidden max-w-32 shrink-0 truncate rounded-full px-1.5 py-0.5 text-2xs font-medium leading-none tabular-nums motion-safe:animate-pulse group-data-[collapsible=icon]:hidden sm:inline-flex",
|
||||
isActive
|
||||
? "bg-sidebar-active-foreground/20 text-sidebar-active-foreground"
|
||||
: "bg-primary/10 text-primary",
|
||||
)}
|
||||
data-testid={`channel-working-${channelName}`}
|
||||
title={label}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export type SidebarDmParticipant = {
|
||||
avatarUrl: string | null;
|
||||
label: string;
|
||||
@@ -193,6 +228,7 @@ export function ChannelMenuButton({
|
||||
isActive,
|
||||
hasUnread,
|
||||
unreadCount = 0,
|
||||
activeWorking,
|
||||
isMuted,
|
||||
dmParticipants,
|
||||
presenceStatus,
|
||||
@@ -203,6 +239,7 @@ export function ChannelMenuButton({
|
||||
isActive: boolean;
|
||||
hasUnread: boolean;
|
||||
unreadCount?: number;
|
||||
activeWorking?: ActiveChannelTurnSummary;
|
||||
isMuted?: boolean;
|
||||
dmParticipants?: SidebarDmParticipant[];
|
||||
presenceStatus?: PresenceStatus;
|
||||
@@ -242,6 +279,13 @@ export function ChannelMenuButton({
|
||||
variant="sidebar"
|
||||
/>
|
||||
) : null}
|
||||
{activeWorking ? (
|
||||
<ChannelWorkingBadge
|
||||
channelName={channel.name}
|
||||
isActive={isActive}
|
||||
summary={activeWorking}
|
||||
/>
|
||||
) : null}
|
||||
{isMuted ? (
|
||||
<BellOff
|
||||
className={cn(
|
||||
@@ -269,6 +313,7 @@ export function ChannelMenuButton({
|
||||
|
||||
export function SidebarSection({
|
||||
action,
|
||||
activeWorkingByChannelId,
|
||||
dmParticipantsByChannelId,
|
||||
emptyState,
|
||||
items,
|
||||
@@ -291,6 +336,7 @@ export function SidebarSection({
|
||||
onUnmuteChannel,
|
||||
}: {
|
||||
action?: React.ReactNode;
|
||||
activeWorkingByChannelId?: ReadonlyMap<string, ActiveChannelTurnSummary>;
|
||||
dmParticipantsByChannelId?: Record<string, SidebarDmParticipant[]>;
|
||||
emptyState?: React.ReactNode;
|
||||
items: Channel[];
|
||||
@@ -362,6 +408,7 @@ export function SidebarSection({
|
||||
>
|
||||
<ChannelMenuButton
|
||||
channel={channel}
|
||||
activeWorking={activeWorkingByChannelId?.get(channel.id)}
|
||||
dmParticipants={dmParticipantsByChannelId?.[channel.id]}
|
||||
hasUnread={unreadChannelIds.has(channel.id)}
|
||||
unreadCount={unreadChannelCounts.get(channel.id) ?? 0}
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { AgentPersona } from "@/shared/api/types";
|
||||
|
||||
type ProfilePanelContextValue = {
|
||||
openProfilePanel: ((pubkey: string) => void) | null;
|
||||
openPersonaProfilePanel: ((persona: AgentPersona) => void) | null;
|
||||
};
|
||||
|
||||
const ProfilePanelContext = React.createContext<ProfilePanelContextValue>({
|
||||
openProfilePanel: null,
|
||||
openPersonaProfilePanel: null,
|
||||
});
|
||||
|
||||
export function ProfilePanelProvider({
|
||||
children,
|
||||
onOpenProfilePanel,
|
||||
onOpenPersonaProfilePanel,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onOpenProfilePanel: (pubkey: string) => void;
|
||||
onOpenPersonaProfilePanel?: (persona: AgentPersona) => void;
|
||||
}) {
|
||||
const value = React.useMemo(
|
||||
() => ({ openProfilePanel: onOpenProfilePanel }),
|
||||
[onOpenProfilePanel],
|
||||
() => ({
|
||||
openProfilePanel: onOpenProfilePanel,
|
||||
openPersonaProfilePanel: onOpenPersonaProfilePanel ?? null,
|
||||
}),
|
||||
[onOpenPersonaProfilePanel, onOpenProfilePanel],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-2xl px-3.5 py-2.5 text-xs text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted/40",
|
||||
destructive: "bg-destructive/10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
ref={ref}
|
||||
role="alert"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
className={cn("mb-1 font-medium leading-4 tracking-tight", className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className={cn("text-xs leading-5", className)} ref={ref} {...props} />
|
||||
));
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertDescription, AlertTitle };
|
||||
@@ -36,12 +36,19 @@ async function openAliceProfile(page: import("@playwright/test").Page) {
|
||||
await expect(panel).toContainText(ALICE_PUBKEY.slice(0, 8));
|
||||
}
|
||||
|
||||
async function openProfileSettingsMenu(page: import("@playwright/test").Page) {
|
||||
const trigger = page.getByTestId("user-profile-settings-menu-trigger");
|
||||
await expect(trigger).toBeVisible();
|
||||
await trigger.click();
|
||||
}
|
||||
|
||||
test.describe("NIP-IA archive button gate", () => {
|
||||
test("case 1 — self viewer + self target: Archive visible, no flair", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, { relayRole: null, oaOwnerIsMe: false });
|
||||
await openSelfProfile(page);
|
||||
await openProfileSettingsMenu(page);
|
||||
const archiveButton = page.getByTestId("user-profile-archive-identity");
|
||||
await expect(archiveButton).toBeVisible();
|
||||
await expect(page.getByTestId("user-profile-archived-flair")).toHaveCount(
|
||||
@@ -69,6 +76,7 @@ test.describe("NIP-IA archive button gate", () => {
|
||||
archivedIdentities: [],
|
||||
});
|
||||
await openAliceProfile(page);
|
||||
await openProfileSettingsMenu(page);
|
||||
await expect(
|
||||
page.getByTestId("user-profile-archive-identity"),
|
||||
).toBeVisible();
|
||||
@@ -83,6 +91,7 @@ test.describe("NIP-IA archive button gate", () => {
|
||||
archivedIdentities: [],
|
||||
});
|
||||
await openAliceProfile(page);
|
||||
await openProfileSettingsMenu(page);
|
||||
await expect(
|
||||
page.getByTestId("user-profile-archive-identity"),
|
||||
).toBeVisible();
|
||||
@@ -97,6 +106,9 @@ test.describe("NIP-IA archive button gate", () => {
|
||||
archivedIdentities: [],
|
||||
});
|
||||
await openAliceProfile(page);
|
||||
await expect(
|
||||
page.getByTestId("user-profile-settings-menu-trigger"),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByTestId("user-profile-archive-identity")).toHaveCount(
|
||||
0,
|
||||
);
|
||||
@@ -115,6 +127,7 @@ test.describe("NIP-IA archive button gate", () => {
|
||||
});
|
||||
await openAliceProfile(page);
|
||||
await expect(page.getByTestId("user-profile-archived-flair")).toBeVisible();
|
||||
await openProfileSettingsMenu(page);
|
||||
await expect(
|
||||
page.getByTestId("user-profile-unarchive-identity"),
|
||||
).toBeVisible();
|
||||
|
||||
@@ -64,15 +64,24 @@ async function setMesh(
|
||||
}, mesh);
|
||||
}
|
||||
|
||||
async function openManagedAgentActions(
|
||||
async function triggerManagedAgentPrimaryAction(
|
||||
page: import("@playwright/test").Page,
|
||||
pubkey: string,
|
||||
) {
|
||||
const trigger = page.getByTestId(`managed-agent-actions-${pubkey}`);
|
||||
await trigger.scrollIntoViewIfNeeded();
|
||||
await trigger.focus();
|
||||
await trigger.press("Enter");
|
||||
await expect(trigger).toHaveAttribute("data-state", "open");
|
||||
// Agent lifecycle actions moved from the old per-row dropdown into the
|
||||
// profile sidebar (PR #1200): the Agents-page row now exposes a "Manage"
|
||||
// button that opens the profile panel, where a single primary-action button
|
||||
// toggles Stop (when running/deployed) / Start (when stopped). Open the panel
|
||||
// for this agent if it isn't already showing it, then click that toggle.
|
||||
const panel = page.getByTestId("user-profile-panel");
|
||||
const primaryAction = panel.getByTestId("user-profile-agent-primary-action");
|
||||
if (!(await primaryAction.isVisible().catch(() => false))) {
|
||||
const row = page.getByTestId(`managed-agent-${pubkey}`);
|
||||
await row.getByRole("button", { name: "Manage" }).click();
|
||||
await expect(panel).toBeVisible();
|
||||
}
|
||||
await expect(primaryAction).toBeEnabled();
|
||||
await primaryAction.click();
|
||||
}
|
||||
|
||||
async function openNewAgentMenu(page: import("@playwright/test").Page) {
|
||||
@@ -330,8 +339,7 @@ test("saved relay-mesh agents restart via the backend serve-target preflight", a
|
||||
0,
|
||||
);
|
||||
|
||||
await openManagedAgentActions(page, pubkey);
|
||||
await page.getByRole("menuitem", { name: "Stop" }).click();
|
||||
await triggerManagedAgentPrimaryAction(page, pubkey);
|
||||
await expect
|
||||
.poll(async () => await commands(page))
|
||||
.toContain("stop_managed_agent");
|
||||
@@ -339,22 +347,19 @@ test("saved relay-mesh agents restart via the backend serve-target preflight", a
|
||||
|
||||
// With a live serve target for the model, manual restart goes through:
|
||||
// the backend preflight re-resolves the target and the agent starts.
|
||||
await openManagedAgentActions(page, pubkey);
|
||||
await page.getByRole("menuitem", { name: "Spawn" }).click();
|
||||
await triggerManagedAgentPrimaryAction(page, pubkey);
|
||||
await expect
|
||||
.poll(async () => await commands(page))
|
||||
.toContain("start_managed_agent");
|
||||
await expect(row).toContainText("running");
|
||||
|
||||
await openManagedAgentActions(page, pubkey);
|
||||
await page.getByRole("menuitem", { name: "Stop" }).click();
|
||||
await triggerManagedAgentPrimaryAction(page, pubkey);
|
||||
await expect(row).toContainText("stopped");
|
||||
|
||||
// Without a live serve target, the backend preflight rejects the start
|
||||
// with an actionable error, surfaced as a toast; the agent stays stopped.
|
||||
await setMesh(page, { models: [] });
|
||||
await openManagedAgentActions(page, pubkey);
|
||||
await page.getByRole("menuitem", { name: "Spawn" }).click();
|
||||
await triggerManagedAgentPrimaryAction(page, pubkey);
|
||||
|
||||
await expect(
|
||||
page
|
||||
|
||||
@@ -48,16 +48,34 @@ async function waitForReactEffects(page: Page) {
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(resolve);
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function getHashSearchParam(page: Page, name: string) {
|
||||
const hash = new URL(page.url()).hash.replace(/^#/, "");
|
||||
const queryStart = hash.indexOf("?");
|
||||
if (queryStart === -1) {
|
||||
return null;
|
||||
}
|
||||
return new URLSearchParams(hash.slice(queryStart + 1)).get(name);
|
||||
}
|
||||
|
||||
async function expectHashSearchParam(
|
||||
page: Page,
|
||||
name: string,
|
||||
value: string | null,
|
||||
) {
|
||||
await expect.poll(() => getHashSearchParam(page, name)).toBe(value);
|
||||
}
|
||||
|
||||
async function addGenericAgent(
|
||||
page: Page,
|
||||
channelName: string,
|
||||
agentName: string,
|
||||
systemPrompt = "Watch the channel and help when asked.",
|
||||
): Promise<string> {
|
||||
await page.getByTestId(`channel-${channelName}`).click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
|
||||
@@ -78,7 +96,7 @@ async function addGenericAgent(
|
||||
);
|
||||
});
|
||||
return page.evaluate(
|
||||
async ({ agentName, channelId }) => {
|
||||
async ({ agentName, channelId, systemPrompt }) => {
|
||||
const invoke = (
|
||||
window as Window & {
|
||||
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: (
|
||||
@@ -95,7 +113,7 @@ async function addGenericAgent(
|
||||
input: {
|
||||
name: agentName,
|
||||
spawnAfterCreate: true,
|
||||
systemPrompt: "Watch the channel and help when asked.",
|
||||
systemPrompt,
|
||||
},
|
||||
});
|
||||
const pubkey = created.agent?.pubkey;
|
||||
@@ -119,7 +137,7 @@ async function addGenericAgent(
|
||||
|
||||
return pubkey;
|
||||
},
|
||||
{ agentName, channelId },
|
||||
{ agentName, channelId, systemPrompt },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -627,7 +645,7 @@ test("updates presence from the profile menu", async ({ page }) => {
|
||||
).toContainText("Offline");
|
||||
});
|
||||
|
||||
test("renders agent memories seeded through the Playwright mock bridge", async ({
|
||||
test("renders agent profile ingress subviews from the Playwright mock bridge", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
@@ -635,7 +653,19 @@ test("renders agent memories seeded through the Playwright mock bridge", async (
|
||||
});
|
||||
await page.goto("/");
|
||||
|
||||
const agentPubkey = await addGenericAgent(page, "general", "Memory Bot");
|
||||
const longAgentInstruction = [
|
||||
"Watch the channel and help when asked.",
|
||||
"Summarize active decisions, call out risks plainly, and keep the tone concise.",
|
||||
"Prefer concrete next steps over broad commentary, and cite the relevant thread context when responding.",
|
||||
"Avoid catchphrases, theatrical roleplay, and unsupported guesses.",
|
||||
"When uncertainty remains, say exactly what evidence would resolve it.",
|
||||
].join("\n\n");
|
||||
const agentPubkey = await addGenericAgent(
|
||||
page,
|
||||
"general",
|
||||
"Memory Bot",
|
||||
longAgentInstruction,
|
||||
);
|
||||
|
||||
await page.getByTestId("channel-general").click();
|
||||
await expect(page.getByTestId("chat-title")).toHaveText("general");
|
||||
@@ -671,15 +701,90 @@ test("renders agent memories seeded through the Playwright mock bridge", async (
|
||||
await messageRow.locator("button").first().click();
|
||||
|
||||
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
|
||||
const memoriesIngress = page.getByTestId("user-profile-memories-ingress");
|
||||
await expect(memoriesIngress).toContainText("Memories");
|
||||
await expect(memoriesIngress).toContainText("9");
|
||||
await memoriesIngress.click();
|
||||
await expectHashSearchParam(page, "profileTab", null);
|
||||
|
||||
await expect(page.getByTestId("user-profile-tab-info")).toBeVisible();
|
||||
await expect(page.getByTestId("user-profile-runtime-status")).toHaveAttribute(
|
||||
"data-status",
|
||||
"running",
|
||||
);
|
||||
await page.getByTestId("user-profile-tab-runtime").click();
|
||||
await expectHashSearchParam(page, "profileTab", "runtime");
|
||||
const instructionPane = page.getByTestId("user-profile-agent-instruction");
|
||||
await expect(instructionPane).toContainText(
|
||||
"Watch the channel and help when asked.",
|
||||
);
|
||||
await expect(instructionPane).toHaveClass(/line-clamp-2/);
|
||||
await page.getByTestId("user-profile-agent-instruction-row").click();
|
||||
await expectHashSearchParam(page, "profileView", "instructions");
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 2, name: "Instructions" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByTestId("user-profile-agent-instructions-view"),
|
||||
).toContainText("When uncertainty remains");
|
||||
await page.getByTestId("user-profile-panel-back").click();
|
||||
await expectHashSearchParam(page, "profileView", null);
|
||||
await expectHashSearchParam(page, "profileTab", "runtime");
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 2, name: "Profile" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTestId("user-profile-tab-runtime").click();
|
||||
await expectHashSearchParam(page, "profileTab", "runtime");
|
||||
await expect(page.getByTestId("user-profile-model")).toBeVisible();
|
||||
await expect(page.getByTestId("user-profile-respond-to")).toBeVisible();
|
||||
|
||||
await page.getByTestId("user-profile-settings-menu-trigger").click();
|
||||
await expect(
|
||||
page.getByTestId(`user-profile-agent-auto-start-${agentPubkey}`),
|
||||
).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByTestId("user-profile-diagnostics-ingress").click();
|
||||
await expectHashSearchParam(page, "profileView", "diagnostics");
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 2, name: "Harness Log" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByTestId("user-profile-agent-status")).toHaveCount(0);
|
||||
await expect(page.getByTestId("managed-agent-log-content")).toBeVisible();
|
||||
await page.getByTestId("user-profile-panel-back").click();
|
||||
await expectHashSearchParam(page, "profileView", null);
|
||||
await expectHashSearchParam(page, "profileTab", "runtime");
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 2, name: "Profile" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTestId("user-profile-tab-info").click();
|
||||
await expectHashSearchParam(page, "profileTab", null);
|
||||
await page.getByTestId(`user-profile-view-activity-${agentPubkey}`).click();
|
||||
await expect(page.getByTestId("agent-session-thread-panel")).toBeVisible();
|
||||
await page.getByTestId("agent-session-back").click();
|
||||
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 2, name: "Profile" }),
|
||||
).toBeVisible();
|
||||
|
||||
await page.getByTestId("user-profile-tab-channels").click();
|
||||
await expectHashSearchParam(page, "profileTab", "channels");
|
||||
await expect(page.getByTestId("user-profile-channels-list")).toContainText(
|
||||
"#general",
|
||||
);
|
||||
|
||||
await page.getByTestId("user-profile-tab-memories").click();
|
||||
await expectHashSearchParam(page, "profileTab", "memories");
|
||||
await expect(page.getByTestId("agent-memory-section")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-memory-list")).toContainText(
|
||||
"ui-density",
|
||||
);
|
||||
await page.goBack();
|
||||
await expectHashSearchParam(page, "profileTab", "channels");
|
||||
await expect(page.getByTestId("user-profile-channels-list")).toContainText(
|
||||
"#general",
|
||||
);
|
||||
await page.goForward();
|
||||
await expectHashSearchParam(page, "profileTab", "memories");
|
||||
await expect(page.getByTestId("agent-memory-section")).toBeVisible();
|
||||
await expect(page.getByTestId("agent-memory-truncated")).toContainText(
|
||||
"View all (9)",
|
||||
);
|
||||
@@ -1085,7 +1190,8 @@ test("supports webview zoom keyboard shortcuts", async ({ page }) => {
|
||||
page.evaluate(() => ({
|
||||
fontSize: getComputedStyle(document.documentElement).fontSize,
|
||||
storedScale: localStorage.getItem("buzz:text-scale"),
|
||||
webviewZoom: window.__BUZZ_E2E_WEBVIEW_ZOOM__,
|
||||
webviewZoom: (window as Window & { __BUZZ_E2E_WEBVIEW_ZOOM__?: number })
|
||||
.__BUZZ_E2E_WEBVIEW_ZOOM__,
|
||||
}));
|
||||
const dispatchPrimaryShortcut = (
|
||||
key: string,
|
||||
|
||||
Reference in New Issue
Block a user