diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 34180c95f..32e41d622 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -879,6 +879,7 @@ dependencies = [ "rodio", "rubato", "rusqlite", + "security-framework 3.7.0", "serde", "serde_json", "serde_yaml", diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index a09bb4f51..0d0187e97 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -6,12 +6,12 @@ use tauri::{AppHandle, State}; use crate::{ app_state::AppState, managed_agents::{ - build_managed_agent_summary, default_agent_workdir, find_managed_agent_mut, - known_acp_runtime, load_managed_agents, load_personas, managed_agent_avatar_url, - missing_command_message, normalize_agent_args, resolve_command, - resolve_effective_prompt_model_provider, save_managed_agents, sync_managed_agent_processes, - try_regenerate_nest, AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, - UpdateManagedAgentResponse, + build_databricks_defaults, build_managed_agent_summary, default_agent_workdir, + find_managed_agent_mut, known_acp_runtime, load_managed_agents, load_personas, + managed_agent_avatar_url, missing_command_message, normalize_agent_args, resolve_command, + resolve_effective_prompt_model_provider, runtime_metadata_env_vars, save_managed_agents, + sync_managed_agent_processes, try_regenerate_nest, AgentModelInfo, AgentModelsResponse, + UpdateManagedAgentRequest, UpdateManagedAgentResponse, }, relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, @@ -27,7 +27,16 @@ pub async fn get_agent_models( app: AppHandle, state: State<'_, AppState>, ) -> Result { - let (resolved_acp, agent_command, agent_args, persisted_model, merged_env) = { + let ( + resolved_acp, + agent_command, + agent_args, + persisted_model, + runtime_default_env, + runtime_metadata_env, + databricks_defaults, + merged_env, + ) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -75,14 +84,51 @@ pub async fn get_agent_models( // Resolve the effective model from the linked persona so the ModelPicker // dropdown shows the current persona model as selected. - let (_prompt, effective_model, _provider) = resolve_effective_prompt_model_provider( - record.persona_id.as_deref(), - &personas, - record.system_prompt.clone(), - record.model.clone(), - ); - - (resolved, resolved_agent, args, effective_model, env) + let (_prompt, effective_model, effective_provider) = + resolve_effective_prompt_model_provider( + record.persona_id.as_deref(), + &personas, + record.system_prompt.clone(), + record.model.clone(), + ); + let runtime = known_acp_runtime(&record.agent_command); + let runtime_default_env: Vec<(String, String)> = runtime + .map(|meta| { + meta.default_env + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect() + }) + .unwrap_or_default(); + let runtime_metadata_env: Vec<(String, String)> = runtime + .map(|meta| { + runtime_metadata_env_vars( + meta.model_env_var, + meta.provider_env_var, + meta.provider_locked, + effective_model.as_deref(), + effective_provider.as_deref(), + ) + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect() + }) + .unwrap_or_default(); + let databricks_defaults: Vec<(String, String)> = build_databricks_defaults() + .into_iter() + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(); + + ( + resolved, + resolved_agent, + args, + effective_model, + runtime_default_env, + runtime_metadata_env, + databricks_defaults, + env, + ) }; // store lock released — subprocess runs without holding the lock // Clone the env map for redaction below — `merged_env` is moved @@ -105,13 +151,17 @@ pub async fn get_agent_models( .arg("--json") .env("BUZZ_ACP_AGENT_COMMAND", &agent_command) .env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); - if let Some(meta) = known_acp_runtime(&agent_command) { - for (key, value) in meta.default_env { - if std::env::var(key).is_err() { - cmd.env(key, value); - } + for (key, value) in &runtime_default_env { + if std::env::var(key).is_err() { + cmd.env(key, value); } } + for (key, value) in &runtime_metadata_env { + cmd.env(key, value); + } + for (key, value) in &databricks_defaults { + cmd.env(key, value); + } // User env layering — written LAST so it overrides any Buzz-set env above. for (k, v) in &merged_env { cmd.env(k, v); @@ -132,6 +182,12 @@ pub async fn get_agent_models( // a failing child process echoed back. let stderr_redacted = crate::managed_agents::redact_env_values_in(stderr.as_ref(), &env_for_redaction); + if let Some(configuration_error) = model_configuration_error(&stderr_redacted) { + return Ok(unavailable_agent_models( + persisted_model, + configuration_error, + )); + } return Err(format!( "buzz-acp models failed (exit {}): {stderr_redacted}", output.status.code().unwrap_or(-1) @@ -413,5 +469,53 @@ fn normalize_agent_models( agent_default_model, selected_model: persisted_model, supports_switching, + configuration_error: None, + } +} + +fn unavailable_agent_models( + persisted_model: Option, + configuration_error: String, +) -> AgentModelsResponse { + AgentModelsResponse { + agent_name: "unknown".to_string(), + agent_version: "unknown".to_string(), + models: Vec::new(), + agent_default_model: None, + selected_model: persisted_model, + supports_switching: false, + configuration_error: Some(configuration_error), + } +} + +fn model_configuration_error(stderr: &str) -> Option { + let normalized = stderr.to_ascii_lowercase(); + + if normalized.contains("buzz_agent_provider required") { + return Some( + "This agent does not have an LLM provider configured. Set a provider and model on the persona or agent, then retry." + .to_string(), + ); } + + if normalized.contains("anthropic_model required") + || normalized.contains("openai_compat_model required") + || normalized.contains("databricks_model required") + { + return Some( + "This agent does not have an LLM model configured. Set a model on the persona or agent, then retry." + .to_string(), + ); + } + + if normalized.contains("anthropic_api_key required") + || normalized.contains("openai_compat_api_key required") + { + return Some( + "This agent is missing credentials for its configured LLM provider. Add the provider credentials, then retry." + .to_string(), + ); + } + + None } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 3bc6ddd24..4d511e79d 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -1845,7 +1845,7 @@ fn child_rust_log_filter() -> String { /// Databricks host/model baked in at compile time for internal builds. Empty /// in OSS builds, where the `BUZZ_BUILD_DATABRICKS_*` env is unset. -fn build_databricks_defaults() -> Vec<(&'static str, &'static str)> { +pub(crate) fn build_databricks_defaults() -> Vec<(&'static str, &'static str)> { let mut defaults = Vec::new(); if let Some(host) = option_env!("BUZZ_DESKTOP_BUILD_DATABRICKS_HOST") { if !host.is_empty() { @@ -1977,7 +1977,7 @@ pub fn stop_managed_agent_process( /// switching need the initial bootstrap value. Provider injection is skipped /// when `provider_locked` is true (e.g. Claude runtimes that only work with /// Anthropic). -fn runtime_metadata_env_vars<'a>( +pub(crate) fn runtime_metadata_env_vars<'a>( model_env_var: Option<&'a str>, provider_env_var: Option<&'a str>, provider_locked: bool, diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 0e777ec0d..1fa5c54ea 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -535,6 +535,8 @@ pub struct AgentModelsResponse { pub selected_model: Option, /// Whether this agent supports model switching. pub supports_switching: bool, + /// Human-readable setup issue that prevents model discovery. + pub configuration_error: Option, } /// A single model available from an agent. diff --git a/desktop/src/app/routes/agents.tsx b/desktop/src/app/routes/agents.tsx index b3783eb5c..b32dc5a99 100644 --- a/desktop/src/app/routes/agents.tsx +++ b/desktop/src/app/routes/agents.tsx @@ -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, +): 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, }); diff --git a/desktop/src/app/routes/channels.$channelId.tsx b/desktop/src/app/routes/channels.$channelId.tsx index 3c64f3ec9..d067f7308 100644 --- a/desktop/src/app/routes/channels.$channelId.tsx +++ b/desktop/src/app/routes/channels.$channelId.tsx @@ -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, ): 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), }; diff --git a/desktop/src/app/routes/pulse.tsx b/desktop/src/app/routes/pulse.tsx index e1a5a001e..297265a34 100644 --- a/desktop/src/app/routes/pulse.tsx +++ b/desktop/src/app/routes/pulse.tsx @@ -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, }; } diff --git a/desktop/src/features/agent-memory/ui/MemorySection.tsx b/desktop/src/features/agent-memory/ui/MemorySection.tsx index d6e388015..dd9e4206f 100644 --- a/desktop/src/features/agent-memory/ui/MemorySection.tsx +++ b/desktop/src/features/agent-memory/ui/MemorySection.tsx @@ -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 ( -

- This agent has no memories yet. -

+ +

No memories yet

+

+ This agent has no memories yet. +

+ ); } diff --git a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs index f9bba8bab..cef73f602 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.test.mjs +++ b/desktop/src/features/agents/activeAgentTurnsStore.test.mjs @@ -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, [ diff --git a/desktop/src/features/agents/activeAgentTurnsStore.ts b/desktop/src/features/agents/activeAgentTurnsStore.ts index a32168585..2f095cbda 100644 --- a/desktop/src/features/agents/activeAgentTurnsStore.ts +++ b/desktop/src/features/agents/activeAgentTurnsStore.ts @@ -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>(); const listeners = new Set<() => void>(); @@ -68,6 +75,7 @@ const clockOffsetByAgent = new Map(); // Cached snapshots for useSyncExternalStore reference stability. // Only regenerated when the underlying turn map for an agent actually changes. const cachedTurnSummaries = new Map(); +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 | null = null; function invalidateCache(agentKey: string) { cachedTurnSummaries.delete(agentKey); + cachedChannelTurnSummaries = null; } function notifyListeners() { @@ -429,6 +438,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 } + >(); + + 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 @@ -459,6 +515,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. @@ -485,6 +552,7 @@ export function resetActiveAgentTurnsStore() { lastProcessed.clear(); clockOffsetByAgent.clear(); cachedTurnSummaries.clear(); + cachedChannelTurnSummaries = null; terminalAtByAgent.clear(); notifyListeners(); } diff --git a/desktop/src/features/agents/ui/AgentGroupRows.tsx b/desktop/src/features/agents/ui/AgentGroupRows.tsx index 2aa95372e..a8131906c 100644 --- a/desktop/src/features/agents/ui/AgentGroupRows.tsx +++ b/desktop/src/features/agents/ui/AgentGroupRows.tsx @@ -6,7 +6,6 @@ export type AgentGroupRowsProps = { agents: ManagedAgent[]; channelIdToName: Record; channelsByPubkey: Record; - 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 (
@@ -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} /> ))}
diff --git a/desktop/src/features/agents/ui/AgentsScreen.tsx b/desktop/src/features/agents/ui/AgentsScreen.tsx index 9bcedca67..d52ac7923 100644 --- a/desktop/src/features/agents/ui/AgentsScreen.tsx +++ b/desktop/src/features/agents/ui/AgentsScreen.tsx @@ -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(() => { + 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 ( -
- }> - - -
+ +
+
+ }> + + + {profilePanelTarget ? ( + + ) : null} +
+
+
); } diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index 44088d490..f80207beb 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -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); - }} - onSelectLogAgent={agents.setLogAgentPubkey} - onStartAgent={(pubkey) => { - void agents.handleStart(pubkey); - }} - onStopAgent={(pubkey) => { - void agents.handleStop(pubkey); + onOpenAgentProfile={(pubkey) => { + openProfilePanel?.(pubkey); }} - onToggleStartOnAppLaunch={(pubkey, startOnAppLaunch) => { - void agents.handleToggleStartOnAppLaunch( - pubkey, - startOnAppLaunch, - ); + onOpenPersonaProfile={(persona) => { + openPersonaProfilePanel?.(persona); }} + onSelectLogAgent={agents.setLogAgentPubkey} selectedLogAgentPubkey={agents.logAgentPubkey} // Persona props canChooseCatalog={personas.catalogPersonas.length > 0} @@ -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); }} diff --git a/desktop/src/features/agents/ui/CopyButton.tsx b/desktop/src/features/agents/ui/CopyButton.tsx index a8d0b621f..0e332375f 100644 --- a/desktop/src/features/agents/ui/CopyButton.tsx +++ b/desktop/src/features/agents/ui/CopyButton.tsx @@ -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 ( ); } diff --git a/desktop/src/features/agents/ui/ManagedAgentLogPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentLogPanel.tsx index 86fdc05cf..8616ad176 100644 --- a/desktop/src/features/agents/ui/ManagedAgentLogPanel.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentLogPanel.tsx @@ -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 (
-
-
-

Harness log

-

- {selectedAgent - ? `${selectedAgent.name} · ${describeLogFile(selectedAgent.logPath)}` - : "Select a local agent to inspect recent output."} -

+ {!selectedAgent ? ( +
+
+

+ Harness Log +

+

+ Select a local agent to inspect recent output. +

+
- {selectedAgent ? ( - - ) : null} -
+ ) : null} {!selectedAgent ? ( -
+

No local agent selected

@@ -57,22 +68,67 @@ export function ManagedAgentLogPanel({

) : isLoading ? ( -
- - - - +
+ {!isBare ? ( + + ) : null} +
+ + + + +
) : ( -
-
- {selectedAgent.name} - {selectedAgent.status} -
+
+ {!isBare ? ( + + ) : null}
@@ -90,3 +146,38 @@ export function ManagedAgentLogPanel({
     
); } + +function HarnessLogHeader({ + logContent, + logFileLabel, + selectedAgent, +}: { + logContent: string; + logFileLabel: string; + selectedAgent: ManagedAgent; +}) { + const fileTitle = `${selectedAgent.name} · ${logFileLabel}`; + + return ( +
+
+ + Harness Log + + + {selectedAgent.name} · {logFileLabel} + +
+ +
+ ); +} diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 696003e35..06cb337ef 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -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; channelNames: { id: string; name: string }[]; - isActionPending: boolean; isLogSelected: boolean; logContent: string | null; logError: Error | null; @@ -72,14 +44,9 @@ export function ManagedAgentRow({ personaLabelsById: Record; 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({ )}
- - onSelectLogAgent(pubkey)} - onStart={onStart} - onStop={onStop} - onToggleStartOnAppLaunch={onToggleStartOnAppLaunch} - /> +
@@ -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 ( - <> - - - - - event.preventDefault()} - > - {agent.backend.type === "provider" ? ( - <> - onStart(agent.pubkey)} - > - - {isActive ? "Redeploy" : "Deploy"} - - onStop(agent.pubkey)} - > - - Shutdown - - - ) : isActive ? ( - onStop(agent.pubkey)} - > - - Stop - - ) : ( - onStart(agent.pubkey)} - > - - Spawn - - )} - - {agent.backend.type !== "provider" ? ( - setEditOpen(true)}> - - Edit - - ) : null} - - onAddToChannel(agent)} - > - - Add to channel - - - { - await navigator.clipboard.writeText(agent.pubkey); - toast.success("Copied pubkey to clipboard"); - }} - > - - Copy pubkey - - - {agent.backend.type === "local" ? ( - onOpenLogs(agent.pubkey)}> - - View logs - - ) : null} - - {agent.backend.type === "local" ? ( - - onToggleStartOnAppLaunch(agent.pubkey, !agent.startOnAppLaunch) - } - > - - {agent.startOnAppLaunch - ? "Disable auto-start" - : "Enable auto-start"} - - ) : null} - - - - onDelete(agent.pubkey)} - > - - Delete - - - - - {editOpen ? ( - - ) : null} - - ); -} - function AgentOriginBadge({ agent }: { agent: ManagedAgent }) { return ( diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index 12950201e..fd5f236b8 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -109,6 +109,9 @@ export function ModelPicker({ ) : error ? (

Failed to load models.

+

+ {error} +

+
) : !modelsData.supportsSwitching ? (
{agent.model ? ( diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index eede54956..eceb9d3e2 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -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; 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 ? ( Inactive ) : null} - +
{!isCollapsed && hasAgents ? ( diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx index ab9712811..d808bfcc0 100644 --- a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -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(); async function handleInterruptTurn() { + if (!channel) { + return; + } + try { await cancelManagedAgentTurn(agent.pubkey, channel.id); toast.success( @@ -155,9 +159,13 @@ export function AgentSessionThreadPanel({ > void; + onProfilePanelTabChange: ( + tab: ProfilePanelTab, + options?: { replace?: boolean }, + ) => void; profilePanelPubkey?: string | null; + profilePanelTab: ProfilePanelTab; profilePanelView: ProfilePanelView; threadHeadMessage: TimelineMessage | null; threadMessages: MainTimelineEntry[]; @@ -237,7 +244,9 @@ export const ChannelPane = React.memo(function ChannelPane({ shouldShowThreadSkeleton, openAgentSessionPubkey, onProfilePanelViewChange, + onProfilePanelTabChange, profilePanelPubkey, + profilePanelTab, profilePanelView, targetMessageId, threadHeadMessage, @@ -311,9 +320,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 && @@ -322,15 +328,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; @@ -361,8 +358,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); @@ -612,15 +607,15 @@ 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], ); return (
@@ -914,13 +909,20 @@ export const ChannelPane = React.memo(function ChannelPane({ panel ); })() - : activeChannel && selectedAgent + : selectedAgent ? (() => { const panel = ( entry.pubkey.toLowerCase() === @@ -964,9 +966,11 @@ 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} /> diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index ea6a9449b..d62fa6531 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -108,9 +108,11 @@ export function ChannelScreen({ openAgentSessionPubkey, openThreadHeadId, profilePanelPubkey, + profilePanelTab, profilePanelView, setOpenAgentSessionPubkey, setOpenThreadHeadId, + setProfilePanelTab, setProfilePanelPubkey, setProfilePanelView, } = useChannelPanelHistoryState(); @@ -318,8 +320,8 @@ export function ChannelScreen({ return pubkeys; }, [channelMembers, managedAgents, relayAgents]); const { + agentSessionCandidates, botTypingEntries, - channelAgentSessionAgents: activeChannelAgentSessionAgents, humanTypingPubkeys, threadTypingPubkeys, } = useChannelActivityTyping({ @@ -331,7 +333,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( @@ -494,6 +518,7 @@ export function ChannelScreen({ ? handleSendVideoReviewComment : undefined; const { + agentSessionAgents, channelAgentSessionAgents, closeAgentSession: handleCloseAgentSession, openAgentSession: handleOpenAgentSession, @@ -511,8 +536,9 @@ export function ChannelScreen({ !relayAgentsQuery.isLoading, channelMembers, handleOpenThread, - managedAgents: activeChannelAgentSessionAgents, + managedAgents: agentSessionCandidates, openAgentSessionPubkey, + profilePanelPubkey, setExpandedThreadReplyIds, setOpenAgentSessionPubkey, setOpenThreadHeadId, @@ -713,8 +739,9 @@ export function ChannelScreen({ > -
- - {presenceStatus ? ( - - - - ) : null} -
+
- {memberIsBot ? ( -
-
- - {memberLabel} - - - -
- - - {truncatePubkey(member.pubkey)} - - -
- ) : ( -
- - {memberLabel} - - {roleLabel ? ( - - {roleLabel} - - ) : null} -
- )} + {managedAgent ? ( + 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(), + ); +} diff --git a/desktop/src/features/channels/ui/useChannelActivityTyping.ts b/desktop/src/features/channels/ui/useChannelActivityTyping.ts index f18f34c0e..c7b95d206 100644 --- a/desktop/src/features/channels/ui/useChannelActivityTyping.ts +++ b/desktop/src/features/channels/ui/useChannelActivityTyping.ts @@ -85,6 +85,7 @@ export function useChannelActivityTyping({ }, [channelAgentPubkeys, typingEntries]); return { + agentSessionCandidates: agentCandidates, botTypingEntries, channelAgentSessionAgents, humanTypingPubkeys, diff --git a/desktop/src/features/channels/ui/useChannelAgentSessions.ts b/desktop/src/features/channels/ui/useChannelAgentSessions.ts index 9196f664d..cef5336c7 100644 --- a/desktop/src/features/channels/ui/useChannelAgentSessions.ts +++ b/desktop/src/features/channels/ui/useChannelAgentSessions.ts @@ -28,6 +28,7 @@ type UseChannelAgentSessionsOptions = { handleOpenThread: (message: TimelineMessage) => void; managedAgents: ChannelAgentSessionAgent[]; openAgentSessionPubkey: string | null; + profilePanelPubkey?: string | null; setExpandedThreadReplyIds: (value: Set) => void; setOpenAgentSessionPubkey: PanelValueSetter; setOpenThreadHeadId: (value: string | null) => void; @@ -159,6 +160,7 @@ export function useChannelAgentSessions({ handleOpenThread, managedAgents, openAgentSessionPubkey, + profilePanelPubkey = null, setExpandedThreadReplyIds, setOpenAgentSessionPubkey, setOpenThreadHeadId, @@ -176,6 +178,7 @@ export function useChannelAgentSessions({ }), [activeChannel, activeChannelId, channelMembers, managedAgents], ); + const agentSessionAgents = managedAgents; const closeAgentSession = React.useCallback(() => { setOpenAgentSessionPubkey(null); @@ -187,14 +190,12 @@ export function useChannelAgentSessions({ setExpandedThreadReplyIds(new Set()); setThreadScrollTargetId(null); setThreadReplyTargetId(null); - setProfilePanelPubkey(null); setOpenAgentSessionPubkey(pubkey); }, [ setExpandedThreadReplyIds, setOpenAgentSessionPubkey, setOpenThreadHeadId, - setProfilePanelPubkey, setThreadReplyTargetId, setThreadScrollTargetId, ], @@ -224,7 +225,9 @@ export function useChannelAgentSessions({ if ( openAgentSessionPubkey && agentsLoaded && - !channelAgentSessionAgents.some( + normalizePubkey(profilePanelPubkey ?? "") !== + normalizePubkey(openAgentSessionPubkey) && + !agentSessionAgents.some( (agent) => normalizePubkey(agent.pubkey) === normalizePubkey(openAgentSessionPubkey), @@ -233,13 +236,15 @@ export function useChannelAgentSessions({ setOpenAgentSessionPubkey(null, { replace: true }); } }, [ + agentSessionAgents, agentsLoaded, - channelAgentSessionAgents, openAgentSessionPubkey, + profilePanelPubkey, setOpenAgentSessionPubkey, ]); return { + agentSessionAgents, channelAgentSessionAgents, closeAgentSession, openAgentSession, diff --git a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts index 53d236f43..4804c2e6b 100644 --- a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts +++ b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts @@ -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,8 +17,8 @@ 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). + * `profileView` (profile panel focused view), `profileTab` (profile summary + * tab), `agentSession` (agent session panel pubkey). */ export type PanelSetterOptions = HistorySearchSetterOptions; @@ -27,15 +32,12 @@ const CHANNEL_SEARCH_KEYS = [ "agentSession", "messageId", "profile", + "profileTab", "profileView", "thread", "threadRootId", ] as const; -function asProfilePanelView(value: string | null): ProfilePanelView { - return value === "memories" || value === "channels" ? value : "summary"; -} - export function useChannelPanelHistoryState() { const { applyPatch, values } = useHistorySearchState(CHANNEL_SEARCH_KEYS); @@ -48,7 +50,10 @@ export function useChannelPanelHistoryState() { // the carried `profileView` would otherwise leak onto the next profile. const setProfilePanelPubkey = React.useCallback( (value, options) => - applyPatch({ profile: value, profileView: null }, options), + applyPatch( + { profile: value, profileTab: null, profileView: null }, + options, + ), [applyPatch], ); @@ -58,6 +63,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( (value, options) => applyPatch({ agentSession: value }, options), [applyPatch], @@ -74,9 +85,11 @@ export function useChannelPanelHistoryState() { openAgentSessionPubkey: values.agentSession, openThreadHeadId: values.thread, profilePanelPubkey: values.profile, - profilePanelView: asProfilePanelView(values.profileView), + profilePanelTab: profilePanelTabFromSearch(values.profileTab), + profilePanelView: profilePanelViewFromSearch(values.profileView), setOpenAgentSessionPubkey, setOpenThreadHeadId, + setProfilePanelTab, setProfilePanelPubkey, setProfilePanelView, }; diff --git a/desktop/src/features/forum/ui/ForumPostCard.tsx b/desktop/src/features/forum/ui/ForumPostCard.tsx index 4bd5f0f08..a6d8e3863 100644 --- a/desktop/src/features/forum/ui/ForumPostCard.tsx +++ b/desktop/src/features/forum/ui/ForumPostCard.tsx @@ -5,7 +5,7 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; -import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; +import { ProfileIdentityTrigger } from "@/features/profile/ui/ProfileIdentityTrigger"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import type { ForumPost } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; @@ -79,21 +79,19 @@ export function ForumPostCard({
{/* biome-ignore lint/a11y/noStaticElementInteractions: presentation wrapper stops click propagation to parent card */}
e.stopPropagation()} role="presentation"> - - - + + + + {authorLabel} + +
{formatRelativeTime(post.createdAt)} diff --git a/desktop/src/features/forum/ui/ForumThreadPanel.tsx b/desktop/src/features/forum/ui/ForumThreadPanel.tsx index 0ea16a860..0230f7ea4 100644 --- a/desktop/src/features/forum/ui/ForumThreadPanel.tsx +++ b/desktop/src/features/forum/ui/ForumThreadPanel.tsx @@ -5,7 +5,7 @@ import { resolveUserLabel, type UserProfileLookup, } from "@/features/profile/lib/identity"; -import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; +import { ProfileIdentityTrigger } from "@/features/profile/ui/ProfileIdentityTrigger"; import { UserAvatar } from "@/shared/ui/UserAvatar"; import type { ForumThreadResponse, ThreadReply } from "@/shared/api/types"; import { channelChrome } from "@/shared/layout/chromeLayout"; @@ -80,21 +80,19 @@ function ReplyRow({ data-forum-event-id={reply.eventId} >
- - - + + + + {replyAuthorLabel} + + {formatRelativeTime(reply.createdAt)} @@ -222,20 +220,18 @@ export function ForumThreadPanel({ data-forum-event-id={post.eventId} >
- - - + + + + {postAuthorLabel} + + {formatRelativeTime(post.createdAt)} diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 988968677..82a3f5d43 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -4,7 +4,7 @@ import type { TimelineMessage } from "@/features/messages/types"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import { useReactionHandler } from "@/features/messages/ui/useReactionHandler"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; -import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; +import { ProfileIdentityTrigger } from "@/features/profile/ui/ProfileIdentityTrigger"; import { useRemindLater } from "@/features/reminders/ui/RemindMeLaterProvider"; import { getThreadReplyAvatarCenterRem, @@ -621,39 +621,31 @@ export const MessageRow = React.memo( {isThreadReplyLayout ? ( <> {message.pubkey ? ( - - - + {avatarNode} + ) : (
{avatarNode}
)}
{message.pubkey ? ( - - - + {authorNode} + ) : ( authorNode )} @@ -671,39 +663,31 @@ export const MessageRow = React.memo( ) : ( <> {message.pubkey ? ( - - - + {avatarNode} + ) : (
{avatarNode}
)}
{message.pubkey ? ( - - - + {authorNode} + ) : ( authorNode )} diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index 25c10b673..a992b9de6 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -8,6 +8,7 @@ import { useReactionHandler } from "@/features/messages/ui/useReactionHandler"; import { recordQuickReactionEmoji } from "@/features/messages/ui/useQuickReactionEmojis"; import type { UserProfileLookup } from "@/features/profile/lib/identity"; import { resolveUserLabel } from "@/features/profile/lib/identity"; +import { ProfileIdentityTrigger } from "@/features/profile/ui/ProfileIdentityTrigger"; import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { cn } from "@/shared/lib/cn"; import { normalizePubkey } from "@/shared/lib/pubkey"; @@ -145,14 +146,12 @@ function SystemMessageAvatar({ if (singlePubkey) { return ( - - - + + {avatar} + ); } @@ -185,14 +184,12 @@ function SystemMessageAvatar({ ); return ( - - - + + {dualAvatar} + ); } diff --git a/desktop/src/features/profile/lib/agentLabels.ts b/desktop/src/features/profile/lib/agentLabels.ts new file mode 100644 index 000000000..9ab39f8d4 --- /dev/null +++ b/desktop/src/features/profile/lib/agentLabels.ts @@ -0,0 +1,10 @@ +const RUNTIME_LABELS: Record = { + goose: "Goose", + "claude-code": "Claude Code", + "codex-acp": "Codex", + aider: "Aider", +}; + +export function runtimeLabel(command: string): string { + return RUNTIME_LABELS[command] ?? command; +} diff --git a/desktop/src/features/profile/ui/AgentWorkingBadge.tsx b/desktop/src/features/profile/ui/AgentWorkingBadge.tsx new file mode 100644 index 000000000..4a913b250 --- /dev/null +++ b/desktop/src/features/profile/ui/AgentWorkingBadge.tsx @@ -0,0 +1,46 @@ +import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; +import { Badge } from "@/shared/ui/badge"; +import { cn } from "@/shared/lib/cn"; +import { useNow } from "@/shared/lib/useNow"; + +type AgentWorkingBadgeProps = { + anchorAt: number; + channelId?: string; + name: string; + onNavigate?: (channelId: string) => void; + variant: "panel" | "popover"; +}; + +export function AgentWorkingBadge({ + anchorAt, + channelId, + name, + onNavigate, + variant, +}: AgentWorkingBadgeProps) { + const now = useNow(1000); + const label = `Working in #${name} · ${formatElapsed(now - anchorAt)}`; + + if (variant === "panel") { + return ( + onNavigate(channelId) : undefined + } + variant="default" + > + {label} + + ); + } + + return ( + + {label} + + ); +} diff --git a/desktop/src/features/profile/ui/ProfileAvatarWithPresence.tsx b/desktop/src/features/profile/ui/ProfileAvatarWithPresence.tsx new file mode 100644 index 000000000..eec18a4ed --- /dev/null +++ b/desktop/src/features/profile/ui/ProfileAvatarWithPresence.tsx @@ -0,0 +1,63 @@ +import type { PresenceStatus } from "@/shared/api/types"; +import { getPresenceLabel } from "@/features/presence/lib/presence"; +import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; +import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { cn } from "@/shared/lib/cn"; + +type ProfileAvatarWithPresenceProps = { + avatarDataUrl?: string | null; + avatarUrl: string | null; + className?: string; + iconClassName?: string; + label: string; + plain?: boolean; + presenceClassName?: string; + presenceDotClassName?: string; + presenceStatus?: PresenceStatus; + presenceTestId?: string; + testId?: string; +}; + +export function ProfileAvatarWithPresence({ + avatarDataUrl, + avatarUrl, + className, + iconClassName, + label, + plain, + presenceClassName, + presenceDotClassName, + presenceStatus, + presenceTestId, + testId, +}: ProfileAvatarWithPresenceProps) { + return ( +
+ + {presenceStatus ? ( + + + + ) : null} +
+ ); +} diff --git a/desktop/src/features/profile/ui/ProfileIdentityTrigger.tsx b/desktop/src/features/profile/ui/ProfileIdentityTrigger.tsx new file mode 100644 index 000000000..65feccc60 --- /dev/null +++ b/desktop/src/features/profile/ui/ProfileIdentityTrigger.tsx @@ -0,0 +1,41 @@ +import type * as React from "react"; + +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; +import { cn } from "@/shared/lib/cn"; + +export const PROFILE_IDENTITY_FOCUS_CLASS = + "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"; + +type ProfileIdentityTriggerProps = { + authorRole?: string; + botIdenticonValue?: string; + buttonClassName?: string; + children: React.ReactNode; + pubkey: string; + triggerElement?: "div" | "span"; +}; + +export function ProfileIdentityTrigger({ + authorRole, + botIdenticonValue, + buttonClassName, + children, + pubkey, + triggerElement = "div", +}: ProfileIdentityTriggerProps) { + return ( + + + + ); +} diff --git a/desktop/src/features/profile/ui/ProfileListIdentity.tsx b/desktop/src/features/profile/ui/ProfileListIdentity.tsx new file mode 100644 index 000000000..801787455 --- /dev/null +++ b/desktop/src/features/profile/ui/ProfileListIdentity.tsx @@ -0,0 +1,105 @@ +import { Bot } from "lucide-react"; + +import { truncatePubkey } from "@/features/profile/lib/identity"; +import { cn } from "@/shared/lib/cn"; + +type ProfileListIdentityProps = { + agentIconClassName?: string; + agentIconTestId?: string; + hoverGroup?: "member" | "dm-result"; + isAgent?: boolean; + label: string; + ownerLabel?: string | null; + pubkey: string; + roleLabel?: string | null; +}; + +function hoverClasses(hoverGroup: "member" | "dm-result", visible: boolean) { + const prefix = + hoverGroup === "dm-result" ? "group-hover/dm-result" : "group-hover/member"; + const focusPrefix = + hoverGroup === "dm-result" + ? "group-focus-within/dm-result" + : "group-focus-within/member"; + + return visible + ? cn( + "opacity-0 transition-opacity duration-150 ease-out", + `${prefix}:opacity-100`, + `${focusPrefix}:opacity-100`, + ) + : cn( + "transition-opacity duration-150 ease-out", + `${prefix}:opacity-0`, + `${focusPrefix}:opacity-0`, + ); +} + +export function ProfileListIdentity({ + agentIconClassName = "h-3 w-3", + agentIconTestId, + hoverGroup = "member", + isAgent = false, + label, + ownerLabel, + pubkey, + roleLabel = "agent", +}: ProfileListIdentityProps) { + if (!isAgent) { + return ( +
+ + {label} + + {roleLabel ? ( + + {roleLabel} + + ) : null} +
+ ); + } + + return ( +
+
+ + {label} + + + +
+ {ownerLabel ? ( + + owned by {ownerLabel} + + ) : null} + + + {truncatePubkey(pubkey)} + + +
+ ); +} diff --git a/desktop/src/features/profile/ui/ProfilePanelPrimitives.tsx b/desktop/src/features/profile/ui/ProfilePanelPrimitives.tsx new file mode 100644 index 000000000..ef1531701 --- /dev/null +++ b/desktop/src/features/profile/ui/ProfilePanelPrimitives.tsx @@ -0,0 +1,157 @@ +import type { LucideIcon } from "lucide-react"; +import { ArrowUpRight, Copy } from "lucide-react"; +import type * as React from "react"; +import { toast } from "sonner"; + +import { cn } from "@/shared/lib/cn"; + +const PANEL_SURFACE_CLASS = "overflow-hidden rounded-2xl bg-muted/20"; +const PANEL_ICON_CLASS = + "flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted/60"; +const PANEL_ROW_BASE_CLASS = + "flex w-full gap-3 px-4 text-left transition-colors"; +const PANEL_ROW_INTERACTIVE_CLASS = "hover:bg-muted/40"; + +async function copyToClipboard(value: string, label?: string) { + await navigator.clipboard.writeText(value); + toast.success(label ? `Copied ${label}` : "Copied to clipboard"); +} + +export function ProfilePanelSurface({ + children, + className, + testId, +}: { + children: React.ReactNode; + className?: string; + testId?: string; +}) { + return ( +
+ {children} +
+ ); +} + +export function ProfilePanelIcon({ icon: Icon }: { icon: LucideIcon }) { + return ( + + + + ); +} + +type ProfilePanelRowProps = { + align?: "center" | "start"; + children?: React.ReactNode; + className?: string; + copyLabel?: string; + copyValue?: string; + icon: LucideIcon; + label: React.ReactNode; + onClick?: () => void; + openLabel?: string; + testId?: string; + trailing?: React.ReactNode; + value?: React.ReactNode; + valueClassName?: string; + valueTitle?: string; +}; + +export function ProfilePanelRow({ + align = "center", + children, + className, + copyLabel, + copyValue, + icon, + label, + onClick, + openLabel, + testId, + trailing, + value, + valueClassName, + valueTitle, +}: ProfilePanelRowProps) { + const isCopyable = Boolean(copyValue); + const isActionable = Boolean(onClick); + const actionLabel = openLabel ?? (typeof label === "string" ? label : "row"); + + const defaultTrailing = + trailing !== undefined ? ( + trailing + ) : isActionable ? ( + + ) : isCopyable ? ( + + ) : null; + + const content = ( + <> + + + + {label} + + {children ?? + (value !== undefined ? ( + + {value} + + ) : null)} + + {defaultTrailing} + + ); + + const rowClassName = cn( + PANEL_ROW_BASE_CLASS, + align === "start" ? "items-start py-3" : "items-center py-3", + className, + ); + + if (isActionable) { + return ( + + ); + } + + if (isCopyable && copyValue) { + return ( + + ); + } + + return ( +
+ {content} +
+ ); +} diff --git a/desktop/src/features/profile/ui/UserProfileAgentActions.tsx b/desktop/src/features/profile/ui/UserProfileAgentActions.tsx new file mode 100644 index 000000000..7f3a62a6e --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfileAgentActions.tsx @@ -0,0 +1,125 @@ +import { CopyPlus, Download, Power, Settings, Trash2 } from "lucide-react"; + +import type { ManagedAgent } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { Switch } from "@/shared/ui/switch"; + +export function UserProfileAgentSettingsMenu({ + isPending, + managedAgent, + onDelete, + onDuplicatePersona, + onExportPersona, + onToggleAutoStart, + personaActionKey, +}: { + isPending: boolean; + managedAgent?: ManagedAgent; + onDelete?: () => void; + onDuplicatePersona?: () => void; + onExportPersona?: () => void; + onToggleAutoStart?: () => void; + personaActionKey?: string; +}) { + 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 hasActions = + canToggleAutoStart || hasPrimaryActions || Boolean(onDelete); + + if (!hasActions) { + return null; + } + + return ( + + + + + event.preventDefault()} + > + {canToggleAutoStart ? ( + { + event.preventDefault(); + onToggleAutoStart(); + }} + > + + + Auto-start + + event.stopPropagation()} + /> + + ) : null} + {onDuplicatePersona ? ( + + + Duplicate + + ) : null} + {onExportPersona ? ( + + + Export + + ) : null} + {onDelete && (canToggleAutoStart || hasPrimaryActions) ? ( + + ) : null} + {onDelete ? ( + + + Delete agent + + ) : null} + + + ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 335202c22..fe3ba2b6d 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -1,19 +1,47 @@ import * as React from "react"; -import { ArrowLeft, X } from "lucide-react"; +import { toast } from "sonner"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useAgentMemoryQuery, useIsManagedAgent, } from "@/features/agent-memory/hooks"; -import { MemoryRefreshButton } from "@/features/agent-memory/ui/MemorySection"; import { + type AttachManagedAgentToChannelResult, + useAcpRuntimesQuery, + useAvailableAcpRuntimes, + useCreateManagedAgentMutation, + useCreatePersonaMutation, + useDeleteManagedAgentMutation, + useDeletePersonaMutation, + useExportPersonaJsonMutation, + useManagedAgentLogQuery, useRelayAgentsQuery, useManagedAgentsQuery, + usePersonasQuery, + useSetManagedAgentStartOnAppLaunchMutation, + useSetPersonaActiveMutation, + useStartManagedAgentMutation, + useStopManagedAgentMutation, + useUpdateManagedAgentMutation, + useUpdatePersonaMutation, } from "@/features/agents/hooks"; +import { AddAgentToChannelDialog } from "@/features/agents/ui/AddAgentToChannelDialog"; import { useActiveAgentTurnsBridge } from "@/features/agents/activeAgentTurnsStore"; +import { resolvePersonaRuntime } from "@/features/agents/lib/resolvePersonaRuntime"; +import { + isManagedAgentActive, + startManagedAgentWithRules, + stopManagedAgentWithRules, +} from "@/features/agents/lib/managedAgentControlActions"; import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; +import { describeLogFile } from "@/features/agents/ui/agentUi"; import { EditAgentDialog } from "@/features/agents/ui/EditAgentDialog"; +import { + duplicatePersonaDialogState, + editPersonaDialogState, + type PersonaDialogState, +} from "@/features/agents/ui/personaDialogState"; import { useChannelsQuery } from "@/features/channels/hooks"; import { usePresenceQuery } from "@/features/presence/hooks"; import { @@ -24,111 +52,48 @@ import { useUserProfileQuery, } from "@/features/profile/hooks"; import { + AgentInfoFocusedView, + AgentInstructionsFocusedView, ChannelsFocusedView, + DiagnosticsFocusedView, MemoryFocusedView, ProfileSummaryView, } from "@/features/profile/ui/UserProfilePanelSections"; +import { AgentConfigurationFocusedView } from "@/features/profile/ui/UserProfilePanelAgentDetails"; +import { UserProfileAgentSettingsMenu } from "@/features/profile/ui/UserProfileAgentActions"; +import { useProfileAgentDeletion } from "@/features/profile/ui/UserProfilePanelDeletion"; +import { useProfileFieldBuckets } from "@/features/profile/ui/UserProfilePanelFields"; +import { submitProfilePersonaDialog } from "@/features/profile/ui/UserProfilePanelPersonaSubmit"; +import { UserProfilePersonaDialogs } from "@/features/profile/ui/UserProfilePersonaDialogs"; +import { + deriveProfileChannels, + type ProfilePanelTab, + type ProfilePanelView, + resolveAgentInstruction, + resolvePanelProfile, + resolveProfileDisplayName, + truncatePubkey, + type UserProfilePanelProps, + useRetainedPersona, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { useUserStatusQuery } from "@/features/user-status/hooks"; import { useAgentSession } from "@/shared/context/AgentSessionContext"; import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; -import { THREAD_PANEL_MIN_WIDTH_PX } from "@/shared/hooks/useThreadPanelWidth"; -import { - AuxiliaryPanelHeader, - AuxiliaryPanelHeaderGroup, - AuxiliaryPanelTitle, - auxiliaryPanelContentPaddingClass, -} from "@/shared/layout/AuxiliaryPanelHeader"; +import { auxiliaryPanelContentPaddingClass } from "@/shared/layout/AuxiliaryPanelHeader"; import { cn } from "@/shared/lib/cn"; -import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types"; -import { Button } from "@/shared/ui/button"; -import { - OverlayPanelBackdrop, - PANEL_BASE_CLASS, - PANEL_OVERLAY_CLASS, - PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS, -} from "@/shared/ui/OverlayPanelBackdrop"; - -type UserProfilePanelProps = { - canResetWidth?: boolean; - currentPubkey?: string; - isSinglePanelView?: boolean; - layout?: "standalone" | "split"; - onClose: () => void; - onOpenDm?: (pubkeys: string[]) => void; - onOpenProfile?: (pubkey: string) => void; - onResetWidth?: () => void; - onResizeStart?: (event: React.PointerEvent) => void; - onViewChange: ( - view: ProfilePanelView, - options?: { replace?: boolean }, - ) => void; - pubkey: string; - /** - * When true, the panel sits beside a sibling pane managed by a single-panel - * width controller (ChannelScreen). The width is clamped so the sibling keeps - * at least THREAD_PANEL_MIN_WIDTH_PX. Standalone/floating mounts (e.g. Pulse) - * have no such sibling, so they omit this and use the configured width - * directly — otherwise `calc(100% - 300px)` would wrongly shrink the panel. - */ - splitPaneClamp?: boolean; - view: ProfilePanelView; - widthPx: number; -}; - -export type ProfilePanelView = "summary" | "memories" | "channels"; - -const VIEW_TITLES: Record = { - summary: "Profile", - memories: "Memories", - channels: "Channels", -}; - -function truncatePubkey(pubkey: string) { - if (pubkey.length <= 16) { - return pubkey; - } - - return `${pubkey.slice(0, 8)}…${pubkey.slice(-8)}`; -} - -type ProfileChannelLink = { - id: string; - name: string; -}; - -function deriveProfileChannels( - pubkeyLower: string, - relayAgent: RelayAgent | undefined, - managedAgent: ManagedAgent | undefined, - channels: Channel[] | undefined, -): ProfileChannelLink[] { - const links = new Map(); - 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), - ); -} +import type { + AgentPersona, + Channel, + CreateManagedAgentInput, + CreatePersonaInput, + ManagedAgent, + UpdatePersonaInput, +} from "@/shared/api/types"; +import { UserProfilePanelFrame } from "@/features/profile/ui/UserProfilePanelFrame"; +import { getUserProfilePanelHeaderContent } from "@/features/profile/ui/UserProfilePanelHeaderContent"; + +export type { ProfilePanelTab, ProfilePanelView }; export function UserProfilePanel({ canResetWidth, @@ -140,10 +105,13 @@ export function UserProfilePanel({ onOpenProfile, onResetWidth, onResizeStart, + onTabChange, onViewChange, + persona, pubkey, splitPaneClamp = false, - view, + tab: controlledTab, + view: controlledView, widthPx, }: UserProfilePanelProps) { const isOverlay = useIsThreadPanelOverlay(); @@ -151,45 +119,142 @@ export function UserProfilePanel({ const isSplitLayout = layout === "split"; useEscapeKey(onClose, isOverlay || isSinglePanelView); + const [internalView, setInternalView] = + React.useState("summary"); + const view = controlledView ?? internalView; + const setView = React.useCallback( + (nextView: ProfilePanelView, options?: { replace?: boolean }) => { + if (onViewChange) { + onViewChange(nextView, options); + return; + } + setInternalView(nextView); + }, + [onViewChange], + ); + const [internalTab, setInternalTab] = React.useState("info"); + const tab = controlledTab ?? internalTab; + const setTab = React.useCallback( + (nextTab: ProfilePanelTab, options?: { replace?: boolean }) => { + if (onTabChange) { + onTabChange(nextTab, options); + return; + } + setInternalTab(nextTab); + }, + [onTabChange], + ); const [editAgentOpen, setEditAgentOpen] = React.useState(false); + const [addToChannelOpen, setAddToChannelOpen] = React.useState(false); + const [personaDialogState, setPersonaDialogState] = + React.useState(null); + const [personaToDelete, setPersonaToDelete] = + React.useState(null); + + const personasQuery = usePersonasQuery(); + const managedAgentsQuery = useManagedAgentsQuery({ enabled: true }); + const managedAgent = React.useMemo(() => { + const agents = managedAgentsQuery.data ?? []; + if (pubkey) { + const pubkeyLower = pubkey.toLowerCase(); + return agents.find((agent) => agent.pubkey.toLowerCase() === pubkeyLower); + } + if (persona) { + return agents.find((agent) => agent.personaId === persona.id); + } + return undefined; + }, [managedAgentsQuery.data, persona, pubkey]); + const resolvedPersonaFromSource = React.useMemo(() => { + const personaId = persona?.id ?? managedAgent?.personaId; + if (personaId) { + const refreshedPersona = personasQuery.data?.find( + (candidate) => candidate.id === personaId, + ); + if (refreshedPersona) { + return refreshedPersona; + } + } + if (persona) { + return persona; + } + if (!managedAgent?.personaId) { + return undefined; + } + return personasQuery.data?.find( + (candidate) => candidate.id === managedAgent.personaId, + ); + }, [managedAgent?.personaId, persona, personasQuery.data]); + const profileIdentityKey = + pubkey ?? managedAgent?.pubkey ?? `persona:${persona?.id ?? "unknown"}`; + const resolvedPersona = useRetainedPersona( + resolvedPersonaFromSource, + profileIdentityKey, + ); + const effectivePubkey = pubkey ?? managedAgent?.pubkey ?? null; + const pubkeyLower = effectivePubkey?.toLowerCase() ?? ""; - const profileQuery = useUserProfileQuery(pubkey); + const profileQuery = useUserProfileQuery(effectivePubkey ?? undefined); const currentProfileQuery = useProfileQuery(currentPubkey !== undefined); - // Batch avatar prefetch seeds kind:0 summaries without `about`; refetch on open - // so the hero can show the full profile description from relay. React.useEffect(() => { + if (!effectivePubkey) return; void profileQuery.refetch(); - }, [profileQuery.refetch]); + }, [effectivePubkey, profileQuery.refetch]); const relayAgentsQuery = useRelayAgentsQuery({ enabled: true }); - const managedAgentsQuery = useManagedAgentsQuery({ enabled: true }); + const availableRuntimesQuery = useAvailableAcpRuntimes(); + const acpRuntimesQuery = useAcpRuntimesQuery(); + const createAgentMutation = useCreateManagedAgentMutation(); + const updateManagedAgentMutation = useUpdateManagedAgentMutation(); + const startAgentMutation = useStartManagedAgentMutation(); + const stopAgentMutation = useStopManagedAgentMutation(); + const deleteAgentMutation = useDeleteManagedAgentMutation(); + const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation(); + const createPersonaMutation = useCreatePersonaMutation(); + const updatePersonaMutation = useUpdatePersonaMutation(); + const deletePersonaMutation = useDeletePersonaMutation(); + const setPersonaActiveMutation = useSetPersonaActiveMutation(); + const exportPersonaJsonMutation = useExportPersonaJsonMutation(); const channelsQuery = useChannelsQuery(); - const presenceQuery = usePresenceQuery([pubkey]); - const userStatusQuery = useUserStatusQuery([pubkey]); + const presenceQuery = usePresenceQuery( + effectivePubkey ? [effectivePubkey] : [], + ); + const userStatusQuery = useUserStatusQuery( + effectivePubkey ? [effectivePubkey] : [], + ); const contactListQuery = useContactListQuery(currentPubkey); const followMutation = useFollowMutation(currentPubkey); const unfollowMutation = useUnfollowMutation(currentPubkey); const { onOpenAgentSession } = useAgentSession(); const { goChannel } = useAppNavigation(); - - const profile = profileQuery.data; + const profile = resolvePanelProfile({ + managedAgent, + persona: resolvedPersona, + profile: profileQuery.data, + }); const ownerPubkey = profile?.ownerPubkey ?? null; const ownerProfileQuery = useUserProfileQuery(ownerPubkey ?? undefined); - const pubkeyLower = pubkey.toLowerCase(); - const presenceStatus = presenceQuery.data?.[pubkeyLower]; - const userStatus = userStatusQuery.data?.[pubkeyLower]; + const presenceStatus = pubkeyLower + ? presenceQuery.data?.[pubkeyLower] + : undefined; + const userStatus = pubkeyLower + ? userStatusQuery.data?.[pubkeyLower] + : undefined; const relayAgent = relayAgentsQuery.data?.find( (agent) => agent.pubkey.toLowerCase() === pubkeyLower, ); - const managedAgent = managedAgentsQuery.data?.find( - (agent) => agent.pubkey.toLowerCase() === pubkeyLower, + const managedAgentLogQuery = useManagedAgentLogQuery( + (view === "diagnostics" || view === "logs") && + managedAgent?.backend.type === "local" + ? managedAgent.pubkey + : null, ); - const isBot = Boolean(relayAgent || managedAgent); - // Does THIS desktop hold the agent's seckey? Gates edit (which needs the key) - // and grants owner access when the agent is managed locally. - const isOwner = useIsManagedAgent(isBot ? pubkey : null); + const isBot = Boolean(relayAgent || managedAgent || resolvedPersona); + const managedAgentOwner = useIsManagedAgent(isBot ? effectivePubkey : null); + // Does THIS desktop hold the agent's seckey (or is this an editable persona)? + // Gates edit (which needs the key) and grants owner access when managed locally. + const isOwner = resolvedPersona ? true : managedAgentOwner; // Is the viewer the agent's declared owner (NIP-OA `ownerPubkey == me`)? This // is the right signal for viewing owner-scoped data (activity feed, memory): // the relay routes and the client decrypts those frames with the owner's OWN @@ -236,15 +301,40 @@ export function UserProfilePanel({ }, [managedAgent, relayAgent, viewerIsOwner]); useActiveAgentTurnsBridge(bridgeAgents); useManagedAgentObserverBridge(observerBridgeAgents); - const canEditAgent = isOwner === true && managedAgent !== undefined; - const memoryQuery = useAgentMemoryQuery(pubkey, { - enabled: viewerIsOwner, + const canEditAgent = + isOwner === true && + (managedAgent !== undefined || + (resolvedPersona !== undefined && !resolvedPersona.isBuiltIn)); + const memoryQuery = useAgentMemoryQuery(effectivePubkey, { + enabled: viewerIsOwner && Boolean(effectivePubkey), }); const isSelf = - currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase(); - const canViewActivity = viewerIsOwner && Boolean(onOpenAgentSession); + currentPubkey !== undefined && + pubkeyLower.length > 0 && + pubkeyLower === currentPubkey.toLowerCase(); + const canViewActivity = + viewerIsOwner && Boolean(onOpenAgentSession) && Boolean(effectivePubkey); + const canOpenAgentLogs = + isOwner === true && managedAgent?.backend.type === "local"; + const canInstantiateAgent = + isOwner === true && + resolvedPersona !== undefined && + managedAgent === undefined; + const isAgentActionPending = + createAgentMutation.isPending || + updateManagedAgentMutation.isPending || + startAgentMutation.isPending || + stopAgentMutation.isPending || + deleteAgentMutation.isPending || + startOnLaunchMutation.isPending || + createPersonaMutation.isPending || + updatePersonaMutation.isPending || + deletePersonaMutation.isPending || + setPersonaActiveMutation.isPending || + exportPersonaJsonMutation.isPending; const isFollowing = !isSelf && + pubkeyLower.length > 0 && (contactListQuery.data?.contacts.some( (contact) => contact.pubkey.toLowerCase() === pubkeyLower, ) ?? @@ -269,19 +359,326 @@ export function UserProfilePanel({ return map; }, [channelsQuery.data]); + const targetKey = + effectivePubkey ?? `persona:${resolvedPersona?.id ?? "unknown"}`; + const prevTargetKeyRef = React.useRef(targetKey); + React.useEffect(() => { + if (prevTargetKeyRef.current === targetKey) return; + prevTargetKeyRef.current = targetKey; + setView("summary", { replace: true }); + setTab("info", { replace: true }); + }, [setTab, setView, targetKey]); const handleMessage = React.useCallback(() => { - onOpenDm?.([pubkey]); + if (!effectivePubkey) return; + onOpenDm?.([effectivePubkey]); onClose(); - }, [onClose, onOpenDm, pubkey]); + }, [effectivePubkey, onClose, onOpenDm]); const handleEditAgent = React.useCallback(() => { + if (resolvedPersona && !resolvedPersona.isBuiltIn) { + setPersonaDialogState(editPersonaDialogState(resolvedPersona)); + return; + } setEditAgentOpen(true); - }, []); + }, [resolvedPersona]); + + const { deleteManagedAgentRecord, deleteManagedAgentsForPersona } = + useProfileAgentDeletion({ + channels: channelsQuery.data, + deleteManagedAgent: deleteAgentMutation.mutateAsync, + managedAgent, + managedAgents: managedAgentsQuery.data, + presenceLookup: presenceQuery.data, + relayAgents: relayAgentsQuery.data, + }); + + const createManagedAgentForPersona = React.useCallback( + async (personaToStart: AgentPersona) => { + const runtimes = availableRuntimesQuery.data ?? []; + const defaultRuntime = runtimes[0] ?? null; + const { runtime, warnings } = resolvePersonaRuntime( + personaToStart.runtime, + runtimes, + defaultRuntime, + ); + + for (const warning of warnings) { + toast.warning(warning); + } + + if (!runtime) { + throw new Error("No available runtime found for this agent."); + } + + const input: CreateManagedAgentInput = { + name: personaToStart.displayName, + acpCommand: "buzz-acp", + agentCommand: runtime.command, + agentArgs: runtime.defaultArgs, + mcpCommand: runtime.mcpCommand ?? "", + personaId: personaToStart.id, + systemPrompt: personaToStart.systemPrompt, + avatarUrl: personaToStart.avatarUrl ?? undefined, + model: personaToStart.model ?? undefined, + envVars: personaToStart.envVars, + spawnAfterCreate: true, + startOnAppLaunch: true, + backend: { type: "local" }, + }; + + const created = await createAgentMutation.mutateAsync(input); + void managedAgentsQuery.refetch(); + void relayAgentsQuery.refetch(); + return created; + }, + [ + availableRuntimesQuery.data, + createAgentMutation.mutateAsync, + managedAgentsQuery.refetch, + relayAgentsQuery.refetch, + ], + ); + + const handleAgentPrimaryAction = React.useCallback(async () => { + if (!managedAgent) return; + + try { + if (isManagedAgentActive(managedAgent)) { + const result = await stopManagedAgentWithRules({ + agent: managedAgent, + channels: channelsQuery.data ?? [], + relayAgents: relayAgentsQuery.data ?? [], + stopManagedAgent: stopAgentMutation.mutateAsync, + }); + toast.success(result.noticeMessage ?? `Stopped ${managedAgent.name}.`); + return; + } + + await startManagedAgentWithRules({ + agent: managedAgent, + startManagedAgent: startAgentMutation.mutateAsync, + }); + toast.success( + managedAgent.backend.type === "provider" + ? `Deploying ${managedAgent.name}.` + : `Started ${managedAgent.name}.`, + ); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Agent action failed.", + ); + } + }, [ + channelsQuery.data, + managedAgent, + relayAgentsQuery.data, + startAgentMutation.mutateAsync, + stopAgentMutation.mutateAsync, + ]); + + const handleInstantiateAgent = React.useCallback(async () => { + if (!resolvedPersona) return; + + try { + const created = await createManagedAgentForPersona(resolvedPersona); + if (created.spawnError) { + toast.error(created.spawnError); + } else { + toast.success(`Started ${created.agent.name}.`); + } + if (created.profileSyncError) { + toast.warning(created.profileSyncError); + } + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to start agent.", + ); + } + }, [createManagedAgentForPersona, resolvedPersona]); + + const handleToggleAgentAutoStart = React.useCallback(async () => { + if (managedAgent?.backend.type !== "local") return; + + try { + const updated = await startOnLaunchMutation.mutateAsync({ + pubkey: managedAgent.pubkey, + startOnAppLaunch: !managedAgent.startOnAppLaunch, + }); + toast.success( + updated.startOnAppLaunch + ? `Will start ${updated.name} automatically.` + : `${updated.name} will stay manual-start only.`, + ); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to update startup preference.", + ); + } + }, [managedAgent, startOnLaunchMutation.mutateAsync]); + + const handleDeleteAgent = React.useCallback(async () => { + if (!managedAgent) return; + + try { + const result = await deleteManagedAgentRecord(managedAgent); + if (result.cancelled) return; + + toast.success(`Deleted ${managedAgent.name}.`); + onClose(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to delete agent.", + ); + } + }, [deleteManagedAgentRecord, managedAgent, onClose]); + + const handleSubmitPersona = React.useCallback( + async (input: CreatePersonaInput | UpdatePersonaInput) => { + await submitProfilePersonaDialog({ + createManagedAgentForPersona, + createPersona: createPersonaMutation.mutateAsync, + input, + managedAgent, + onDone: () => { + setPersonaDialogState(null); + void personasQuery.refetch(); + }, + previousPersona: resolvedPersona, + runtimes: acpRuntimesQuery.data ?? [], + updateManagedAgent: updateManagedAgentMutation.mutateAsync, + updatePersona: updatePersonaMutation.mutateAsync, + }); + }, + [ + createPersonaMutation.mutateAsync, + createManagedAgentForPersona, + managedAgent, + personasQuery.refetch, + resolvedPersona, + acpRuntimesQuery.data, + updateManagedAgentMutation.mutateAsync, + updatePersonaMutation.mutateAsync, + ], + ); + + const handleEditPersona = React.useCallback(() => { + if (!resolvedPersona || resolvedPersona.isBuiltIn) return; + setPersonaDialogState(editPersonaDialogState(resolvedPersona)); + }, [resolvedPersona]); + + const handleDuplicatePersona = React.useCallback(() => { + if (!resolvedPersona) return; + setPersonaDialogState(duplicatePersonaDialogState(resolvedPersona)); + }, [resolvedPersona]); + + const handleExportPersona = React.useCallback(() => { + if (!resolvedPersona) return; + exportPersonaJsonMutation.mutate(resolvedPersona.id, { + onSuccess: (saved) => { + if (saved) { + toast.success(`Exported ${resolvedPersona.displayName}.`); + } + }, + onError: (error) => { + toast.error( + error instanceof Error ? error.message : "Failed to export agent.", + ); + }, + }); + }, [exportPersonaJsonMutation, resolvedPersona]); + + const handleDeletePersona = React.useCallback(async () => { + if (!resolvedPersona) return; + + if (resolvedPersona.isBuiltIn) { + try { + const deletedInstances = + await deleteManagedAgentsForPersona(resolvedPersona); + if (deletedInstances.cancelled) return; + + await setPersonaActiveMutation.mutateAsync({ + id: resolvedPersona.id, + active: false, + }); + toast.success(`Removed ${resolvedPersona.displayName} from My Agents.`); + onClose(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to delete agent.", + ); + } + return; + } + + if (resolvedPersona.sourceTeam) { + toast.error("This agent is managed by a team."); + return; + } + + setPersonaToDelete(resolvedPersona); + }, [ + deleteManagedAgentsForPersona, + onClose, + resolvedPersona, + setPersonaActiveMutation.mutateAsync, + ]); + + const handleConfirmDeletePersona = React.useCallback( + async (personaToConfirm: AgentPersona) => { + if (personaToConfirm.sourceTeam) { + toast.error("This agent is managed by a team."); + setPersonaToDelete(null); + return; + } + + try { + const deletedInstances = + await deleteManagedAgentsForPersona(personaToConfirm); + if (deletedInstances.cancelled) return; + + await deletePersonaMutation.mutateAsync(personaToConfirm.id); + toast.success(`Deleted ${personaToConfirm.displayName}.`); + setPersonaToDelete(null); + onClose(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to delete agent.", + ); + } + }, + [deleteManagedAgentsForPersona, deletePersonaMutation.mutateAsync, onClose], + ); + + const handleAddedToChannel = React.useCallback( + (channel: Channel, result: AttachManagedAgentToChannelResult) => { + if (result.restarted) { + toast.success( + `Added ${result.agent.name} to ${channel.name} and restarted it.`, + ); + } else if (result.started) { + toast.success(`Added ${result.agent.name} to ${channel.name}.`); + } else if (result.membershipAdded) { + toast.success(`Added ${result.agent.name} to ${channel.name}.`); + } else { + toast.success(`${result.agent.name} is already in ${channel.name}.`); + } + void managedAgentsQuery.refetch(); + void relayAgentsQuery.refetch(); + void channelsQuery.refetch(); + }, + [ + channelsQuery.refetch, + managedAgentsQuery.refetch, + relayAgentsQuery.refetch, + ], + ); const handleOpenActivity = React.useCallback(() => { - onClose(); - onOpenAgentSession?.(pubkey); - }, [onClose, onOpenAgentSession, pubkey]); + if (!effectivePubkey) return; + onOpenAgentSession?.(effectivePubkey); + }, [effectivePubkey, onOpenAgentSession]); const handleOpenChannel = React.useCallback( (channelId: string) => { @@ -290,7 +687,11 @@ export function UserProfilePanel({ [goChannel], ); - const displayName = profile?.displayName ?? truncatePubkey(pubkey); + const displayName = resolveProfileDisplayName({ + persona: resolvedPersona, + profile, + pubkey: effectivePubkey, + }); const ownerHandle = React.useMemo(() => { if (ownerPubkey) { const ownerProfile = ownerProfileQuery.data; @@ -323,115 +724,204 @@ export function UserProfilePanel({ ? `${ownerHandle} (you)` : ownerHandle : null; - const panelTitle = VIEW_TITLES[view]; - const memoryCount = memoryQuery.data - ? (memoryQuery.data.core ? 1 : 0) + memoryQuery.data.memories.length - : undefined; - - const headerLeftContent = ( - - {view !== "summary" ? ( - - ) : null} - {panelTitle} - + const ownerProfilePubkey = + ownerPubkey ?? (isOwner === true ? (currentPubkey ?? null) : null); + const ownerAvatarProfile = ownerPubkey + ? ownerProfileQuery.data + : currentProfileQuery.data; + const memoryCount = + memoryQuery.data && + (memoryQuery.data.core ? 1 : 0) + memoryQuery.data.memories.length; + const agentInstruction = resolveAgentInstruction( + managedAgent, + resolvedPersona, ); - - const headerActions = ( -
- {view === "memories" && viewerIsOwner ? ( - - ) : null} - -
+ const canManagePersona = isOwner === true && resolvedPersona !== undefined; + const canEditPersona = + canManagePersona && resolvedPersona?.isBuiltIn !== true; + const canDeletePersona = canManagePersona && !resolvedPersona?.sourceTeam; + const agentSettingsMenu = + viewerIsOwner && managedAgent ? ( + + ) : canInstantiateAgent ? ( + + ) : null; + const { + agentInfoFields, + agentSettingsFields, + diagnosticsFields, + modelLabel, + } = useProfileFieldBuckets({ + isBot, + isOwner, + managedAgent, + onOpenProfile, + ownerAvatarUrl: ownerAvatarProfile?.avatarUrl ?? null, + ownerDisplayName, + ownerHandle, + ownerProfilePubkey, + ownerPubkey, + persona: resolvedPersona, + presenceLoaded: presenceQuery.isSuccess, + presenceStatus, + profile, + pubkey: effectivePubkey, + relayAgent, + }); + const isDiagnosticsLikeView = view === "diagnostics" || view === "logs"; + const managedAgentLogContent = managedAgentLogQuery.data?.content ?? null; + const logHeaderSubtitle = + isDiagnosticsLikeView && managedAgent + ? `${managedAgent.name} · ${describeLogFile(managedAgent.logPath)}` + : null; + const { headerActions, headerLeftContent } = getUserProfilePanelHeaderContent( + { + agentSettingsMenu, + effectivePubkey, + logCopyValue: isDiagnosticsLikeView ? managedAgentLogContent : null, + logSubtitle: logHeaderSubtitle, + onBack: () => setView("summary"), + onClose, + view, + viewerIsOwner, + }, ); const profileBody = (
{view === "summary" ? ( onViewChange("channels")} - onOpenOwner={ - ownerPubkey && onOpenProfile - ? () => onOpenProfile(ownerPubkey) - : undefined - } - onOpenMemories={() => onViewChange("memories")} + modelLabel={modelLabel} + agentInfoFields={agentInfoFields} + agentSettingsFields={agentSettingsFields} + diagnosticsFields={diagnosticsFields} + onAddToChannel={() => setAddToChannelOpen(true)} + onOpenActivity={handleOpenActivity} + onOpenChannel={handleOpenChannel} + onOpenDiagnostics={() => setView("diagnostics")} + onOpenInstructions={() => setView("instructions")} + onTabChange={setTab} onOpenDm={onOpenDm} - presenceLoaded={presenceQuery.isSuccess} presenceStatus={presenceStatus} profile={profile} - pubkey={pubkey} + pubkey={effectivePubkey} relayAgent={relayAgent} + tab={tab} unfollowMutation={unfollowMutation} userStatus={userStatus} /> ) : null} - - {view === "memories" ? ( - + {view === "memories" && effectivePubkey ? ( + + ) : null} + {view === "info" ? ( + + ) : null} + {view === "configuration" ? ( + + ) : null} + {view === "instructions" ? ( + + ) : null} + {view === "diagnostics" ? ( + ) : null} - {view === "channels" ? ( setAddToChannelOpen(true)} onOpenChannel={handleOpenChannel} /> ) : null} + {view === "logs" ? ( + + ) : null}
); - const editAgentDialog = canEditAgent && managedAgent ? ( ) : null; - - if (isSplitLayout) { - return ( - <> -
- - {headerLeftContent} - {headerActions} - - {profileBody} -
- {editAgentDialog} - - ); - } - + const addAgentToChannelDialog = managedAgent ? ( + + ) : null; + const personaDialogs = ( + setPersonaToDelete(null)} + onCloseDialog={() => setPersonaDialogState(null)} + onConfirmDelete={(selectedPersona) => { + void handleConfirmDeletePersona(selectedPersona); + }} + onSubmit={handleSubmitPersona} + /> + ); return ( - <> - {isFloatingOverlay && } - - {editAgentDialog} - + ); } diff --git a/desktop/src/features/profile/ui/UserProfilePanelAgentDetails.tsx b/desktop/src/features/profile/ui/UserProfilePanelAgentDetails.tsx new file mode 100644 index 000000000..a187ab560 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelAgentDetails.tsx @@ -0,0 +1,259 @@ +import { ChevronRight, Cpu, MessageSquare } from "lucide-react"; + +import type { ManagedAgent } from "@/shared/api/types"; +import { Markdown } from "@/shared/ui/markdown"; +import { + ProfilePanelIcon, + ProfilePanelRow, + ProfilePanelSurface, +} from "@/features/profile/ui/ProfilePanelPrimitives"; +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 ( +
+ +
+ ); +} + +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 ( + + + + ); +} + +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 ? ( + + ) : null} + + {showModelRow ? ( + + ) : null} + + {fields.length > 0 ? : 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 = ( + <> + +
+
Instructions
+ {trimmedInstruction ? ( + canOpenInstructions ? ( + + {trimmedInstruction} + + ) : ( +
+ +
+ ) + ) : ( +

+ No instruction set. +

+ )} +
+ {canOpenInstructions ? ( + + ) : null} + + ); + + if (canOpenInstructions) { + return ( + + ); + } + + return
{rowContent}
; +} + +export function AgentInstructionsFocusedView({ + instruction, +}: { + instruction: string | null; +}) { + const trimmedInstruction = instruction?.trim() ?? ""; + + return ( +
+
+ {trimmedInstruction ? ( + + ) : ( +

+ No instruction set. +

+ )} +
+
+ ); +} + +function AgentModelRow({ modelLabel }: { modelLabel: string }) { + return ( + + ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelDeletion.ts b/desktop/src/features/profile/ui/UserProfilePanelDeletion.ts new file mode 100644 index 000000000..2e4b30135 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelDeletion.ts @@ -0,0 +1,159 @@ +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[0], + "agent" +>; + +type DeleteProfileManagedAgentContext = DeleteManagedAgentRulesContext & { + removeAgentFromAllChannels: (pubkey: string) => Promise; +}; + +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, + }), + [ + 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 { + 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 { + const { managedAgents, selectedAgent, ...deleteContext } = context; + const agentsByPubkey = new Map(); + + 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 {}; +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelFields.tsx b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx new file mode 100644 index 000000000..19509b4af --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelFields.tsx @@ -0,0 +1,468 @@ +import type { LucideIcon } from "lucide-react"; +import { + Activity, + Cpu, + Ear, + Fingerprint, + Server, + Terminal, + UserRound, +} from "lucide-react"; +import * as React from "react"; + +import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; +import { runtimeLabel } from "@/features/profile/lib/agentLabels"; +import { truncatePubkey as truncatePubkeyShort } from "@/features/profile/lib/identity"; +import { + ProfilePanelRow, + ProfilePanelSurface, +} from "@/features/profile/ui/ProfilePanelPrimitives"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; +import type { + AgentPersona, + ManagedAgent, + Profile, + RelayAgent, +} from "@/shared/api/types"; + +export type ProfileField = { + copyValue?: string; + displayValue: string; + displayNode?: React.ReactNode; + icon: LucideIcon; + label: string; + onClick?: () => void; + testId?: string; +}; + +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 = ( + <> + + {ownerDisplayName} + + ); + + if (ownerDisplayName) { + fields.push({ + copyValue: ownerClickable + ? undefined + : (ownerProfilePubkey ?? ownerPubkey ?? ownerHandle ?? undefined), + displayValue: ownerDisplayName, + displayNode: ( + + {ownerContent} + + ), + 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: ( + + ), + 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 publicKeyLabel = "Public key"; + const ownedByLabel = "Owned by"; + const statusLabel = "Status"; + return [ + ...fields.filter((field) => field.label === publicKeyLabel), + ...fields.filter((field) => field.label === ownedByLabel), + ...fields.filter( + (field) => + field.label !== publicKeyLabel && + field.label !== ownedByLabel && + field.copyValue, + ), + ...fields.filter((field) => field.label === statusLabel), + ...fields.filter((field) => { + if ( + 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) => ( + + ))} + + ); +} + +export function ProfileFieldGroup({ fields }: { fields: ProfileField[] }) { + return ( +
+ + + +
+ ); +} + +function ProfileFieldRow({ field }: { field: ProfileField }) { + return ( + + ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelFrame.tsx b/desktop/src/features/profile/ui/UserProfilePanelFrame.tsx new file mode 100644 index 000000000..512d583ab --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelFrame.tsx @@ -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_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; + 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 ( + <> +
+ + {headerLeftContent} + {headerActions} + + {profileBody} +
+ {editAgentDialog} + {addAgentToChannelDialog} + {personaDialogs} + + ); + } + + return ( + <> + {isFloatingOverlay && } + + {editAgentDialog} + {addAgentToChannelDialog} + {personaDialogs} + + ); +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelHeaderContent.tsx b/desktop/src/features/profile/ui/UserProfilePanelHeaderContent.tsx new file mode 100644 index 000000000..ab9e3f0c0 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelHeaderContent.tsx @@ -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 = ( + + {view !== "summary" ? ( + + ) : null} + {shouldShowLogDetails ? ( +
+ + {title} + +

+ {logSubtitle} +

+
+ ) : ( + {title} + )} +
+ ); + const headerActions = ( +
+ {view === "memories" && viewerIsOwner && effectivePubkey ? ( + + ) : null} + {view === "summary" ? agentSettingsMenu : null} + {shouldShowLogDetails ? ( + + ) : null} + +
+ ); + + return { headerActions, headerLeftContent }; +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs new file mode 100644 index 000000000..406cc3f09 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.test.mjs @@ -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, + ); +}); diff --git a/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts new file mode 100644 index 000000000..eff9a1af1 --- /dev/null +++ b/desktop/src/features/profile/ui/UserProfilePanelPersonaSubmit.ts @@ -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; + createPersona: (input: CreatePersonaInput) => Promise; + 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; +}; + +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.", + ); + } +} diff --git a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx index 42a3739c3..1c084fbf1 100644 --- a/desktop/src/features/profile/ui/UserProfilePanelSections.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanelSections.tsx @@ -1,165 +1,303 @@ import * as React from "react"; import type { LucideIcon } from "lucide-react"; import { - Activity, ArrowUpRight, - Brain, ChevronDown, - ChevronRight, ChevronUp, - Copy, - Cpu, - Fingerprint, - Hash, + CircleAlert, MessageSquare, Pencil, - Server, - Terminal, + Play, + Square, UserMinus, UserPlus, - UserRound, } from "lucide-react"; import { toast } from "sonner"; import { MemorySection } from "@/features/agent-memory/ui/MemorySection"; -import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge"; import { useActiveAgentTurns } from "@/features/agents/activeAgentTurnsStore"; -import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; +import { getManagedAgentPrimaryActionLabel } from "@/features/agents/lib/managedAgentControlActions"; +import { ManagedAgentLogPanel } from "@/features/agents/ui/ManagedAgentLogPanel"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; -import { getPresenceLabel } from "@/features/presence/lib/presence"; -import { PresenceDot } from "@/features/presence/ui/PresenceBadge"; import type { useFollowMutation, useUnfollowMutation, useUserProfileQuery, } from "@/features/profile/hooks"; -import { truncatePubkey as truncatePubkeyShort } from "@/features/profile/lib/identity"; -import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; +import { AgentWorkingBadge } from "@/features/profile/ui/AgentWorkingBadge"; +import { + type ProfileField, + ProfileFieldGroup, +} from "@/features/profile/ui/UserProfilePanelFields"; +import { AGENT_DETAILS_FIELD_LABELS } from "@/features/profile/ui/UserProfilePanelAgentDetails"; +import { + ProfileInfoTabContent, + ProfileIngressRow, + ProfileRuntimeTabContent, + ProfileTabBar, +} from "@/features/profile/ui/UserProfilePanelTabs"; +import { ProfileAvatarWithPresence } from "@/features/profile/ui/ProfileAvatarWithPresence"; +import { ProfilePanelSurface } from "@/features/profile/ui/ProfilePanelPrimitives"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import { BotIdenticon } from "@/features/messages/ui/BotIdenticon"; import type { ManagedAgent, RelayAgent } from "@/shared/api/types"; +import type { + ProfileChannelLink, + ProfilePanelTab, +} from "@/features/profile/ui/UserProfilePanelUtils"; import { useFeatureEnabled } from "@/shared/features"; import { cn } from "@/shared/lib/cn"; -import { useNow } from "@/shared/lib/useNow"; +import { Alert, AlertDescription, AlertTitle } from "@/shared/ui/alert"; import { Badge } from "@/shared/ui/badge"; -import { UserAvatar } from "@/shared/ui/UserAvatar"; - -const RUNTIME_LABELS: Record = { - goose: "Goose", - "claude-code": "Claude Code", - "codex-acp": "Codex", - aider: "Aider", -}; - -function runtimeLabel(command: string): string { - return RUNTIME_LABELS[command] ?? command; -} +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; -async function copyToClipboard(value: string, label?: string) { - await navigator.clipboard.writeText(value); - toast.success(label ? `Copied ${label}` : "Copied to clipboard"); -} +export { AgentInstructionsFocusedView } from "@/features/profile/ui/UserProfilePanelAgentDetails"; // ── Summary view ───────────────────────────────────────────────────────────── export type ProfileSummaryViewProps = { + canAddToChannel: boolean; canEditAgent: boolean; + canOpenAgentLogs: boolean; canViewActivity: boolean; channelCount: number; channelIdToName: Record; + channels: ProfileChannelLink[]; channelsLoading: boolean; displayName: string; followMutation: ReturnType; + canInstantiateAgent: boolean; + agentInstruction: string | null; + handleAgentPrimaryAction: () => void; handleEditAgent: () => void; + handleEditPersona?: () => void; + handleInstantiateAgent: () => void; handleMessage: () => void; - handleOpenActivity: () => void; isBot: boolean; + isAgentActionPending: boolean; isFollowing: boolean; isOwner: boolean | undefined; isSelf: boolean; managedAgent: ManagedAgent | undefined; memoriesLoading: boolean; memoryCount: number | undefined; - ownerDisplayName: string | null; - ownerAvatarUrl: string | null; - ownerHandle: string | null; - ownerPubkey: string | null; - onOpenChannels: () => void; - onOpenOwner?: () => void; - onOpenMemories: () => void; + modelLabel: string; + agentInfoFields: ProfileField[]; + agentSettingsFields: ProfileField[]; + diagnosticsFields: ProfileField[]; + onAddToChannel: () => void; + onOpenActivity: () => void; + onOpenChannel: (channelId: string) => void; + onOpenDiagnostics: () => void; + onOpenInstructions: () => void; + onTabChange: (tab: ProfilePanelTab, options?: { replace?: boolean }) => void; onOpenDm?: (pubkeys: string[]) => void; - presenceLoaded: boolean; presenceStatus: "online" | "away" | "offline" | undefined; profile: ReturnType["data"]; - pubkey: string; + pubkey: string | null; relayAgent: RelayAgent | undefined; + tab: ProfilePanelTab; unfollowMutation: ReturnType; userStatus: { text: string; emoji: string } | null | undefined; }; +type RuntimeTabStatus = "running" | "stopped" | "error"; + +function resolveRuntimeTabStatus({ + diagnosticsError, + managedAgent, +}: { + diagnosticsError: boolean; + managedAgent: ManagedAgent | undefined; +}): RuntimeTabStatus | undefined { + if (diagnosticsError || managedAgent?.lastError) { + return "error"; + } + + if (!managedAgent) { + return undefined; + } + + if (managedAgent.status === "running" || managedAgent.status === "deployed") { + return "running"; + } + + return "stopped"; +} + +function RuntimeTabStatusDot({ status }: { status: RuntimeTabStatus }) { + const label = + status === "error" ? "Error" : status === "running" ? "Running" : "Stopped"; + + return ( +