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:
Taylor Ho
2026-06-26 11:39:42 -07:00
committed by GitHub
co-authored by klopez4212 npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f Marge npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w
parent 8c3d0c92e8
commit 8d40150c83
50 changed files with 5221 additions and 1416 deletions
+29
View File
@@ -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,
});
+10 -6
View File
@@ -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),
};
+10 -5
View File
@@ -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>
+143 -5
View File
@@ -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>
);
}
+7 -26
View File
@@ -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);
}}
+17 -6
View File
@@ -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,
};
+17 -10
View File
@@ -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 };
}
+22 -7
View File
@@ -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 (
+54
View File
@@ -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();
+19 -14
View File
@@ -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
+117 -11
View File
@@ -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,