feat: agent memory viewer (read-only) in profile panel (#917)

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Bart Simpson <bart@sprout.local>
This commit is contained in:
Taylor Ho
2026-06-10 08:32:38 -07:00
committed by GitHub
parent 2dc466fe0b
commit 384eb6cba4
22 changed files with 3022 additions and 509 deletions

View File

@@ -368,6 +368,69 @@ fn parse_strict_json(bytes: &[u8]) -> Result<serde_json::Value, EngramError> {
Ok(v)
}
// ── Reference extraction (`[[slug]]`) ───────────────────────────────────────
/// Extract `[[slug]]` references from a body's free-form text field
/// (`profile` for [`Body::Core`], `value` for [`Body::Memory`]).
///
/// Per *NIP-AE: References*, references are literal substrings of the form
/// `[[<slug>]]` where `<slug>` matches the *Slugs* grammar. Bare slug-shaped
/// strings without brackets are NOT references. The spec defines no escaping
/// mechanism and no markup-aware exclusion, so this scan is purely textual.
///
/// Returns the slugs in **first-occurrence order**, deduplicated. Candidates
/// that fail [`validate_slug`] are silently dropped: callers building a
/// reachability graph want only well-formed targets, and an empty `[[]]` or
/// a `[[bogus slug!]]` is treated the same as ordinary text.
///
/// This function performs no I/O and no allocation beyond the returned
/// `Vec<String>`.
pub fn extract_refs(body: &str) -> Vec<String> {
let bytes = body.as_bytes();
let mut out: Vec<String> = Vec::new();
let mut i = 0;
while i + 3 < bytes.len() {
// Need at least `[[x]]` — 5 bytes — to contain a non-empty payload.
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
let start = i + 2;
// Find the next `]]` after `start`. We do not allow nesting:
// the first `]]` closes the reference. If we hit another `[[`
// before `]]`, restart the scan from that inner `[[` so e.g.
// `[[outer [[mem/x]]` still surfaces `mem/x`.
let mut j = start;
let mut closed = false;
while j + 1 < bytes.len() {
if bytes[j] == b'[' && bytes[j + 1] == b'[' {
// Inner `[[` — abandon the outer match, restart there.
break;
}
if bytes[j] == b']' && bytes[j + 1] == b']' {
closed = true;
break;
}
j += 1;
}
if closed {
// SAFETY: `start` and `j` both sit on ASCII bracket
// boundaries; the slice between them is UTF-8 because the
// input is.
let candidate = &body[start..j];
if validate_slug(candidate).is_ok() && !out.iter().any(|s| s == candidate) {
out.push(candidate.to_string());
}
i = j + 2;
continue;
}
// Either we hit an inner `[[` or ran off the end without a
// closing `]]`. Advance past the opening `[[` and keep looking.
i = start;
continue;
}
i += 1;
}
out
}
// ── Envelope build / parse ──────────────────────────────────────────────────
/// Build a signed `kind:30174` event for a given body.
@@ -831,4 +894,166 @@ mod tests {
let err = build_event(&agent, &owner.public_key(), &body, 1).unwrap_err();
assert!(matches!(err, EngramError::BodyTooLarge(_)));
}
// ── extract_refs ────────────────────────────────────────────────────────
#[test]
fn extract_refs_empty_body() {
assert!(extract_refs("").is_empty());
}
#[test]
fn extract_refs_no_refs() {
assert!(extract_refs("plain prose with no brackets").is_empty());
assert!(extract_refs("single [bracket] only").is_empty());
assert!(extract_refs("mem/example without brackets").is_empty());
}
#[test]
fn extract_refs_basic_memory() {
assert_eq!(
extract_refs("see [[mem/example]] for context"),
vec!["mem/example".to_string()]
);
}
#[test]
fn extract_refs_basic_core() {
assert_eq!(
extract_refs("rooted at [[core]] yo"),
vec!["core".to_string()]
);
}
#[test]
fn extract_refs_multiple_in_order() {
assert_eq!(
extract_refs("[[mem/a]] then [[mem/b]] then [[mem/c]]"),
vec![
"mem/a".to_string(),
"mem/b".to_string(),
"mem/c".to_string(),
]
);
}
#[test]
fn extract_refs_dedupes_preserving_first_occurrence() {
assert_eq!(
extract_refs("[[mem/a]] [[mem/b]] [[mem/a]] [[mem/c]] [[mem/b]]"),
vec![
"mem/a".to_string(),
"mem/b".to_string(),
"mem/c".to_string(),
]
);
}
#[test]
fn extract_refs_nested_segments() {
assert_eq!(
extract_refs("see [[mem/notes/2026-05-12]]"),
vec!["mem/notes/2026-05-12".to_string()]
);
}
#[test]
fn extract_refs_spec_fixture_core_profile() {
// Body 4 from the NIP-AE reference vectors.
assert_eq!(
extract_refs("test agent. see [[mem/example]] and [[mem/notes/2026-05-12]]."),
vec![
"mem/example".to_string(),
"mem/notes/2026-05-12".to_string(),
]
);
}
#[test]
fn extract_refs_drops_empty_brackets() {
assert!(extract_refs("[[]]").is_empty());
assert!(extract_refs("ends with [[]] yo").is_empty());
}
#[test]
fn extract_refs_drops_invalid_slugs() {
// Uppercase, spaces, leading dash, missing `mem/` — all rejected by validate_slug.
assert!(extract_refs("[[Mem/Example]]").is_empty());
assert!(extract_refs("[[mem/with spaces]]").is_empty());
assert!(extract_refs("[[mem/-leading-dash]]").is_empty());
assert!(extract_refs("[[example]]").is_empty());
assert!(extract_refs("[[mem/]]").is_empty());
}
#[test]
fn extract_refs_unclosed_brackets() {
assert!(extract_refs("[[mem/x with no closing").is_empty());
assert_eq!(
extract_refs("[[mem/x with no close, but [[mem/y]] is fine"),
vec!["mem/y".to_string()]
);
}
#[test]
fn extract_refs_single_brackets_dont_match() {
assert!(extract_refs("[mem/x]").is_empty());
assert!(extract_refs("[mem/x] and [mem/y]").is_empty());
}
#[test]
fn extract_refs_triple_brackets_match_inner() {
// `[[[mem/x]]]` — the outer `[[` at position 0 opens; the inner
// scan walks through `[mem/x` and finds `]]` at positions 8-9, so
// the candidate is `[mem/x` (with the leading `[`), which fails
// `validate_slug` and is dropped. Surplus opening brackets without
// matching `]]` boundaries are noise.
assert!(extract_refs("[[[mem/x]]]").is_empty());
// `[[mem/x]]]` — `mem/x` matches; trailing `]` is just text.
assert_eq!(extract_refs("[[mem/x]]]"), vec!["mem/x".to_string()]);
}
#[test]
fn extract_refs_handles_utf8_around_brackets() {
assert_eq!(
extract_refs("héllo [[mem/example]] wörld 🎉"),
vec!["mem/example".to_string()]
);
}
#[test]
fn extract_refs_long_slug_at_limit() {
// Build a slug that's exactly SLUG_MAX_LEN bytes — must extract.
let segment = "a".repeat(64);
// mem/ (4) + segment (64) = 68 — well under the limit, but exercises
// a long single-segment slug.
let slug = format!("mem/{segment}");
let body = format!("[[{slug}]]");
assert_eq!(extract_refs(&body), vec![slug]);
}
#[test]
fn extract_refs_oversized_slug_dropped() {
// mem/ + 256 bytes = 260 bytes total > SLUG_MAX_LEN (255).
let slug = format!("mem/{}", "a".repeat(64));
// Repeat enough segments to bust the cap; each segment is 64+1 = 65 bytes.
let oversized = format!(
"{slug}/{}/{}/{}",
"b".repeat(64),
"c".repeat(64),
"d".repeat(64)
);
assert!(oversized.len() > SLUG_MAX_LEN);
let body = format!("[[{oversized}]]");
assert!(extract_refs(&body).is_empty());
}
#[test]
fn extract_refs_self_reference_is_allowed() {
// The function does not know "self"; consumers handle that. We just
// surface every well-formed `[[slug]]`.
assert_eq!(
extract_refs("I refer to [[mem/me]] from inside mem/me's value"),
vec!["mem/me".to_string()]
);
}
}

View File

@@ -0,0 +1,227 @@
//! Owner-gated engram (NIP-AE memory) reader for the desktop.
//!
//! IXI-7 phase 1: read-only memory surface inside the agent profile panel.
//! One Tauri call per panel open returns the entire decrypted listing —
//! `core` (if present), every non-tombstoned `mem/...` entry, and the
//! outgoing `[[slug]]` refs extracted from each body. The UI computes
//! reachability + orphans from this single payload; no incremental sync.
//!
//! Why this shape:
//! - Owner secret key never leaves Rust. The desktop signs in as the owner
//! and decrypts via `agent ↔ owner` NIP-44 conversation key.
//! - Owner gating is enforced here: the requested agent pubkey MUST exist
//! in the local `managed_agents` store. If not, the command refuses.
//! The UI hides the section anyway, but defense in depth.
//! - One call returns everything because the orphans view requires the
//! full set anyway. Lazy/per-node decrypt is deferred to IXI-60.
use std::collections::HashMap;
use std::time::SystemTime;
use nostr::PublicKey;
use serde::Serialize;
use tauri::{AppHandle, State};
use sprout_core::engram::{self, extract_refs, select_head, validate_and_decrypt, Body};
use sprout_core::kind::KIND_AGENT_ENGRAM;
use crate::{app_state::AppState, managed_agents::load_managed_agents, relay::query_relay};
/// Hard cap on engrams returned per (agent, owner) pair. Matches the CLI
/// `mem ls` reference. If the relay returns this many we set
/// `truncated = true` so the UI can warn that the list may be incomplete.
const ENGRAM_FETCH_LIMIT: u32 = 5000;
/// One memory entry returned to the UI.
///
/// `slug` is the canonical slug (`core` or `mem/foo/bar`). `body` is the
/// decrypted UTF-8 payload (profile text for core, value for memory).
/// `outgoing_refs` is the list of `[[slug]]` references extracted from the
/// body — used by the UI to BFS reachability from `core`.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EngramEntry {
pub slug: String,
pub body: String,
pub event_id: String,
pub created_at: u64,
pub outgoing_refs: Vec<String>,
}
/// Single-payload response for one panel open. `core` is split out because
/// the UI roots the reachability tree there; `memories` excludes core (and
/// tombstones) so it maps 1:1 to the `mem/...` set.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentMemoryListing {
/// `core` entry, if the agent has one.
pub core: Option<EngramEntry>,
/// All non-core, non-tombstoned memories. Sorted by slug.
pub memories: Vec<EngramEntry>,
/// True if the relay returned `>= ENGRAM_FETCH_LIMIT` events — list may
/// be incomplete. UI surfaces a warning. Tracked for follow-up in
/// IXI-60 (pagination + lazy decrypt).
pub truncated: bool,
/// Unix seconds when the response was assembled. UI uses this for
/// "last loaded" copy on the refetch affordance.
pub fetched_at: u64,
}
/// `get_agent_memory` — owner-gated single-payload engram listing.
///
/// Returns the full decrypted set for the (agent, owner) pair where
/// `owner = current viewer`. Refuses if the agent isn't in this desktop's
/// `managed_agents` store (i.e. the viewer is not its owner). This mirrors
/// the relay's hard refusal of cross-owner reads.
///
/// Errors are stringified for the Tauri bridge. The UI distinguishes
/// fetch error vs empty success vs success-with-data; an `Err(_)` return
/// is the "couldn't load" path. An empty `memories` Vec with `core: None`
/// is the legitimate "no memories" empty state.
#[tauri::command]
pub async fn get_agent_memory(
agent_pubkey: String,
app: AppHandle,
state: State<'_, AppState>,
) -> Result<AgentMemoryListing, String> {
// ── Owner gating ────────────────────────────────────────────────────
// The viewer (this desktop's identity) is the prospective owner. We
// accept the request only if the agent appears in our managed_agents
// store — that's the local source of truth for "agents I own". The UI
// must already have hidden the section for non-owners; this is just
// defense in depth.
//
// Why `managed_agents` rather than a NIP-OA `kind:0` lookup (the gate
// the archive command uses): the question this surface needs to answer
// is "do I have the seckey to decrypt this agent's engrams?", not
// "does this agent's `kind:0` declare me as its OA owner?". The former
// is what `managed_agents` records — it can't lie, you either have the
// key locally or you don't. The latter is forgeable (a malicious agent
// can put any pubkey in their `auth` tag) and would also require a
// relay roundtrip on every panel open. Keep this gate; don't swap it
// back to `resolve_oa_owner` "for consistency" — they're answering
// different questions.
let agent = PublicKey::from_hex(&agent_pubkey)
.map_err(|e| format!("agent pubkey must be 64-hex: {e}"))?;
let managed = load_managed_agents(&app)?;
if !managed.iter().any(|m| m.pubkey == agent_pubkey) {
return Err(format!(
"not the owner of agent {agent_pubkey} (no managed-agent record)"
));
}
// ── Resolve owner key material ──────────────────────────────────────
// Owner = viewer. Clone the secret key out of the lock immediately so
// we don't hold the mutex across the relay round trip.
let (owner_pubkey, owner_seckey) = {
let keys = state.keys.lock().map_err(|e| e.to_string())?;
(keys.public_key(), keys.secret_key().clone())
};
// ── Relay query ─────────────────────────────────────────────────────
// Mirrors the CLI `mem ls` filter: kind 30174, authored by the agent,
// p-tagged for the owner. The relay enforces the same access shape.
let filter = serde_json::json!({
"kinds": [KIND_AGENT_ENGRAM],
"authors": [agent.to_hex()],
"#p": [owner_pubkey.to_hex()],
"limit": ENGRAM_FETCH_LIMIT,
});
let events = query_relay(&state, &[filter]).await?;
// `>=` is intentional and accepts a false-positive at exactly
// ENGRAM_FETCH_LIMIT events: if the relay returned the cap, we can't
// distinguish "exactly cap" from "cap because clipped". The banner copy
// says "may be incomplete" which covers the off-by-one. Switch to a
// delta-cursor sync in IXI-60 if this matters in practice.
let truncated = events.len() as u32 >= ENGRAM_FETCH_LIMIT;
// ── Validate, decrypt, group by `d` (NIP-AE Listing) ────────────────
// Pattern is the CLI's: drop bad apples silently rather than fail the
// whole listing. A single corrupt event must not deny-of-service the
// panel.
let mut groups: HashMap<String, Vec<(nostr::Event, Body)>> = HashMap::new();
for ev in events {
if ev.verify().is_err() {
continue;
}
let Some(d_value) = ev
.tags
.iter()
.find(|t| t.kind().to_string() == "d")
.and_then(|t| t.content())
.map(|s| s.to_string())
else {
continue;
};
let body = match validate_and_decrypt(
&ev,
&agent,
&owner_pubkey,
&owner_seckey,
&agent, // viewer (owner) decrypts with agent as the conversation peer
) {
Ok(b) => b,
Err(_) => continue,
};
groups.entry(d_value).or_default().push((ev, body));
}
// ── Pick head per d-group, drop tombstones, split core vs memories ──
let mut core: Option<EngramEntry> = None;
let mut memories: Vec<EngramEntry> = Vec::new();
for (_d, members) in groups {
let events: Vec<nostr::Event> = members.iter().map(|(e, _)| e.clone()).collect();
let Some(head) = select_head(events) else {
continue;
};
let Some((_, body)) = members.into_iter().find(|(e, _)| e.id == head.id) else {
continue;
};
let event_id = head.id.to_hex();
let created_at = head.created_at.as_secs();
match body {
Body::Memory { value: None, .. } => {
// Tombstone — exclude from the listing.
continue;
}
Body::Core { profile } => {
let outgoing_refs = extract_refs(&profile);
core = Some(EngramEntry {
slug: engram::CORE_SLUG.to_string(),
body: profile,
event_id,
created_at,
outgoing_refs,
});
}
Body::Memory {
slug,
value: Some(value),
} => {
let outgoing_refs = extract_refs(&value);
memories.push(EngramEntry {
slug,
body: value,
event_id,
created_at,
outgoing_refs,
});
}
}
}
memories.sort_by(|a, b| a.slug.cmp(&b.slug));
let fetched_at = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
Ok(AgentMemoryListing {
core,
memories,
truncated,
fetched_at,
})
}

View File

@@ -6,6 +6,7 @@ mod canvas;
mod channel_templates;
mod channels;
mod dms;
mod engrams;
mod export_util;
mod identity;
mod identity_archive;
@@ -33,6 +34,7 @@ pub use canvas::*;
pub use channel_templates::*;
pub use channels::*;
pub use dms::*;
pub use engrams::*;
pub use identity::*;
pub use identity_archive::*;
pub use media::*;

View File

@@ -787,6 +787,7 @@ pub fn run() {
apply_workspace,
get_active_workspace,
set_prevent_sleep_active,
get_agent_memory,
])
.build(tauri::generate_context!())
.expect("error while building tauri application");

View File

@@ -0,0 +1,96 @@
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import { useManagedAgentsQuery } from "@/features/agents/hooks";
import {
getAgentMemory,
type AgentMemoryListing,
} from "@/shared/api/tauriEngrams";
import { buildMemoryGraph, type MemoryGraph } from "./lib/buildMemoryGraph";
export const agentMemoryQueryKey = (agentPubkey: string) =>
["agent-memory", agentPubkey.toLowerCase()] as const;
/**
* Synchronous gate: does this desktop manage the agent? Used by the profile
* panel to hide the Memory section entirely for non-owners.
*
* Returns `boolean | undefined`:
* - `undefined` is the *loading* state (managed-agent list still resolving).
* Callers should defer rendering, not show an error.
* - `true` / `false` once the list is known.
*
* Why `managed_agents` (not NIP-OA `kind:0` via `useOaOwnerQuery`)?
* The archive button gates on `useOaOwnerQuery` because publishing a
* `kind:9035` requires verifying NIP-OA cryptographically — the action is
* *signing as the OA owner*. The memory viewer's question is different:
* "do I have the seckey to decrypt this agent's engrams?" `managed_agents`
* answers exactly that — it's the local source of truth for "agents whose
* keys this desktop holds." NIP-OA on its own is weaker for this surface:
* a malicious agent can forge an `auth` tag in their `kind:0` pointing at
* any pubkey, but only the desktop that actually holds the seckey can
* decrypt. Don't "fix" this back to `useOaOwnerQuery` — it would replace
* a precise predicate with a weaker one and add a relay roundtrip.
*
* Lowercase compare because pubkeys can arrive from either side in mixed
* case via Nostr libs; the underlying store stores them as-given.
*/
export function useIsManagedAgent(
agentPubkey: string | null | undefined,
): boolean | undefined {
const query = useManagedAgentsQuery();
if (!agentPubkey) return false;
if (!query.data) return undefined;
const lower = agentPubkey.toLowerCase();
return query.data.some((m) => m.pubkey.toLowerCase() === lower);
}
/**
* Fetch + decrypt the engram listing for one agent. Owner-gated at the
* Rust layer; if the viewer isn't the agent's owner the underlying call
* returns an `Err` (we surface it as `query.isError`). The UI must hide
* the section for non-owners — see {@link useIsManagedAgent} — but this
* hook is robust to a misuse there.
*
* `staleTime: 30s`: engrams change rarely (each write is a deliberate
* agent action). 30s keeps profile re-opens snappy without going so far
* that the user sees stale data after their agent edits a memory in the
* background. Refetch is one-tap via `query.refetch()`.
*
* `enabled` defaults to true; pass `false` from a non-owner caller (or
* when no agent is selected) to skip the call entirely.
*/
export function useAgentMemoryQuery(
agentPubkey: string | null | undefined,
options?: { enabled?: boolean },
) {
const enabled = (options?.enabled ?? true) && !!agentPubkey;
return useQuery<AgentMemoryListing>({
enabled,
queryKey: agentMemoryQueryKey(agentPubkey ?? ""),
queryFn: () => getAgentMemory(agentPubkey as string),
staleTime: 30_000,
});
}
/**
* Convenience wrapper: feeds the listing through {@link buildMemoryGraph}
* and memoizes the result. The graph is computed in JS (off the Rust
* boundary) because it's a pure function of the payload; recomputing on
* every render is cheap but not free for large agents (IXI-60 will
* worker-ize this if the numbers warrant).
*/
export function useAgentMemoryGraph(
agentPubkey: string | null | undefined,
options?: { enabled?: boolean },
): {
query: ReturnType<typeof useAgentMemoryQuery>;
graph: MemoryGraph | null;
} {
const query = useAgentMemoryQuery(agentPubkey, options);
const graph = React.useMemo<MemoryGraph | null>(() => {
if (!query.data) return null;
return buildMemoryGraph(query.data);
}, [query.data]);
return { query, graph };
}

View File

@@ -0,0 +1,144 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildMemoryGraph } from "./buildMemoryGraph.ts";
function entry(slug, body, outgoingRefs = []) {
return {
slug,
body,
eventId: `id-${slug}`,
createdAt: 0,
outgoingRefs,
};
}
test("buildMemoryGraph: empty listing → null tree, no orphans, no dangling", () => {
const g = buildMemoryGraph({
core: null,
memories: [],
truncated: false,
fetchedAt: 0,
});
assert.equal(g.rootedTree, null);
assert.deepEqual(g.orphans, []);
assert.deepEqual(g.dangling, []);
});
test("buildMemoryGraph: core only, no refs → tree of one, no orphans", () => {
const g = buildMemoryGraph({
core: entry("core", "I am Bart"),
memories: [],
truncated: false,
fetchedAt: 0,
});
assert.equal(g.rootedTree?.entry.slug, "core");
assert.deepEqual(g.rootedTree?.children, []);
assert.deepEqual(g.orphans, []);
});
test("buildMemoryGraph: core → mem/foo → mem/bar reachable chain", () => {
const g = buildMemoryGraph({
core: entry("core", "see [[mem/foo]]", ["mem/foo"]),
memories: [
entry("mem/foo", "see [[mem/bar]]", ["mem/bar"]),
entry("mem/bar", "leaf", []),
],
truncated: false,
fetchedAt: 0,
});
assert.equal(g.rootedTree?.entry.slug, "core");
assert.equal(g.rootedTree?.children.length, 1);
assert.equal(g.rootedTree?.children[0].entry.slug, "mem/foo");
assert.equal(g.rootedTree?.children[0].children[0].entry.slug, "mem/bar");
assert.deepEqual(g.orphans, []);
assert.deepEqual(g.dangling, []);
});
test("buildMemoryGraph: orphans surface when no path from core", () => {
const g = buildMemoryGraph({
core: entry("core", "isolated"),
memories: [entry("mem/zeta", "z", []), entry("mem/alpha", "a", [])],
truncated: false,
fetchedAt: 0,
});
// Orphans sorted by slug → alpha before zeta
assert.deepEqual(
g.orphans.map((o) => o.slug),
["mem/alpha", "mem/zeta"],
);
});
test("buildMemoryGraph: cycle is broken — no infinite recursion", () => {
const g = buildMemoryGraph({
core: entry("core", "see [[mem/a]]", ["mem/a"]),
memories: [
entry("mem/a", "see [[mem/b]]", ["mem/b"]),
entry("mem/b", "see [[mem/a]]", ["mem/a"]), // cycle back to a
],
truncated: false,
fetchedAt: 0,
});
assert.equal(g.rootedTree?.entry.slug, "core");
const a = g.rootedTree?.children[0];
assert.equal(a?.entry.slug, "mem/a");
const b = a?.children[0];
assert.equal(b?.entry.slug, "mem/b");
// The cycle back to `a` is dropped — visited set guards it.
assert.deepEqual(b?.children, []);
assert.deepEqual(g.orphans, []);
});
test("buildMemoryGraph: dangling refs surface with referrers", () => {
const g = buildMemoryGraph({
core: entry("core", "see [[mem/missing]]", ["mem/missing"]),
memories: [
entry("mem/orphan", "see [[mem/also-missing]]", ["mem/also-missing"]),
],
truncated: false,
fetchedAt: 0,
});
// mem/missing referenced by core; mem/also-missing referenced by mem/orphan
assert.deepEqual(g.dangling, [
{ slug: "mem/also-missing", referencedBy: ["mem/orphan"] },
{ slug: "mem/missing", referencedBy: ["core"] },
]);
});
test("buildMemoryGraph: duplicate refs from same body deduplicate referrer list", () => {
const g = buildMemoryGraph({
core: entry("core", "see [[mem/missing]] and [[mem/missing]]", [
"mem/missing",
"mem/missing",
]),
memories: [],
truncated: false,
fetchedAt: 0,
});
assert.deepEqual(g.dangling, [
{ slug: "mem/missing", referencedBy: ["core"] },
]);
});
test("buildMemoryGraph: shared child appears once at first encounter", () => {
// core → mem/a → mem/shared
// core → mem/b → mem/shared
// mem/shared should attach under mem/a (DFS first), not mem/b.
const g = buildMemoryGraph({
core: entry("core", "", ["mem/a", "mem/b"]),
memories: [
entry("mem/a", "", ["mem/shared"]),
entry("mem/b", "", ["mem/shared"]),
entry("mem/shared", "", []),
],
truncated: false,
fetchedAt: 0,
});
const root = g.rootedTree;
assert.equal(root?.children[0].entry.slug, "mem/a");
assert.equal(root?.children[0].children[0].entry.slug, "mem/shared");
assert.equal(root?.children[1].entry.slug, "mem/b");
// mem/b loses the duplicate child because `shared` was already visited.
assert.deepEqual(root?.children[1].children, []);
assert.deepEqual(g.orphans, []);
});

View File

@@ -0,0 +1,121 @@
import type {
AgentMemoryListing,
EngramEntry,
} from "@/shared/api/tauriEngrams";
/** A `mem/...` slug that another body referenced via `[[slug]]` but which
* has no decrypted entry in the listing — either tombstoned or never written. */
export type DanglingRef = {
/** The unresolved slug (e.g. `mem/values/honesty`). */
slug: string;
/** Slugs of the entries that referenced it. Helps the UI explain why
* a dangling ref is showing up. */
referencedBy: string[];
};
/** A node in the reachability tree rooted at `core`. Children are themselves
* nodes — recursive rendering. The same engram never appears twice (visited
* set guards cycles); a ref to an already-visited node is dropped from the
* tree (the node still lives at its first appearance). */
export type MemoryTreeNode = {
entry: EngramEntry;
children: MemoryTreeNode[];
};
/** Output of {@link buildMemoryGraph}: the full panel-ready view. */
export type MemoryGraph = {
/** Tree rooted at `core`. `null` when the agent has no core memory. */
rootedTree: MemoryTreeNode | null;
/** Memories not reachable from `core` via `[[slug]]` refs. Sorted by slug. */
orphans: EngramEntry[];
/** Refs in any decrypted body that didn't resolve to a known entry. */
dangling: DanglingRef[];
};
/**
* Build the panel's view of an {@link AgentMemoryListing}: tree rooted at
* `core`, orphan list, and dangling-ref list.
*
* Why a single pass:
* - Reachable vs orphans is derivable from the same BFS; no need to run
* twice.
* - Dangling-ref detection drops out for free — any ref whose slug isn't
* in the slug→entry index is dangling, regardless of which body cited
* it.
*
* Cycle handling: a `visited` set is maintained per BFS. The first time a
* memory is reached, it becomes a node in the tree; subsequent refs to it
* are silently ignored. This means the *tree* is acyclic by construction
* even though the underlying graph may have cycles.
*
* Tombstones never appear here — they're filtered out at the Rust layer
* before this function ever sees them. A `[[slug]]` ref to a tombstoned
* memory therefore shows up as a dangling ref, which is the right user-
* facing signal ("this memory used to exist but doesn't anymore").
*/
export function buildMemoryGraph(listing: AgentMemoryListing): MemoryGraph {
// slug → entry, used for both reachability and dangling-ref detection.
// `core` lives in this index too so that a `[[core]]` ref from a memory
// back-references correctly (rare but possible).
const bySlug = new Map<string, EngramEntry>();
if (listing.core) bySlug.set(listing.core.slug, listing.core);
for (const m of listing.memories) bySlug.set(m.slug, m);
// Track which entries we've already placed in the tree.
const visited = new Set<string>();
// refSlug → list of slugs that referenced it. Built lazily as we discover
// refs that don't resolve.
const danglingMap = new Map<string, string[]>();
function recordDangling(refSlug: string, referencedBy: string): void {
const list = danglingMap.get(refSlug);
if (list) {
// Don't double-count if the same body refs the same dangling slug
// twice — duplicate `[[foo]] [[foo]]` should still surface a single
// referrer in the UI.
if (!list.includes(referencedBy)) list.push(referencedBy);
} else {
danglingMap.set(refSlug, [referencedBy]);
}
}
function buildNode(entry: EngramEntry): MemoryTreeNode {
visited.add(entry.slug);
const children: MemoryTreeNode[] = [];
for (const refSlug of entry.outgoingRefs) {
const target = bySlug.get(refSlug);
if (!target) {
recordDangling(refSlug, entry.slug);
continue;
}
// Skip self-refs and already-visited nodes; the visited guard makes
// the recursion finite even on cyclic graphs.
if (visited.has(target.slug)) continue;
children.push(buildNode(target));
}
return { entry, children };
}
const rootedTree = listing.core ? buildNode(listing.core) : null;
// Orphans = every memory not visited during the BFS from core. Sort by
// slug so the UI is deterministic across refetches.
const orphans = listing.memories
.filter((m) => !visited.has(m.slug))
.slice()
.sort((a, b) => a.slug.localeCompare(b.slug));
// Even orphans can themselves cite refs we should surface. Walk them
// (no tree, just ref resolution) so dangling targets are complete.
for (const o of orphans) {
for (const refSlug of o.outgoingRefs) {
if (!bySlug.has(refSlug)) recordDangling(refSlug, o.slug);
}
}
const dangling: DanglingRef[] = Array.from(danglingMap.entries())
.map(([slug, referencedBy]) => ({ slug, referencedBy }))
.sort((a, b) => a.slug.localeCompare(b.slug));
return { rootedTree, orphans, dangling };
}

View File

@@ -0,0 +1,563 @@
import * as React from "react";
import { AlertTriangle, ChevronDown, RefreshCw } from "lucide-react";
import {
useAgentMemoryGraph,
useIsManagedAgent,
} from "@/features/agent-memory/hooks";
import type { MemoryTreeNode } from "@/features/agent-memory/lib/buildMemoryGraph";
import type { EngramEntry } from "@/shared/api/tauriEngrams";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Skeleton } from "@/shared/ui/skeleton";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
const MEMORY_LIST_PREVIEW_LIMIT = 3;
const MEMORY_TRUNCATED_TOOLTIP =
"This list may be incomplete — the relay returned the maximum number of memories.";
const MEMORY_DANGLING_REF_TOOLTIP =
"This memory links to a slug that wasn't found in the loaded memory list.";
/**
* Memory section — IXI-7 phase 1 read-only viewer.
*
* Owner-gated: returns `null` for non-owners (and while the managed-agent
* list is still loading, to avoid a one-frame flash of the section before
* we know the viewer's owner status).
*
* The whole thing is contained: skeleton/error/empty live *inside* this
* section so the rest of the profile panel stays interactive while we
* fetch+decrypt. Refetch is non-blocking (cached data stays visible while
* `isFetching` is true).
*
* Layout:
* ⚠ truncated relay banner (if applicable)
* ── tree rooted at `core` ──
* ── orphans list (if any) ──
* ── missing [[slug]] refs highlighted inline on parent memories ──
*
* tho will refine the visual design — this is the structural placement.
*/
export function MemorySection({
agentPubkey,
}: {
agentPubkey: string;
}): React.ReactElement | null {
const isOwner = useIsManagedAgent(agentPubkey);
// Hide entirely for non-owners. Defer (`null`) while the managed-agent
// list is still loading rather than render a placeholder we'll yank.
if (isOwner !== true) return null;
return <MemorySectionForOwner agentPubkey={agentPubkey} />;
}
export function MemoryRefreshButton({
agentPubkey,
className,
iconClassName,
}: {
agentPubkey: string;
className?: string;
iconClassName?: string;
}): React.ReactElement | null {
const isOwner = useIsManagedAgent(agentPubkey);
const { query } = useAgentMemoryGraph(agentPubkey, {
enabled: isOwner === true,
});
if (isOwner !== true || !query.data) return null;
return (
<Button
aria-label="Refresh memory"
className={cn(className, query.isFetching && "cursor-wait")}
data-testid="agent-memory-refetch"
disabled={query.isFetching}
onClick={() => query.refetch()}
size="icon"
type="button"
variant="ghost"
>
<RefreshCw
className={cn(
iconClassName ?? "h-3.5 w-3.5",
query.isFetching && "animate-spin",
)}
/>
</Button>
);
}
function MemorySectionForOwner({ agentPubkey }: { agentPubkey: string }) {
const { query, graph } = useAgentMemoryGraph(agentPubkey);
// Order matters here. We want:
// - first paint, no cache → skeleton
// - error with no cache → error state (with retry)
// - error WITH cache → keep the data, show a non-blocking "refetch failed"
// banner; user can retry without losing what they had
// - data, but empty → empty state ("This agent has no memories yet")
// - data, non-empty → render
const showInitialSkeleton = query.isLoading && !query.data;
const showInitialError = query.isError && !query.data;
return (
<section data-testid="agent-memory-section">
{showInitialSkeleton ? <MemorySkeleton /> : null}
{showInitialError ? (
<MemoryErrorState
error={query.error}
onRetry={() => query.refetch()}
retrying={query.isFetching}
/>
) : null}
{query.data && graph ? (
<>
{/* Stale-cache error banner: shown when a refetch fails but we
still have prior data on screen. Distinct from the initial
error state above. */}
{query.isError && !query.isFetching ? (
<MemoryStaleErrorBanner onRetry={() => query.refetch()} />
) : null}
<MemoryGraphView graph={graph} truncated={query.data.truncated} />
</>
) : null}
</section>
);
}
// ── Subviews ────────────────────────────────────────────────────────────────
function MemorySkeleton() {
return (
<div
aria-label="Loading memory"
className="space-y-2"
data-testid="agent-memory-skeleton"
role="status"
>
<Skeleton className="h-4 w-2/3" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-3/5" />
</div>
);
}
function MemoryErrorState({
error,
onRetry,
retrying,
}: {
error: unknown;
onRetry: () => void;
retrying: boolean;
}) {
const message =
error instanceof Error ? error.message : String(error ?? "unknown error");
return (
<div
className="flex flex-col gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs"
data-testid="agent-memory-error"
role="alert"
>
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
<div className="space-y-1">
<div className="font-medium text-destructive">
Couldn't load memory
</div>
<div className="text-muted-foreground">{message}</div>
</div>
</div>
<Button
className="self-start"
disabled={retrying}
onClick={onRetry}
size="sm"
variant="outline"
>
{retrying ? "Retrying…" : "Retry"}
</Button>
</div>
);
}
function MemoryStaleErrorBanner({ onRetry }: { onRetry: () => void }) {
return (
<div
className="mb-2 flex items-center gap-2 rounded-md border border-warning/30 bg-warning/5 px-2 py-1.5 text-xs"
data-testid="agent-memory-stale-error"
>
<AlertTriangle className="h-3 w-3 shrink-0 text-warning" />
<span className="flex-1 text-muted-foreground">Refresh failed.</span>
<button
className="font-medium text-warning hover:underline"
onClick={onRetry}
type="button"
>
Retry
</button>
</div>
);
}
function MemoryGraphView({
graph,
truncated,
}: {
graph: NonNullable<ReturnType<typeof useAgentMemoryGraph>["graph"]>;
truncated: boolean;
}) {
const { rootedTree, orphans, dangling } = graph;
const [showAllEntries, setShowAllEntries] = React.useState(false);
const danglingSlugs = React.useMemo(
() => new Set(dangling.map((d) => d.slug)),
[dangling],
);
const isEmpty = !rootedTree && orphans.length === 0;
if (isEmpty) {
return (
<p
className="text-sm italic text-muted-foreground"
data-testid="agent-memory-empty"
>
This agent has no memories yet.
</p>
);
}
const core = rootedTree?.entry ?? null;
const memories = [
...(rootedTree ? flattenTreeDescendants(rootedTree) : []),
...orphans,
];
const entries = [...(core ? [core] : []), ...memories];
const hasMoreEntries = entries.length > MEMORY_LIST_PREVIEW_LIMIT;
const visibleEntries = showAllEntries
? entries
: entries.slice(0, MEMORY_LIST_PREVIEW_LIMIT);
return (
<div className="space-y-3">
{!core && memories.length > 0 ? (
<p
className="text-xs italic text-muted-foreground"
data-testid="agent-memory-no-core"
>
No <code className="font-mono text-[10px]">core</code> memory yet —
agent identity is unrooted.
</p>
) : null}
<div className="space-y-2" data-testid="agent-memory-list">
{visibleEntries.map((entry) => (
<MemoryEntryAccordion
danglingSlugs={danglingSlugs}
entry={entry}
key={entry.eventId}
/>
))}
</div>
{hasMoreEntries && !showAllEntries ? (
<MemoryShowMoreButton
count={entries.length}
onClick={() => setShowAllEntries(true)}
truncated={truncated}
/>
) : null}
{truncated && !hasMoreEntries ? <MemoryTruncatedHint /> : null}
{hasMoreEntries && showAllEntries ? (
<button
className="flex w-full justify-center rounded-2xl bg-muted/40 px-4 py-3 text-sm font-medium text-foreground transition-colors hover:bg-muted/50"
data-testid="agent-memory-show-less"
onClick={() => setShowAllEntries(false)}
type="button"
>
Show less
</button>
) : null}
</div>
);
}
function MemoryShowMoreButton({
count,
onClick,
truncated,
}: {
count: number;
onClick: () => void;
truncated: boolean;
}) {
const button = (
<button
className="flex w-full items-center justify-center gap-2 rounded-2xl bg-muted/40 px-4 py-3 text-sm font-medium text-foreground transition-colors hover:bg-muted/50"
data-testid={
truncated ? "agent-memory-truncated" : "agent-memory-show-more"
}
onClick={onClick}
type="button"
>
View all ({count})
</button>
);
if (!truncated) return button;
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent className="max-w-xs text-xs" side="top">
{MEMORY_TRUNCATED_TOOLTIP}
</TooltipContent>
</Tooltip>
);
}
function MemoryTruncatedHint() {
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className="flex justify-center rounded-2xl border border-warning/30 bg-warning/5 px-4 py-2"
data-testid="agent-memory-truncated"
>
<AlertTriangle className="h-4 w-4 text-warning" />
</div>
</TooltipTrigger>
<TooltipContent className="max-w-xs text-xs" side="top">
{MEMORY_TRUNCATED_TOOLTIP}
</TooltipContent>
</Tooltip>
);
}
function flattenTreeDescendants(node: MemoryTreeNode): EngramEntry[] {
const entries: EngramEntry[] = [];
for (const child of node.children) {
entries.push(child.entry);
entries.push(...flattenTreeDescendants(child));
}
return entries;
}
const MEMORY_REF_PATTERN = /\[\[([^\]]+)\]\]/g;
function MemoryBodyText({ body }: { body: string }) {
const parts: React.ReactNode[] = [];
let lastIndex = 0;
for (const match of body.matchAll(MEMORY_REF_PATTERN)) {
const index = match.index ?? 0;
const slug = match[1];
if (index > lastIndex) {
parts.push(body.slice(lastIndex, index));
}
parts.push(
<span
className="wrap-break-word break-all text-foreground"
key={`${index}-${slug}`}
>
[[{slug}]]
</span>,
);
lastIndex = index + match[0].length;
}
if (lastIndex < body.length) {
parts.push(body.slice(lastIndex));
}
return <>{parts}</>;
}
function MemoryDanglingRefsHint({ slugs }: { slugs: string[] }) {
if (slugs.length === 0) return null;
return (
<div
className="-mx-4 -mb-2 mt-2 px-2 pb-1"
data-testid="agent-memory-dangling-hint"
>
<div className="rounded-xl bg-warning/5 px-2.5 py-2 text-xs leading-5">
<p className="text-warning">
Missing {slugs.length === 1 ? "link" : "links"}:{" "}
{slugs.map((slug, index) => (
<React.Fragment key={slug}>
{index > 0 ? ", " : null}
<span
className="wrap-break-word break-all text-warning"
data-testid="agent-memory-dangling-ref"
>
[[{slug}]]
</span>
</React.Fragment>
))}
</p>
<p className="mt-0.5 text-foreground/50">
{MEMORY_DANGLING_REF_TOOLTIP}
</p>
</div>
</div>
);
}
function MemorySlugTitle({ slug }: { slug: string }) {
const segments = slug.split("/").filter((segment) => segment.length > 0);
if (segments.length === 0) return null;
if (segments.length === 1) {
return (
<span
className={segments[0] === "mem" ? "text-foreground/40" : undefined}
>
{segments[0]}
</span>
);
}
return (
<span className="inline-flex flex-wrap items-baseline">
{segments.map((segment, index) => {
const segmentPath = segments.slice(0, index + 1).join("/");
return (
<React.Fragment key={segmentPath}>
{index > 0 ? (
<span className="px-0.5 text-foreground/40">/</span>
) : null}
<span
className={cn(
segment === "mem" ? "text-foreground/40" : "text-foreground",
)}
>
{segment}
</span>
</React.Fragment>
);
})}
</span>
);
}
function elementExceedsLines(element: HTMLElement, lines: number): boolean {
const lineHeight = Number.parseFloat(getComputedStyle(element).lineHeight);
if (!Number.isFinite(lineHeight) || lineHeight <= 0) {
return element.scrollHeight > element.clientHeight + 1;
}
return element.scrollHeight > lineHeight * lines + 1;
}
/** A single engram accordion — collapsed preview truncates to two lines. */
function MemoryEntryAccordion({
danglingSlugs,
entry,
}: {
danglingSlugs: ReadonlySet<string>;
entry: EngramEntry;
}) {
const [open, setOpen] = React.useState(false);
const [showCaret, setShowCaret] = React.useState(false);
const articleRef = React.useRef<HTMLElement>(null);
const titleRef = React.useRef<HTMLDivElement>(null);
const bodyRef = React.useRef<HTMLDivElement>(null);
const isEmpty = entry.body.trim().length === 0;
const danglingRefsForEntry = entry.outgoingRefs.filter((ref) =>
danglingSlugs.has(ref),
);
const hasDanglingRefs = danglingRefsForEntry.length > 0;
const canExpand = showCaret || hasDanglingRefs;
// biome-ignore lint/correctness/useExhaustiveDependencies: remeasure when accordion clamping changes
React.useLayoutEffect(() => {
const measure = () => {
const titleEl = titleRef.current;
const bodyEl = bodyRef.current;
if (!titleEl || !bodyEl) return;
setShowCaret(
elementExceedsLines(titleEl, 2) || elementExceedsLines(bodyEl, 2),
);
};
measure();
const root = articleRef.current;
if (!root) return undefined;
const observer = new ResizeObserver(measure);
observer.observe(root);
return () => observer.disconnect();
}, [open]);
const content = (
<>
<div className="min-w-0 flex-1">
<div
className={cn(
"text-sm font-semibold text-foreground",
!open && "line-clamp-2",
)}
ref={titleRef}
>
{hasDanglingRefs ? (
<AlertTriangle className="mr-1 inline-block h-3.5 w-3.5 align-[-2px] text-warning" />
) : null}
<MemorySlugTitle slug={entry.slug} />
</div>
<div
className={cn(
"mt-1 text-xs leading-5 text-foreground/70",
open ? "whitespace-pre-wrap wrap-break-word" : "line-clamp-2",
)}
ref={bodyRef}
>
{isEmpty ? (
<span className="italic text-foreground/50">(empty)</span>
) : (
<MemoryBodyText body={entry.body} />
)}
</div>
</div>
{canExpand ? (
<ChevronDown
className={cn(
"mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform",
open && "rotate-180",
)}
/>
) : null}
</>
);
return (
<article
className="overflow-hidden rounded-2xl bg-muted/40"
ref={articleRef}
>
{canExpand ? (
<button
aria-expanded={open}
className="w-full px-4 py-3 text-left transition-colors hover:bg-muted/50"
onClick={() => setOpen((value) => !value)}
type="button"
>
<div className="flex items-start gap-3">{content}</div>
{hasDanglingRefs && open ? (
<MemoryDanglingRefsHint slugs={danglingRefsForEntry} />
) : null}
</button>
) : (
<div className="flex items-start gap-3 px-4 py-3">{content}</div>
)}
</article>
);
}

View File

@@ -238,9 +238,28 @@ export function useUpdateManagedAgentMutation() {
},
);
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
await queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey });
onSettled: async (_data, _error, variables) => {
// Backend republishes kind:0 on a name change (sync_managed_agent_profile),
// so the relay has fresh profile data — but the desktop's React Query cache
// for ["user-profile", pubkey] has a 60s staleTime and will not refetch on
// its own. Invalidate explicitly so the profile pane re-renders against
// the new display name / about / NIP-05 immediately. Also poke any
// ["users-batch", ...] entries that include this pubkey so sidebar member
// rows, channel header chips, and message author labels refresh too.
const lowerPubkey = variables.pubkey.toLowerCase();
await Promise.all([
queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }),
queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }),
queryClient.invalidateQueries({
queryKey: ["user-profile", lowerPubkey],
}),
queryClient.invalidateQueries({
predicate: (query) =>
query.queryKey[0] === "users-batch" &&
query.queryKey.includes(lowerPubkey),
}),
]);
},
});
}

View File

@@ -0,0 +1,39 @@
import * as React from "react";
import { Badge } from "@/shared/ui/badge";
import type { ManagedAgent, PresenceStatus } from "@/shared/api/types";
/** Grace period after mount before treating "running + no presence" as "Starting…" */
const PRESENCE_GRACE_MS = 15_000;
export function AgentStatusBadge({
presenceLoaded,
presenceStatus,
status,
}: {
presenceLoaded: boolean;
presenceStatus: PresenceStatus | undefined;
status: ManagedAgent["status"];
}) {
const [inGracePeriod, setInGracePeriod] = React.useState(true);
React.useEffect(() => {
const timer = setTimeout(() => setInGracePeriod(false), PRESENCE_GRACE_MS);
return () => clearTimeout(timer);
}, []);
const isActive = status === "running" || status === "deployed";
const isStarting =
!inGracePeriod &&
presenceLoaded &&
status === "running" &&
(!presenceStatus || presenceStatus === "offline");
const variant = isStarting ? "warning" : isActive ? "default" : "secondary";
return (
<Badge variant={variant}>
{isStarting ? "Starting\u2026" : status.replace(/_/g, " ")}
</Badge>
);
}

View File

@@ -17,6 +17,7 @@ import { toast } from "sonner";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import { Badge } from "@/shared/ui/badge";
import { AgentStatusBadge } from "@/features/agents/ui/AgentStatusBadge";
import type {
ManagedAgent,
PresenceLookup,
@@ -474,38 +475,3 @@ function AgentOriginBadge({ agent }: { agent: ManagedAgent }) {
</Badge>
);
}
/** Grace period after mount before treating "running + no presence" as "Starting…" */
const PRESENCE_GRACE_MS = 15_000;
function AgentStatusBadge({
presenceLoaded,
presenceStatus,
status,
}: {
presenceLoaded: boolean;
presenceStatus: PresenceStatus | undefined;
status: ManagedAgent["status"];
}) {
const [inGracePeriod, setInGracePeriod] = React.useState(true);
React.useEffect(() => {
const timer = setTimeout(() => setInGracePeriod(false), PRESENCE_GRACE_MS);
return () => clearTimeout(timer);
}, []);
const isActive = status === "running" || status === "deployed";
const isStarting =
!inGracePeriod &&
presenceLoaded &&
status === "running" &&
(!presenceStatus || presenceStatus === "offline");
const variant = isStarting ? "warning" : isActive ? "default" : "secondary";
return (
<Badge variant={variant}>
{isStarting ? "Starting\u2026" : status.replace(/_/g, " ")}
</Badge>
);
}

View File

@@ -1,5 +1,5 @@
import type * as React from "react";
import { CircleDot, Octagon, X } from "lucide-react";
import { ArrowLeft, CircleDot, Octagon, X } from "lucide-react";
import { toast } from "sonner";
import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel";
@@ -13,7 +13,6 @@ import { cn } from "@/shared/lib/cn";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { UserAvatar } from "@/shared/ui/UserAvatar";
import {
OverlayPanelBackdrop,
PANEL_BASE_CLASS,
@@ -32,6 +31,7 @@ type AgentSessionThreadPanelProps = {
isWorking: boolean;
isSinglePanelView?: boolean;
profiles?: UserProfileLookup;
onBackToProfile: () => void;
onClose: () => void;
onResetWidth: () => void;
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => void;
@@ -46,13 +46,13 @@ export function AgentSessionThreadPanel({
isWorking,
isSinglePanelView = false,
profiles,
onBackToProfile,
onClose,
onResetWidth,
onResizeStart,
widthPx,
}: AgentSessionThreadPanelProps) {
const isLive = isManagedAgentActive(agent);
const avatarUrl = profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null;
const isOverlay = useIsThreadPanelOverlay();
const isFloatingOverlay = isOverlay && !isSinglePanelView;
const usesChannelSplitChrome = !isOverlay && !isSinglePanelView;
@@ -94,7 +94,7 @@ export function AgentSessionThreadPanel({
{!isOverlay && !isSinglePanelView && (
<button
aria-label="Resize agent session panel"
className="group absolute inset-y-0 left-0 z-20 w-3 -translate-x-1/2 cursor-col-resize"
className="peer/agent-session-resize group/agent-session-resize absolute inset-y-0 left-0 z-40 w-3 -translate-x-1/2 cursor-col-resize"
data-testid="agent-session-resize-handle"
onDoubleClick={canResetWidth ? onResetWidth : undefined}
onPointerDown={onResizeStart}
@@ -105,7 +105,7 @@ export function AgentSessionThreadPanel({
}
type="button"
>
<span className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-transparent transition-colors group-hover:bg-border/80" />
<span className="absolute bottom-0 left-1/2 top-10 w-px -translate-x-1/2 bg-transparent transition-colors group-hover/agent-session-resize:bg-border/80 group-focus-visible/agent-session-resize:bg-border/80" />
</button>
)}
@@ -113,7 +113,7 @@ export function AgentSessionThreadPanel({
<div
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-x-0 top-0 z-40 bg-background/75 backdrop-blur-md before:absolute before:left-0 before:right-0 before:top-10 before:h-px before:bg-border/35 after:absolute after:bottom-0 after:-left-px after:top-10 after:w-px after:bg-border/80 supports-[backdrop-filter]:bg-background/65 dark:bg-background/45 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/35",
"pointer-events-none absolute inset-x-0 top-0 z-40 bg-background/80 backdrop-blur-md after:absolute after:left-0 after:right-0 after:top-10 after:h-px after:bg-border/35 supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55",
usesChannelSplitChrome ? "h-[92px]" : "h-[76px]",
)}
/>
@@ -123,40 +123,45 @@ export function AgentSessionThreadPanel({
className={cn(
"flex cursor-default select-none items-center",
isSinglePanelView
? `relative ${PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS} -mb-[76px] min-h-[76px] shrink-0 gap-[10px] bg-background/80 pb-[4px] pl-[16px] pr-[8px] pt-[42px] backdrop-blur-md supports-[backdrop-filter]:bg-background/70 sm:pl-[24px] sm:pr-[12px] dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55`
? `relative ${PANEL_SINGLE_COLUMN_HEADER_LAYER_CLASS} mb-[-76px] min-h-[76px] shrink-0 gap-[10px] bg-transparent pb-[4px] pl-[16px] pr-[8px] pt-[42px] sm:pl-[24px] sm:pr-[12px]`
: isOverlay
? "relative z-50 min-h-[44px] shrink-0 gap-3 bg-background/80 px-3 py-[6px] backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55"
: "absolute inset-x-0 top-[48px] z-50 h-[32px] gap-[10px] py-0 pl-[16px] pr-[8px] sm:pr-[12px]",
? "relative z-50 min-h-[44px] shrink-0 gap-3 bg-background/80 px-3 py-[6px] backdrop-blur-md supports-backdrop-filter:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-backdrop-filter:bg-background/55"
: "absolute inset-x-0 top-[48px] z-50 h-[32px] gap-[10px] bg-transparent py-0 pl-[16px] pr-[8px] after:absolute after:bottom-0 after:-left-px after:top-0 after:w-px after:bg-border/45 after:transition-colors peer-hover/agent-session-resize:after:bg-border/80 peer-focus-visible/agent-session-resize:after:bg-border/80 sm:pr-[12px]",
)}
data-tauri-drag-region
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<div
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<Button
aria-label="Back from activity"
className={cn(
"flex min-w-0 flex-1 items-center gap-2",
isSinglePanelView && "translate-y-px",
"shrink-0",
usesChannelSplitChrome
? "h-8 w-8 rounded-lg border border-border/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-4"
: "h-7 w-7 rounded-full text-muted-foreground hover:bg-muted/60 hover:text-foreground",
)}
data-testid="agent-session-back"
onClick={onBackToProfile}
size="icon"
type="button"
variant="ghost"
>
<ArrowLeft
className={cn(usesChannelSplitChrome ? "size-4" : "size-3.5")}
/>
</Button>
<h2
className={cn(
"min-w-0 translate-y-px truncate font-semibold tracking-tight",
usesChannelSplitChrome
? "text-base leading-6"
: "text-sm leading-5",
)}
>
<UserAvatar
avatarUrl={avatarUrl}
className="h-5 w-5 shrink-0 text-[8px]"
displayName={agent.name}
size="xs"
/>
<h2
className={cn(
"min-w-0 flex-1 translate-y-px truncate font-semibold tracking-tight",
usesChannelSplitChrome
? "text-base leading-6"
: "text-sm leading-5",
)}
>
{agent.name}
</h2>
</div>
Activity
</h2>
<div
className={cn(
"flex shrink-0 items-center gap-2",
"ml-auto flex shrink-0 items-center gap-2",
isSinglePanelView && "translate-y-px",
)}
>
@@ -200,7 +205,7 @@ export function AgentSessionThreadPanel({
className={cn(
usesChannelSplitChrome
? "h-8 w-8 rounded-lg border border-border/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-5"
: "h-6 w-6 text-foreground hover:bg-muted/60 hover:text-foreground",
: "h-4 w-4 rounded-full text-foreground hover:bg-muted/60 hover:text-foreground",
)}
data-testid="agent-session-close"
onClick={onClose}
@@ -210,7 +215,7 @@ export function AgentSessionThreadPanel({
>
<X
className={cn(
usesChannelSplitChrome ? "size-5" : "h-3.5 w-3.5",
usesChannelSplitChrome ? "size-5" : "h-2.5 w-2.5",
)}
/>
</Button>

View File

@@ -61,6 +61,7 @@ type ChannelPaneProps = {
onJoinChannel?: () => Promise<void>;
onOpenAgentSession: (pubkey: string) => void;
onOpenDm?: (pubkeys: string[]) => void;
onOpenProfilePanel: (pubkey: string) => void;
onOpenThread: (message: TimelineMessage) => void;
onResetThreadPanelWidth: () => void;
onSelectThreadReplyTarget: (message: TimelineMessage) => void;
@@ -140,6 +141,7 @@ export const ChannelPane = React.memo(function ChannelPane({
onJoinChannel,
onOpenAgentSession,
onOpenDm,
onOpenProfilePanel,
onOpenThread,
onResetThreadPanelWidth,
onSelectThreadReplyTarget,
@@ -530,6 +532,7 @@ export const ChannelPane = React.memo(function ChannelPane({
)}
isSinglePanelView={isSinglePanelView}
profiles={profiles}
onBackToProfile={() => onOpenProfilePanel(selectedAgent.pubkey)}
onClose={onCloseAgentSession}
onResetWidth={onResetThreadPanelWidth}
onResizeStart={onThreadPanelResizeStart}

View File

@@ -565,6 +565,7 @@ export function ChannelScreen({
onExpandThreadReplies={handleExpandThreadReplies}
onOpenAgentSession={handleOpenAgentSession}
onOpenDm={handleOpenDm}
onOpenProfilePanel={handleOpenProfilePanel}
onResetThreadPanelWidth={handleThreadPanelWidthReset}
onCloseProfilePanel={handleCloseProfilePanel}
onOpenThread={handleOpenThreadAndCloseAgentSession}

View File

@@ -10,6 +10,7 @@ type ProfileAvatarProps = {
label: string;
className?: string;
iconClassName?: string;
plain?: boolean;
testId?: string;
};
@@ -18,13 +19,18 @@ export function ProfileAvatar({
label,
className,
iconClassName,
plain = false,
testId,
}: ProfileAvatarProps) {
const initials = getInitials(label);
return (
<Avatar
className={cn("shrink-0 bg-primary/20 text-primary shadow-xs", className)}
className={cn(
"shrink-0 text-primary shadow-xs",
plain ? "bg-transparent shadow-none" : "bg-primary/20",
className,
)}
data-testid={testId}
>
{avatarUrl ? (
@@ -36,7 +42,10 @@ export function ProfileAvatar({
/>
) : null}
<AvatarFallback
className="bg-primary/20 font-semibold text-primary"
className={cn(
"font-semibold text-primary",
plain ? "bg-transparent" : "bg-primary/20",
)}
delayMs={200}
>
{initials.length > 0 ? (

View File

@@ -1,42 +1,38 @@
import * as React from "react";
import {
Activity,
Archive,
ArchiveRestore,
Copy,
MessageSquare,
X,
} from "lucide-react";
import { toast } from "sonner";
import { ArrowLeft, X } from "lucide-react";
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
import {
useContactListQuery,
useFollowMutation,
useUnfollowMutation,
useUserProfileQuery,
} from "@/features/profile/hooks";
useAgentMemoryQuery,
useIsManagedAgent,
} from "@/features/agent-memory/hooks";
import { MemoryRefreshButton } from "@/features/agent-memory/ui/MemorySection";
import {
useRelayAgentsQuery,
useManagedAgentsQuery,
} from "@/features/agents/hooks";
import {
useArchiveIdentityMutation,
useIsIdentityArchived,
useOaOwnerQuery,
useUnarchiveIdentityMutation,
} from "@/features/identity-archive/hooks";
import { EditAgentDialog } from "@/features/agents/ui/EditAgentDialog";
import { useChannelsQuery } from "@/features/channels/hooks";
import { usePresenceQuery } from "@/features/presence/hooks";
import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks";
import {
useContactListQuery,
useFollowMutation,
useProfileQuery,
useUnfollowMutation,
useUserProfileQuery,
} from "@/features/profile/hooks";
import {
ChannelsFocusedView,
MemoryFocusedView,
ProfileSummaryView,
} from "@/features/profile/ui/UserProfilePanelSections";
import { useUserStatusQuery } from "@/features/user-status/hooks";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
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 { cn } from "@/shared/lib/cn";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import type { Channel, ManagedAgent, RelayAgent } from "@/shared/api/types";
import { Button } from "@/shared/ui/button";
import {
OverlayPanelBackdrop,
@@ -54,36 +50,18 @@ type UserProfilePanelProps = {
onResetWidth: () => void;
onResizeStart: (event: React.PointerEvent<HTMLButtonElement>) => 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;
widthPx: number;
};
const RUNTIME_LABELS: Record<string, string> = {
goose: "Goose",
"claude-code": "Claude Code",
"codex-acp": "Codex",
aider: "Aider",
type ProfilePanelView = "summary" | "memories" | "channels";
const VIEW_TITLES: Record<ProfilePanelView, string> = {
summary: "Profile",
memories: "Memories",
channels: "Channels",
};
function runtimeLabel(command: string): string {
return RUNTIME_LABELS[command] ?? command;
}
function InfoBadge({ children }: { children: React.ReactNode }) {
return (
<span className="inline-flex items-center rounded-full bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground">
{children}
</span>
);
}
function truncatePubkey(pubkey: string) {
if (pubkey.length <= 16) {
return pubkey;
@@ -92,6 +70,44 @@ function truncatePubkey(pubkey: string) {
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<string, ProfileChannelLink>();
const channelsByName = new Map(
channels?.map((channel) => [channel.name, channel]) ?? [],
);
relayAgent?.channels.forEach((name, index) => {
const channel = channelsByName.get(name);
const id = relayAgent.channelIds[index] ?? channel?.id ?? name;
links.set(id, { id, name });
});
if (managedAgent && channels) {
for (const channel of channels) {
const isMember = channel.memberPubkeys.some(
(memberPubkey) => memberPubkey.toLowerCase() === pubkeyLower,
);
if (isMember) {
links.set(channel.id, { id: channel.id, name: channel.name });
}
}
}
return [...links.values()].sort((left, right) =>
left.name.localeCompare(right.name),
);
}
export function UserProfilePanel({
canResetWidth,
currentPubkey,
@@ -110,26 +126,28 @@ export function UserProfilePanel({
splitPaneClamp && !isOverlay && !isSinglePanelView;
useEscapeKey(onClose, isOverlay || isSinglePanelView);
const [view, setView] = React.useState<ProfilePanelView>("summary");
const [editAgentOpen, setEditAgentOpen] = React.useState(false);
const profileQuery = useUserProfileQuery(pubkey);
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(() => {
void profileQuery.refetch();
}, [profileQuery.refetch]);
const relayAgentsQuery = useRelayAgentsQuery({ enabled: true });
const managedAgentsQuery = useManagedAgentsQuery({ enabled: true });
const channelsQuery = useChannelsQuery();
const presenceQuery = usePresenceQuery([pubkey]);
const userStatusQuery = useUserStatusQuery([pubkey]);
const myMembershipQuery = useMyRelayMembershipQuery();
const oaOwnerQuery = useOaOwnerQuery(
pubkey,
// Skip the kind:0 lookup when viewing yourself — the OA gate is for
// archiving *other* identities you own.
currentPubkey !== undefined &&
pubkey.toLowerCase() !== currentPubkey.toLowerCase(),
);
const isArchived = useIsIdentityArchived(pubkey);
const contactListQuery = useContactListQuery(currentPubkey);
const followMutation = useFollowMutation(currentPubkey);
const unfollowMutation = useUnfollowMutation(currentPubkey);
const archiveMutation = useArchiveIdentityMutation();
const unarchiveMutation = useUnarchiveIdentityMutation();
const { onOpenAgentSession } = useAgentSession();
const { goChannel } = useAppNavigation();
const profile = profileQuery.data;
const pubkeyLower = pubkey.toLowerCase();
@@ -137,15 +155,20 @@ export function UserProfilePanel({
const userStatus = userStatusQuery.data?.[pubkeyLower];
const relayAgent = relayAgentsQuery.data?.find(
(a) => a.pubkey.toLowerCase() === pubkeyLower,
(agent) => agent.pubkey.toLowerCase() === pubkeyLower,
);
const managedAgent = managedAgentsQuery.data?.find(
(a) => a.pubkey.toLowerCase() === pubkeyLower,
(agent) => agent.pubkey.toLowerCase() === pubkeyLower,
);
const isBot = Boolean(relayAgent || managedAgent);
const isOwner = useIsManagedAgent(isBot ? pubkey : null);
const canEditAgent = isOwner === true && managedAgent !== undefined;
const memoryQuery = useAgentMemoryQuery(pubkey, {
enabled: isOwner === true,
});
const isSelf =
currentPubkey !== undefined && pubkeyLower === currentPubkey.toLowerCase();
const canViewActivity = isBot && Boolean(onOpenAgentSession);
const canViewActivity = isOwner === true && Boolean(onOpenAgentSession);
const isFollowing =
!isSelf &&
(contactListQuery.data?.contacts.some(
@@ -153,54 +176,62 @@ export function UserProfilePanel({
) ??
false);
// NIP-IA gates. Button shows when ANY of: self path (acting on own pubkey),
// admin path (current user is owner/admin in relay_members), or owner path
// (current user is the verified NIP-OA owner of the viewee per its live
// kind:0). The relay picks the consent path; we just ensure the request is
// permitted to be built locally.
const myRole = myMembershipQuery.data?.role;
const isRelayAdminOrOwner = myRole === "owner" || myRole === "admin";
const isOaOwnerOfViewee = oaOwnerQuery.data?.isMe === true;
const canArchive = isSelf || isRelayAdminOrOwner || isOaOwnerOfViewee;
const profileChannels = React.useMemo(
() =>
deriveProfileChannels(
pubkeyLower,
relayAgent,
managedAgent,
channelsQuery.data,
),
[pubkeyLower, relayAgent, managedAgent, channelsQuery.data],
);
const handleArchive = React.useCallback(() => {
archiveMutation.mutate(
{ targetPubkey: pubkey },
{
onSuccess: () => toast.success("Archived on this relay"),
onError: (error) =>
toast.error(
`Archive failed: ${error instanceof Error ? error.message : String(error)}`,
),
},
);
}, [archiveMutation, pubkey]);
const handleUnarchive = React.useCallback(() => {
unarchiveMutation.mutate(
{ targetPubkey: pubkey },
{
onSuccess: () => toast.success("Unarchived on this relay"),
onError: (error) =>
toast.error(
`Unarchive failed: ${error instanceof Error ? error.message : String(error)}`,
),
},
);
}, [pubkey, unarchiveMutation]);
const handleCopyPubkey = React.useCallback(() => {
void navigator.clipboard.writeText(pubkey).then(() => {
toast.success("Copied to clipboard");
});
}, [pubkey]);
const prevPubkeyRef = React.useRef(pubkey);
if (prevPubkeyRef.current !== pubkey) {
prevPubkeyRef.current = pubkey;
setView("summary");
}
const handleMessage = React.useCallback(() => {
onOpenDm?.([pubkey]);
onClose();
}, [onClose, onOpenDm, pubkey]);
const handleEditAgent = React.useCallback(() => {
setEditAgentOpen(true);
}, []);
const handleOpenActivity = React.useCallback(() => {
onClose();
onOpenAgentSession?.(pubkey);
}, [onClose, onOpenAgentSession, pubkey]);
const handleOpenChannel = React.useCallback(
(channelId: string) => {
void goChannel(channelId);
},
[goChannel],
);
const displayName = profile?.displayName ?? truncatePubkey(pubkey);
const ownerHandle = React.useMemo(() => {
if (currentPubkey === undefined) {
return null;
}
const currentProfile = currentProfileQuery.data;
return (
currentProfile?.nip05Handle?.trim() ||
currentProfile?.displayName?.trim() ||
truncatePubkey(currentPubkey)
);
}, [currentProfileQuery.data, currentPubkey]);
const ownerDisplayName = ownerHandle ? `${ownerHandle} (you)` : null;
const panelTitle = VIEW_TITLES[view];
const memoryCount = memoryQuery.data
? (memoryQuery.data.core ? 1 : 0) + memoryQuery.data.memories.length
: undefined;
return (
<>
@@ -265,6 +296,26 @@ export function UserProfilePanel({
data-tauri-drag-region
>
<div className="flex min-w-0 items-center gap-1.5">
{view !== "summary" ? (
<Button
aria-label="Back to profile"
className={cn(
"shrink-0",
usesChannelSplitChrome
? "h-8 w-8 rounded-lg border border-border/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-4"
: "h-7 w-7 rounded-full text-muted-foreground hover:bg-muted/60 hover:text-foreground",
)}
data-testid="user-profile-panel-back"
onClick={() => setView("summary")}
size="icon"
type="button"
variant="ghost"
>
<ArrowLeft
className={cn(usesChannelSplitChrome ? "size-4" : "size-3.5")}
/>
</Button>
) : null}
<h2
className={cn(
"translate-y-px font-semibold tracking-tight",
@@ -273,27 +324,43 @@ export function UserProfilePanel({
: "text-sm leading-5",
)}
>
Profile
{panelTitle}
</h2>
</div>
<Button
aria-label="Close profile"
className={cn(
"ml-auto",
usesChannelSplitChrome
? "h-8 w-8 rounded-lg border border-border/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-5"
: "h-4 w-4 rounded-full text-foreground hover:bg-muted/60 hover:text-foreground",
)}
data-testid="user-profile-panel-close"
onClick={onClose}
size="icon"
type="button"
variant="ghost"
>
<X
className={cn(usesChannelSplitChrome ? "size-5" : "h-2.5 w-2.5")}
/>
</Button>
<div className="ml-auto flex shrink-0 items-center gap-2">
{view === "memories" && isOwner === true ? (
<MemoryRefreshButton
agentPubkey={pubkey}
className={cn(
usesChannelSplitChrome
? "h-8 w-8 rounded-lg border border-border/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-4"
: "h-4 w-4 rounded-full text-foreground hover:bg-muted/60 hover:text-foreground",
)}
iconClassName={cn(
usesChannelSplitChrome ? "size-4" : "h-2.5 w-2.5",
)}
/>
) : null}
<Button
aria-label="Close profile"
className={cn(
usesChannelSplitChrome
? "h-8 w-8 rounded-lg border border-border/40 text-muted-foreground hover:bg-muted/70 hover:text-foreground [&_svg]:size-5"
: "h-4 w-4 rounded-full text-foreground hover:bg-muted/60 hover:text-foreground",
)}
data-testid="user-profile-panel-close"
onClick={onClose}
size="icon"
type="button"
variant="ghost"
>
<X
className={cn(
usesChannelSplitChrome ? "size-5" : "h-2.5 w-2.5",
)}
/>
</Button>
</div>
</div>
<div
@@ -303,210 +370,59 @@ export function UserProfilePanel({
(usesChannelSplitChrome ? "pt-[92px]" : "pt-[76px]"),
)}
>
<div className="flex flex-col items-center gap-4 pt-4">
{/* Avatar */}
{profile?.avatarUrl ? (
<img
alt={displayName}
className="aspect-square w-full rounded-lg object-cover shadow-xs"
referrerPolicy="no-referrer"
src={rewriteRelayUrl(profile.avatarUrl)}
/>
) : (
<div className="flex aspect-square w-full items-center justify-center rounded-lg bg-secondary text-5xl font-semibold text-secondary-foreground shadow-xs">
{displayName.slice(0, 2).toUpperCase()}
</div>
)}
{/* Name + bot identicon */}
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold">{displayName}</h3>
{isBot ? (
<BotIdenticon
value={displayName}
size={20}
className="shrink-0 rounded"
/>
) : null}
</div>
{profile?.nip05Handle ? (
<p className="text-xs text-muted-foreground">
{profile.nip05Handle}
</p>
) : null}
{/* NIP-IA "Archived" flair (relay-scoped). Spec §Client Behavior:
surface archive metadata where relevant. */}
{isArchived ? (
<span
className="mt-1 inline-flex items-center gap-1 rounded-full bg-amber-500/10 px-2 py-0.5 text-[11px] font-medium text-amber-700 dark:text-amber-300"
data-testid="user-profile-archived-flair"
title="This identity is archived on this relay. Historical events remain attributed to it."
>
<Archive className="h-3 w-3" />
Archived on this relay
</span>
) : null}
</div>
{/* Presence */}
{presenceStatus ? <PresenceBadge status={presenceStatus} /> : null}
{/* User status */}
{userStatus ? (
<p className="text-center text-sm text-muted-foreground">
{userStatus.emoji ? (
<StatusEmoji
className="mr-1 h-3.5 w-3.5"
value={userStatus.emoji}
/>
) : null}
{userStatus.text}
</p>
) : null}
</div>
{/* Pubkey (copyable) */}
<div className="mt-6">
<button
className="flex w-full items-center gap-2 rounded-lg border border-border/60 bg-card/50 px-3 py-2 text-left font-mono text-[11px] text-muted-foreground transition-colors hover:bg-muted/50"
data-testid="user-profile-copy-pubkey"
onClick={handleCopyPubkey}
title="Copy public key"
type="button"
>
<span className="min-w-0 flex-1 truncate">{pubkey}</span>
<Copy className="h-3.5 w-3.5 shrink-0" />
</button>
</div>
{/* Bot info badges */}
{isBot && (managedAgent || relayAgent) ? (
<div className="mt-4 flex flex-wrap gap-1.5">
{managedAgent?.agentCommand ? (
<InfoBadge>{runtimeLabel(managedAgent.agentCommand)}</InfoBadge>
) : relayAgent?.agentType ? (
<InfoBadge>{runtimeLabel(relayAgent.agentType)}</InfoBadge>
) : null}
{managedAgent?.model ? (
<InfoBadge>{managedAgent.model}</InfoBadge>
) : null}
{managedAgent?.acpCommand ? (
<InfoBadge>ACP: {managedAgent.acpCommand}</InfoBadge>
) : null}
</div>
{view === "summary" ? (
<ProfileSummaryView
canEditAgent={canEditAgent}
canViewActivity={canViewActivity}
channelCount={profileChannels.length}
channelsLoading={channelsQuery.isLoading}
displayName={displayName}
followMutation={followMutation}
handleEditAgent={handleEditAgent}
handleMessage={handleMessage}
handleOpenActivity={handleOpenActivity}
isBot={isBot}
isFollowing={isFollowing}
isOwner={isOwner}
isSelf={isSelf}
managedAgent={managedAgent}
memoriesLoading={memoryQuery.isLoading}
memoryCount={memoryCount}
ownerDisplayName={ownerDisplayName}
ownerHandle={ownerHandle}
onOpenChannels={() => setView("channels")}
onOpenMemories={() => setView("memories")}
onOpenDm={onOpenDm}
presenceLoaded={presenceQuery.isSuccess}
presenceStatus={presenceStatus}
profile={profile}
pubkey={pubkey}
relayAgent={relayAgent}
unfollowMutation={unfollowMutation}
userStatus={userStatus}
/>
) : null}
{/* About */}
{profile?.about ? (
<div className="mt-4">
<h4 className="mb-1 text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
About
</h4>
<p className="text-sm leading-relaxed text-muted-foreground">
{profile.about}
</p>
</div>
{view === "memories" ? (
<MemoryFocusedView agentPubkey={pubkey} isOwner={isOwner} />
) : null}
{/* Actions */}
<div className="mt-6 flex flex-col gap-2">
{!isSelf ? (
isFollowing ? (
<Button
className="w-full"
disabled={unfollowMutation.isPending}
onClick={() =>
unfollowMutation.mutate(pubkey, {
onError: (error) =>
toast.error(
`Unfollow failed: ${error instanceof Error ? error.message : String(error)}`,
),
})
}
type="button"
variant="outline"
>
Unfollow
</Button>
) : (
<Button
className="w-full"
disabled={followMutation.isPending}
onClick={() =>
followMutation.mutate(pubkey, {
onError: (error) =>
toast.error(
`Follow failed: ${error instanceof Error ? error.message : String(error)}`,
),
})
}
type="button"
variant="default"
>
Follow
</Button>
)
) : null}
{onOpenDm && !isSelf ? (
<Button
className="w-full"
data-testid="user-profile-message"
onClick={handleMessage}
type="button"
>
<MessageSquare className="h-4 w-4" />
Message
</Button>
) : null}
{canViewActivity ? (
<button
className="flex w-full items-center gap-2 rounded-lg border border-border/60 px-3 py-2 text-left text-xs font-medium text-foreground transition-colors hover:bg-muted/50"
data-testid={`user-profile-view-activity-${pubkey}`}
onClick={() => {
onClose();
onOpenAgentSession?.(pubkey);
}}
type="button"
>
<Activity className="h-3.5 w-3.5 text-muted-foreground" />
View activity log
</button>
) : null}
{/* NIP-IA archive / unarchive. Gated to self / relay admin / OA
owner of viewee. The relay verifies authority — these gates are
purely a UX guard. */}
{canArchive && isArchived === false ? (
<Button
className="w-full"
data-testid="user-profile-archive-identity"
disabled={archiveMutation.isPending}
onClick={handleArchive}
type="button"
variant="secondary"
>
<Archive className="h-4 w-4" />
{archiveMutation.isPending ? "Archiving…" : "Archive identity"}
</Button>
) : null}
{canArchive && isArchived === true ? (
<Button
className="w-full"
data-testid="user-profile-unarchive-identity"
disabled={unarchiveMutation.isPending}
onClick={handleUnarchive}
type="button"
variant="secondary"
>
<ArchiveRestore className="h-4 w-4" />
{unarchiveMutation.isPending
? "Unarchiving…"
: "Unarchive identity"}
</Button>
) : null}
</div>
{view === "channels" ? (
<ChannelsFocusedView
channels={profileChannels}
isLoading={channelsQuery.isLoading}
onOpenChannel={handleOpenChannel}
/>
) : null}
</div>
</aside>
{canEditAgent && managedAgent ? (
<EditAgentDialog
agent={managedAgent}
onOpenChange={setEditAgentOpen}
open={editAgentOpen}
/>
) : null}
</>
);
}

View File

@@ -0,0 +1,889 @@
import * as React from "react";
import type { LucideIcon } from "lucide-react";
import {
Activity,
ArrowUpRight,
Brain,
ChevronDown,
ChevronRight,
ChevronUp,
Copy,
Cpu,
Fingerprint,
Hash,
MessageSquare,
Pencil,
Server,
Terminal,
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 { 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 { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
import type { ManagedAgent, RelayAgent } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
const RUNTIME_LABELS: Record<string, string> = {
goose: "Goose",
"claude-code": "Claude Code",
"codex-acp": "Codex",
aider: "Aider",
};
function runtimeLabel(command: string): string {
return RUNTIME_LABELS[command] ?? command;
}
async function copyToClipboard(value: string, label?: string) {
await navigator.clipboard.writeText(value);
toast.success(label ? `Copied ${label}` : "Copied to clipboard");
}
// ── Summary view ─────────────────────────────────────────────────────────────
export type ProfileSummaryViewProps = {
canEditAgent: boolean;
canViewActivity: boolean;
channelCount: number;
channelsLoading: boolean;
displayName: string;
followMutation: ReturnType<typeof useFollowMutation>;
handleEditAgent: () => void;
handleMessage: () => void;
handleOpenActivity: () => void;
isBot: boolean;
isFollowing: boolean;
isOwner: boolean | undefined;
isSelf: boolean;
managedAgent: ManagedAgent | undefined;
memoriesLoading: boolean;
memoryCount: number | undefined;
ownerDisplayName: string | null;
ownerHandle: string | null;
onOpenChannels: () => void;
onOpenMemories: () => void;
onOpenDm?: (pubkeys: string[]) => void;
presenceLoaded: boolean;
presenceStatus: "online" | "away" | "offline" | undefined;
profile: ReturnType<typeof useUserProfileQuery>["data"];
pubkey: string;
relayAgent: RelayAgent | undefined;
unfollowMutation: ReturnType<typeof useUnfollowMutation>;
userStatus: { text: string; emoji: string } | null | undefined;
};
export function ProfileSummaryView({
canEditAgent,
canViewActivity,
channelCount,
channelsLoading,
displayName,
followMutation,
handleEditAgent,
handleMessage,
handleOpenActivity,
isBot,
isFollowing,
isOwner,
isSelf,
managedAgent,
memoriesLoading,
memoryCount,
ownerDisplayName,
ownerHandle,
onOpenChannels,
onOpenMemories,
onOpenDm,
presenceLoaded,
presenceStatus,
profile,
pubkey,
relayAgent,
unfollowMutation,
userStatus,
}: ProfileSummaryViewProps) {
const metadataFields = [
...buildPublicFields({
pubkey,
profile,
relayAgent,
isBot,
}),
...(isOwner === true
? buildOwnerFields({
managedAgent,
ownerDisplayName,
ownerHandle,
presenceLoaded,
presenceStatus,
relayAgent,
})
: []),
];
const showMemoriesIngress = isOwner === true;
const showChannelsIngress =
channelsLoading || channelCount > 0 || isBot || relayAgent !== undefined;
return (
<div className="flex flex-col gap-6 pt-4">
<ProfileHero
displayName={displayName}
isBot={isBot}
presenceStatus={presenceStatus}
profile={profile}
userStatus={userStatus}
/>
{!isSelf ? (
<ProfilePrimaryActions
canEditAgent={canEditAgent}
followMutation={followMutation}
onEditAgent={handleEditAgent}
isFollowing={isFollowing}
onMessage={onOpenDm ? handleMessage : undefined}
pubkey={pubkey}
unfollowMutation={unfollowMutation}
/>
) : null}
{showMemoriesIngress || showChannelsIngress || canViewActivity ? (
<section className="space-y-2">
{showMemoriesIngress ? (
<ProfileIngressRow
icon={Brain}
label="Memories"
onClick={onOpenMemories}
testId="user-profile-memories-ingress"
trailing={
memoriesLoading
? "Loading…"
: memoryCount !== undefined
? String(memoryCount)
: "View"
}
/>
) : null}
{showChannelsIngress ? (
<ProfileIngressRow
icon={Hash}
label="Channels"
onClick={onOpenChannels}
testId="user-profile-channels-ingress"
trailing={
channelsLoading
? "Loading…"
: channelCount > 0
? String(channelCount)
: "None"
}
/>
) : null}
{canViewActivity ? (
<ProfileIngressRow
icon={Activity}
label="Activity log"
onClick={handleOpenActivity}
testId={`user-profile-view-activity-${pubkey}`}
/>
) : null}
</section>
) : null}
{metadataFields.length > 0 ? (
<ProfileFieldGroup fields={metadataFields} />
) : null}
</div>
);
}
// ── Hero & metadata ──────────────────────────────────────────────────────────
function ProfileHero({
displayName,
isBot,
presenceStatus,
profile,
userStatus,
}: {
displayName: string;
isBot: boolean;
presenceStatus: "online" | "away" | "offline" | undefined;
profile: ProfileSummaryViewProps["profile"];
userStatus: ProfileSummaryViewProps["userStatus"];
}) {
return (
<div className="flex flex-col items-center gap-3 text-center">
<div className="relative">
<ProfileAvatar
avatarUrl={profile?.avatarUrl ?? null}
className="h-20 w-20 text-xl"
iconClassName="h-8 w-8"
label={displayName}
plain
testId="user-profile-avatar"
/>
{presenceStatus ? (
<span
aria-label={getPresenceLabel(presenceStatus)}
className="absolute bottom-0 right-0 flex h-6 w-6 items-center justify-center rounded-full bg-background"
data-testid="user-profile-presence-badge"
role="img"
>
<PresenceDot className="h-3.5 w-3.5" status={presenceStatus} />
</span>
) : null}
</div>
<div className="flex flex-col items-center gap-1">
<div className="flex items-center justify-center gap-2">
<h3 className="text-xl font-semibold tracking-tight">
{displayName}
</h3>
{isBot ? (
<BotIdenticon
className="shrink-0 rounded"
size={20}
value={displayName}
/>
) : null}
</div>
{profile?.about?.trim() ? (
<ProfileHeroDescription
about={profile.about.trim()}
key={profile.about.trim()}
/>
) : null}
{profile?.nip05Handle ? (
<p className="text-sm text-muted-foreground">{profile.nip05Handle}</p>
) : null}
{userStatus ? (
<p className="text-sm text-muted-foreground">
{userStatus.emoji ? (
<StatusEmoji
className="mr-1 inline h-3.5 w-3.5"
value={userStatus.emoji}
/>
) : null}
{userStatus.text}
</p>
) : null}
</div>
</div>
);
}
function ProfileHeroDescription({ about }: { about: string }) {
const [expanded, setExpanded] = React.useState(false);
const [isTruncated, setIsTruncated] = React.useState(false);
const textRef = React.useRef<HTMLParagraphElement>(null);
const measureTruncation = React.useCallback(() => {
const element = textRef.current;
if (!element || expanded) {
return;
}
setIsTruncated(element.scrollHeight > element.clientHeight + 1);
}, [expanded]);
React.useLayoutEffect(() => {
measureTruncation();
}, [measureTruncation]);
React.useEffect(() => {
const element = textRef.current;
if (!element) {
return;
}
const observer = new ResizeObserver(() => {
measureTruncation();
});
observer.observe(element);
return () => observer.disconnect();
}, [measureTruncation]);
const toggleClassName =
"inline-flex items-center gap-0.5 text-xs font-medium text-muted-foreground opacity-60 transition-opacity hover:text-foreground hover:opacity-100";
return (
<div className="flex w-full flex-col items-center gap-0.5">
<div className="w-fit max-w-full px-2">
<p
className={cn(
"text-center whitespace-pre-wrap text-sm leading-relaxed text-muted-foreground",
!expanded && "line-clamp-3",
)}
data-testid="user-profile-description"
ref={textRef}
>
{about}
</p>
</div>
{!expanded && isTruncated ? (
<button
className={toggleClassName}
data-testid="user-profile-description-toggle"
onClick={() => setExpanded(true)}
type="button"
>
more
<ChevronDown className="h-3 w-3" />
</button>
) : null}
{expanded ? (
<button
className={toggleClassName}
data-testid="user-profile-description-toggle"
onClick={() => setExpanded(false)}
type="button"
>
less
<ChevronUp className="h-3 w-3" />
</button>
) : null}
</div>
);
}
// ── Primary actions ──────────────────────────────────────────────────────────
function ProfilePrimaryActions({
canEditAgent,
followMutation,
isFollowing,
onEditAgent,
onMessage,
pubkey,
unfollowMutation,
}: {
canEditAgent: boolean;
followMutation: ReturnType<typeof useFollowMutation>;
isFollowing: boolean;
onEditAgent: () => void;
onMessage?: () => void;
pubkey: string;
unfollowMutation: ReturnType<typeof useUnfollowMutation>;
}) {
return (
<div className="flex items-start justify-center gap-8">
{isFollowing ? (
<ProfileQuickAction
active
disabled={unfollowMutation.isPending}
icon={UserMinus}
label="Unfollow"
onClick={() =>
unfollowMutation.mutate(pubkey, {
onError: (error) =>
toast.error(
`Unfollow failed: ${error instanceof Error ? error.message : String(error)}`,
),
})
}
/>
) : (
<ProfileQuickAction
disabled={followMutation.isPending}
icon={UserPlus}
label="Follow"
onClick={() =>
followMutation.mutate(pubkey, {
onError: (error) =>
toast.error(
`Follow failed: ${error instanceof Error ? error.message : String(error)}`,
),
})
}
/>
)}
{onMessage ? (
<ProfileQuickAction
icon={MessageSquare}
label="Message"
onClick={onMessage}
testId="user-profile-message"
/>
) : null}
{canEditAgent ? (
<ProfileQuickAction
icon={Pencil}
label="Edit"
onClick={onEditAgent}
testId="user-profile-edit-agent"
/>
) : null}
</div>
);
}
function ProfileQuickAction({
active,
disabled,
icon: Icon,
label,
onClick,
testId,
}: {
active?: boolean;
disabled?: boolean;
icon: LucideIcon;
label: string;
onClick: () => void;
testId?: string;
}) {
return (
<button
className="flex flex-col items-center gap-2 disabled:cursor-not-allowed disabled:opacity-50"
data-testid={testId}
disabled={disabled}
onClick={onClick}
type="button"
>
<span
className={cn(
"flex h-14 w-14 items-center justify-center rounded-full transition-colors",
active
? "bg-foreground text-background hover:bg-foreground/90"
: "bg-muted/60 text-foreground hover:bg-muted/80",
)}
>
<Icon className="h-5 w-5" />
</span>
<span
className={cn(
"text-xs",
active ? "text-foreground" : "text-muted-foreground",
)}
>
{label}
</span>
</button>
);
}
// ── Field rows ───────────────────────────────────────────────────────────────
type ProfileField = {
copyValue?: string;
/**
* Plain-text representation. Always required so non-visual surfaces (e.g. tooltips,
* copy-to-clipboard) keep working. When `displayNode` is set, the row renders that
* instead of the text — but the text still drives the title/tooltip.
*/
displayValue: string;
/**
* Optional rich rendering for the value cell (e.g. a status badge). When present,
* replaces the plain text node in the row.
*/
displayNode?: React.ReactNode;
icon: LucideIcon;
label: string;
testId?: string;
};
function buildPublicFields({
isBot,
profile,
pubkey,
relayAgent,
}: {
isBot: boolean;
profile: ProfileSummaryViewProps["profile"];
pubkey: string;
relayAgent: RelayAgent | undefined;
}): ProfileField[] {
const fields: ProfileField[] = [
{
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 (relayAgent?.capabilities.length) {
fields.push({
copyValue: relayAgent.capabilities.join(", "),
displayValue: relayAgent.capabilities.join(", "),
icon: Server,
label: "Capabilities",
testId: "user-profile-capabilities",
});
}
return fields;
}
function buildOwnerFields({
managedAgent,
ownerDisplayName,
ownerHandle,
presenceLoaded,
presenceStatus,
relayAgent,
}: {
managedAgent: ManagedAgent | undefined;
ownerDisplayName: string | null;
ownerHandle: string | null;
presenceLoaded: boolean;
presenceStatus: "online" | "away" | "offline" | undefined;
relayAgent: RelayAgent | undefined;
}): ProfileField[] {
const fields: ProfileField[] = [];
if (ownerDisplayName) {
fields.push({
copyValue: ownerHandle ?? undefined,
displayValue: ownerDisplayName,
icon: UserRound,
label: "Owned by",
testId: "user-profile-owned-by",
});
}
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",
});
}
if (managedAgent) {
fields.push({
displayValue: managedAgent.status
.replace(/_/g, " ")
.replace(/\b\w/g, (char: string) => char.toUpperCase()),
displayNode: (
<AgentStatusBadge
presenceLoaded={presenceLoaded}
presenceStatus={presenceStatus}
status={managedAgent.status}
/>
),
icon: Activity,
label: "Status",
testId: "user-profile-agent-status",
});
}
if (managedAgent?.model) {
fields.push({
copyValue: managedAgent.model,
displayValue: managedAgent.model,
icon: Cpu,
label: "Model",
testId: "user-profile-model",
});
}
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",
});
fields.push({
displayValue: managedAgent.respondTo.replace(/-/g, " "),
icon: MessageSquare,
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 ProfileFieldGroup({ fields }: { fields: ProfileField[] }) {
const publicKeyLabel = "Public key";
const ownedByLabel = "Owned by";
const statusLabel = "Status";
const orderedFields = [
...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;
}),
];
return (
<section>
<div className="overflow-hidden rounded-2xl bg-muted/20">
{orderedFields.map((field) => (
<ProfileFieldRow field={field} key={field.testId ?? field.label} />
))}
</div>
</section>
);
}
function ProfileFieldRow({ field }: { field: ProfileField }) {
const Icon = field.icon;
const isCopyable = Boolean(field.copyValue);
const content = (
<>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted/60">
<Icon className="h-4 w-4 text-muted-foreground" />
</span>
<span className="min-w-0 flex-1 text-left">
<span className="block text-xs font-medium text-foreground">
{field.label}
</span>
<span
className="mt-0.5 block truncate text-sm text-muted-foreground"
title={field.displayValue}
>
{field.displayNode ?? field.displayValue}
</span>
</span>
{isCopyable ? (
<Copy className="h-4 w-4 shrink-0 text-muted-foreground" />
) : null}
</>
);
if (isCopyable && field.copyValue) {
return (
<button
aria-label={`Copy ${field.label}`}
className="flex w-full items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-muted/40"
data-testid={field.testId}
onClick={() => void copyToClipboard(field.copyValue ?? "", field.label)}
title={`Copy ${field.label}`}
type="button"
>
{content}
</button>
);
}
return (
<div
className="flex items-center gap-3 px-4 py-3"
data-testid={field.testId}
>
{content}
</div>
);
}
// ── Ingress rows ─────────────────────────────────────────────────────────────
function ProfileIngressRow({
icon: Icon,
label,
onClick,
testId,
trailing,
}: {
icon: LucideIcon;
label: string;
onClick: () => void;
testId: string;
trailing?: string;
}) {
return (
<button
className="flex w-full items-center gap-3 rounded-2xl bg-muted/20 px-4 py-2 text-left transition-colors hover:bg-muted/40"
data-testid={testId}
onClick={onClick}
type="button"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted/60">
<Icon className="h-4 w-4 text-muted-foreground" />
</span>
<span className="min-w-0 flex-1 text-sm font-medium text-foreground">
{label}
</span>
{trailing ? (
<span className="text-sm text-muted-foreground">{trailing}</span>
) : null}
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
);
}
// ── Focused views ────────────────────────────────────────────────────────────
export function MemoryFocusedView({
agentPubkey,
isOwner,
}: {
agentPubkey: string;
isOwner: boolean | undefined;
}) {
if (isOwner !== true) {
return null;
}
return (
<div className="pt-4">
<MemorySection agentPubkey={agentPubkey} />
</div>
);
}
type ProfileChannelLink = {
id: string;
name: string;
};
export function ChannelsFocusedView({
channels,
isLoading,
onOpenChannel,
}: {
channels: ProfileChannelLink[];
isLoading: boolean;
onOpenChannel: (channelId: string) => void;
}) {
if (isLoading) {
return (
<p className="pt-4 text-base leading-7 text-muted-foreground">
Loading channels…
</p>
);
}
if (channels.length === 0) {
return (
<p
className="pt-4 text-base leading-7 italic text-muted-foreground"
data-testid="user-profile-channels-empty"
>
No visible channel memberships.
</p>
);
}
return (
<ul
className="overflow-hidden rounded-2xl bg-muted/20"
data-testid="user-profile-channels-list"
>
{channels.map((channel) => (
<li key={channel.id}>
<button
aria-label={`Open #${channel.name}`}
className="group flex w-full items-center gap-3 px-4 py-3 text-left text-base leading-7 text-foreground transition-colors hover:bg-muted/40"
data-testid={`user-profile-channel-link-${channel.name}`}
onClick={() => onOpenChannel(channel.id)}
type="button"
>
<span className="min-w-0 flex-1 truncate">#{channel.name}</span>
<ArrowUpRight
aria-hidden="true"
className="h-4 w-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground"
/>
</button>
</li>
))}
</ul>
);
}

View File

@@ -0,0 +1,54 @@
import { invokeTauri } from "@/shared/api/tauri";
// ── NIP-AE agent memory (engram) reads ──────────────────────────────────────
/**
* One memory entry. `slug` is the canonical engram slug — `core` for the
* agent's identity profile, `mem/...` for everything else. `body` is the
* decrypted UTF-8 payload. `outgoingRefs` is the list of `[[slug]]`
* references parsed from the body; the UI BFSes from `core.outgoingRefs`
* to compute reachable vs orphan sets.
*/
export type EngramEntry = {
slug: string;
body: string;
eventId: string;
/** Unix seconds. */
createdAt: number;
outgoingRefs: string[];
};
/**
* Response shape for `get_agent_memory`. `core` is split from `memories`
* because the UI roots its reachability tree there. `truncated` flags a
* relay cap hit (>= 5000 events for this (agent, owner) pair). `fetchedAt`
* (unix seconds) is for "last loaded" copy on the refetch affordance.
*/
export type AgentMemoryListing = {
core: EngramEntry | null;
memories: EngramEntry[];
truncated: boolean;
fetchedAt: number;
};
/**
* Owner-gated single-payload engram listing. The Rust side enforces that
* the requested agent appears in this desktop's `managed_agents` store
* before deriving the conversation key or attempting decrypt — non-owners
* receive an `Err` (and the UI hides the section anyway).
*
* Throws on:
* - non-hex agent pubkey
* - viewer is not the agent's owner
* - relay query failure
*
* Returns `{ core: null, memories: [] }` when the agent has no engrams —
* that's the legitimate empty state, distinct from a thrown error.
*/
export async function getAgentMemory(
agentPubkey: string,
): Promise<AgentMemoryListing> {
return invokeTauri<AgentMemoryListing>("get_agent_memory", {
agentPubkey,
});
}

View File

@@ -41,6 +41,7 @@ type E2eConfig = {
acp?: MockCommandAvailability;
mcp?: MockCommandAvailability;
};
agentMemory?: RawAgentMemoryListing | Record<string, RawAgentMemoryListing>;
profileReadDelayMs?: number;
profileReadError?: string;
profileUpdateError?: string;
@@ -346,6 +347,21 @@ type RawManagedAgentLog = {
log_path: string;
};
type RawEngramEntry = {
slug: string;
body: string;
eventId: string;
createdAt: number;
outgoingRefs: string[];
};
type RawAgentMemoryListing = {
core: RawEngramEntry | null;
memories: RawEngramEntry[];
truncated: boolean;
fetchedAt: number;
};
type RawCommandAvailability = {
command: string;
resolved_path: string | null;
@@ -815,6 +831,24 @@ function cloneManagedAgent(agent: MockManagedAgent): RawManagedAgent {
};
}
function cloneEngramEntry(entry: RawEngramEntry): RawEngramEntry {
return {
...entry,
outgoingRefs: [...entry.outgoingRefs],
};
}
function cloneAgentMemoryListing(
listing: RawAgentMemoryListing,
): RawAgentMemoryListing {
return {
core: listing.core ? cloneEngramEntry(listing.core) : null,
memories: listing.memories.map(cloneEngramEntry),
truncated: listing.truncated,
fetchedAt: listing.fetchedAt,
};
}
function resetMockRelayMembers(config: E2eConfig | undefined) {
const pubkey = getMockMemberPubkey(config);
// Drive the active identity's role from `mock.relayRole` so the e2e harness
@@ -4059,6 +4093,59 @@ async function handleListManagedAgents(): Promise<RawManagedAgent[]> {
return mockManagedAgents.map(cloneManagedAgent);
}
function isAgentMemoryListing(
value: RawAgentMemoryListing | Record<string, RawAgentMemoryListing>,
): value is RawAgentMemoryListing {
return (
"memories" in value &&
Array.isArray(value.memories) &&
"truncated" in value &&
"fetchedAt" in value
);
}
async function handleGetAgentMemory(
args: { agentPubkey?: string },
config: E2eConfig | undefined,
): Promise<RawAgentMemoryListing> {
const pubkey = args.agentPubkey?.toLowerCase();
if (!pubkey) {
throw new Error("mock get_agent_memory: missing agent pubkey");
}
const isManagedAgent = mockManagedAgents.some(
(agent) => agent.pubkey.toLowerCase() === pubkey,
);
if (!isManagedAgent) {
throw new Error(`mock get_agent_memory: unmanaged agent ${pubkey}`);
}
const configuredMemory = config?.mock?.agentMemory;
if (!configuredMemory) {
return {
core: null,
memories: [],
truncated: false,
fetchedAt: Math.floor(Date.now() / 1000),
};
}
if (isAgentMemoryListing(configuredMemory)) {
return cloneAgentMemoryListing(configuredMemory);
}
const listing =
configuredMemory[pubkey] ?? configuredMemory[args.agentPubkey ?? ""];
return listing
? cloneAgentMemoryListing(listing)
: {
core: null,
memories: [],
truncated: false,
fetchedAt: Math.floor(Date.now() / 1000),
};
}
async function handleListPersonas(): Promise<RawPersona[]> {
return mockPersonas.map((persona) => ({ ...persona }));
}
@@ -4454,6 +4541,14 @@ async function handleCreateManagedAgent(args: {
};
mockManagedAgents.unshift(managedAgent);
applyMockDisplayName(pubkey, name);
mockProfiles.set(pubkey, {
pubkey,
display_name: name,
avatar_url: args.input.avatarUrl?.trim() || null,
about: args.input.systemPrompt?.trim() || null,
nip05_handle: null,
});
syncMockRelayAgentsFromManagedAgents();
return {
@@ -5789,6 +5884,11 @@ export function maybeInstallE2eTauriMocks() {
return handleExportPersonaToJson(payload as { id: string });
case "list_managed_agents":
return handleListManagedAgents();
case "get_agent_memory":
return handleGetAgentMemory(
(payload as Parameters<typeof handleGetAgentMemory>[0]) ?? {},
activeConfig,
);
case "create_managed_agent":
return handleCreateManagedAgent(
payload as Parameters<typeof handleCreateManagedAgent>[0],

View File

@@ -1,115 +0,0 @@
import { expect, test } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
// NIP-IA archive button + "Archived" flair gate matrix.
//
// Guards the composition `canArchive = isSelf || isRelayAdminOrOwner ||
// isOaOwnerOfViewee` in UserProfilePanel.tsx. Unit tests cover each input in
// isolation; this spec covers the OR composition where silent regressions
// (refactor turns OR into AND, role expansion bypasses a branch, etc.) would
// otherwise slip past code review.
const ALICE_PUBKEY =
"953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f";
async function openSelfProfile(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// First seed message in #general is from the active identity.
const firstMessage = page.getByTestId("message-row").first();
await firstMessage.locator("button", { hasText: "npub1mock..." }).click();
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
}
async function openAliceProfile(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
// Second seed message in #general is from Alice. Her display name "alice"
// is registered in mockDisplayNames, so the author button text is "alice".
const aliceMessage = page.getByTestId("message-row").nth(1);
await aliceMessage.locator("button", { hasText: "alice" }).first().click();
const panel = page.getByTestId("user-profile-panel");
await expect(panel).toBeVisible();
await expect(panel).toContainText(ALICE_PUBKEY.slice(0, 8));
}
test.describe("NIP-IA archive button gate", () => {
test("case 1 — self viewer + self target: Archive visible, no flair", async ({
page,
}) => {
await installMockBridge(page, { relayRole: null, oaOwnerIsMe: false });
await openSelfProfile(page);
await expect(
page.getByTestId("user-profile-archive-identity"),
).toBeVisible();
await expect(page.getByTestId("user-profile-archived-flair")).toHaveCount(
0,
);
});
test("case 2 — relay admin viewing Alice: Archive visible", async ({
page,
}) => {
await installMockBridge(page, {
relayRole: "admin",
oaOwnerIsMe: false,
archivedIdentities: [],
});
await openAliceProfile(page);
await expect(
page.getByTestId("user-profile-archive-identity"),
).toBeVisible();
});
test("case 3 — verified OA owner viewing Alice: Archive visible", async ({
page,
}) => {
await installMockBridge(page, {
relayRole: null,
oaOwnerIsMe: true,
archivedIdentities: [],
});
await openAliceProfile(page);
await expect(
page.getByTestId("user-profile-archive-identity"),
).toBeVisible();
});
test("case 4 — no authority viewing Alice: Archive hidden", async ({
page,
}) => {
await installMockBridge(page, {
relayRole: null,
oaOwnerIsMe: false,
archivedIdentities: [],
});
await openAliceProfile(page);
await expect(page.getByTestId("user-profile-archive-identity")).toHaveCount(
0,
);
await expect(
page.getByTestId("user-profile-unarchive-identity"),
).toHaveCount(0);
});
test("case 5 — Alice archived: flair + Unarchive button (under admin gate)", async ({
page,
}) => {
await installMockBridge(page, {
relayRole: "admin",
oaOwnerIsMe: false,
archivedIdentities: [ALICE_PUBKEY],
});
await openAliceProfile(page);
await expect(page.getByTestId("user-profile-archived-flair")).toBeVisible();
await expect(
page.getByTestId("user-profile-unarchive-identity"),
).toBeVisible();
await expect(page.getByTestId("user-profile-archive-identity")).toHaveCount(
0,
);
});
});

View File

@@ -1,6 +1,9 @@
import { expect, test, type Page } from "@playwright/test";
import { installMockBridge } from "../helpers/bridge";
import {
createMockAgentMemoryListing,
installMockBridge,
} from "../helpers/bridge";
import { openProfileMenu, openSettings } from "../helpers/settings";
async function expectHomeView(page: import("@playwright/test").Page) {
@@ -51,6 +54,61 @@ async function waitForReactEffects(page: Page) {
);
}
async function addGenericAgent(
page: Page,
channelName: string,
agentName: string,
) {
await page.getByTestId(`channel-${channelName}`).click();
await expect(page.getByTestId("chat-title")).toHaveText(channelName);
await page.getByTestId("channel-add-bot-trigger").click();
await expect(page.getByRole("heading", { name: "Add agents" })).toBeVisible();
await page.getByRole("button", { name: "Generic" }).click();
await page.locator("#channel-generic-name").fill(agentName);
await page
.locator("#channel-generic-prompt")
.fill("Watch the channel and help when asked.");
await page.getByRole("button", { name: "Add agent" }).click();
await expect(page.getByRole("heading", { name: "Add agents" })).toHaveCount(
0,
);
}
async function getManagedAgentPubkey(page: Page, agentName: string) {
await page.getByTestId("open-agents-view").click();
const managedAgentRow = page
.locator('[data-testid^="managed-agent-"]')
.filter({ hasText: agentName });
await expect(managedAgentRow).toHaveCount(1);
const managedAgentTestId = await managedAgentRow
.first()
.getAttribute("data-testid");
if (!managedAgentTestId) {
throw new Error("Managed agent row test id missing.");
}
return managedAgentTestId.replace("managed-agent-", "");
}
async function waitForMockLiveSubscription(page: Page, channelName: string) {
await expect
.poll(async () => {
return page.evaluate((channelName) => {
return (
(
window as Window & {
__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
channelName: string;
}) => boolean;
}
).__SPROUT_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ channelName }) ??
false
);
}, channelName);
})
.toBe(true);
}
test.beforeEach(async ({ page }) => {
await installMockBridge(page);
});
@@ -497,6 +555,67 @@ test("updates presence from the profile menu", async ({ page }) => {
).toContainText("Offline");
});
test("renders agent memories seeded through the Playwright mock bridge", async ({
page,
}) => {
await installMockBridge(page, {
agentMemory: createMockAgentMemoryListing(),
});
await page.goto("/");
await addGenericAgent(page, "general", "Memory Bot");
const agentPubkey = await getManagedAgentPubkey(page, "Memory Bot");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await waitForMockLiveSubscription(page, "general");
await page.evaluate(
({ pubkey }) => {
const emit = (
window as Window & {
__SPROUT_E2E_EMIT_MOCK_MESSAGE__?: (input: {
channelName: string;
content: string;
pubkey: string;
}) => unknown;
}
).__SPROUT_E2E_EMIT_MOCK_MESSAGE__;
if (!emit) {
throw new Error("Mock message emitter is unavailable.");
}
emit({
channelName: "general",
content: "Memory bot check-in",
pubkey,
});
},
{ pubkey: agentPubkey },
);
const messageRow = page
.getByTestId("message-row")
.filter({ hasText: "Memory bot check-in" });
await expect(messageRow).toBeVisible();
await messageRow.locator("button").first().click();
await expect(page.getByTestId("user-profile-panel")).toBeVisible();
const memoriesIngress = page.getByTestId("user-profile-memories-ingress");
await expect(memoriesIngress).toContainText("Memories");
await expect(memoriesIngress).toContainText("9");
await memoriesIngress.click();
await expect(page.getByTestId("agent-memory-section")).toBeVisible();
await expect(page.getByTestId("agent-memory-list")).toContainText(
"ui-density",
);
await expect(page.getByTestId("agent-memory-truncated")).toContainText(
"View all (9)",
);
await page.getByTestId("agent-memory-truncated").click();
await expect(page.getByTestId("agent-memory-list")).toContainText("orphan");
});
test("renders settings in the app shell with a back button", async ({
page,
}) => {

View File

@@ -42,8 +42,28 @@ type MockCommandAvailability = {
resolvedPath?: string | null;
};
export type MockEngramEntry = {
slug: string;
body: string;
eventId: string;
createdAt: number;
outgoingRefs: string[];
};
export type MockAgentMemoryListing = {
core: MockEngramEntry | null;
memories: MockEngramEntry[];
truncated: boolean;
fetchedAt: number;
};
type MockBridgeOptions = {
acpRuntimesCatalog?: Record<string, unknown>[];
/**
* Listing returned by the mocked `get_agent_memory` command. Pass a single
* listing for any managed agent, or a pubkey-keyed record for per-agent data.
*/
agentMemory?: MockAgentMemoryListing | Record<string, MockAgentMemoryListing>;
managedAgentPrereqs?: {
acp?: MockCommandAvailability;
mcp?: MockCommandAvailability;
@@ -115,6 +135,115 @@ const ONBOARDING_COMPLETION_STORAGE_KEY_PREFIX =
const DEFAULT_MOCK_PUBKEY = "deadbeef".repeat(8);
const DEFAULT_RELAY_WS_URL = "ws://localhost:3000";
function cloneEngramEntry(entry: MockEngramEntry): MockEngramEntry {
return {
...entry,
outgoingRefs: [...entry.outgoingRefs],
};
}
/**
* Recreates the old Memories UI development fixture as explicit Playwright
* seed data. Use with `installMockBridge(page, { agentMemory: ... })`.
*/
export function createMockAgentMemoryListing(
overrides: Partial<MockAgentMemoryListing> = {},
): MockAgentMemoryListing {
const listing: MockAgentMemoryListing = {
core: {
slug: "core",
body: `I am a mock agent used to flesh out the Memories panel.
I prefer concise updates, explicit next steps, and visual polish before edge-case handling.
See [[mem/preferences/ui-density]] and [[mem/projects/sprout-memory-viewer]] for details.
A retired launch checklist used to live at [[mem/archive/deleted-launch-checklist]], but that memory was deleted after the plan changed.`,
eventId: "mock-core",
createdAt: 1_700_000_000,
outgoingRefs: [
"mem/preferences/ui-density",
"mem/projects/sprout-memory-viewer",
"mem/archive/deleted-launch-checklist",
],
},
memories: [
{
slug: "mem/preferences/ui-density",
body: "Prefer compact lists with generous body text when expanded.\n\nNested ref: [[mem/working-style/review-loop]]",
eventId: "mock-ui-density",
createdAt: 1_700_000_100,
outgoingRefs: ["mem/working-style/review-loop"],
},
{
slug: "mem/working-style/review-loop",
body: "Ship small slices, screenshot the happy path, then iterate on empty/error states.",
eventId: "mock-review-loop",
createdAt: 1_700_000_200,
outgoingRefs: [],
},
{
slug: "mem/projects/sprout-memory-viewer",
body: "Building the IXI-7 read-only memory viewer in the profile panel.\n\nChild memory: [[mem/projects/sprout-memory-viewer/notes]]",
eventId: "mock-project",
createdAt: 1_700_000_300,
outgoingRefs: ["mem/projects/sprout-memory-viewer/notes"],
},
{
slug: "mem/projects/sprout-memory-viewer/notes",
body: "Tree should auto-expand core. Everything else collapsed with a one-line preview.",
eventId: "mock-project-notes",
createdAt: 1_700_000_400,
outgoingRefs: [],
},
{
slug: "mem/people/alice",
body: "Alice prefers async updates in #design.",
eventId: "mock-alice",
createdAt: 1_700_000_500,
outgoingRefs: [],
},
{
slug: "mem/people/bob",
body: "Bob reviews PRs quickly but wants screenshots.",
eventId: "mock-bob",
createdAt: 1_700_000_600,
outgoingRefs: ["mem/people/alice"],
},
{
slug: "mem/scratch/todo",
body: "",
eventId: "mock-empty",
createdAt: 1_700_000_700,
outgoingRefs: [],
},
{
slug: "mem/orphan/unreferenced",
body: "This orphaned note is not reachable from core. It still points at [[mem/research/old-panel-sketches]], a deleted design scratchpad from an earlier pass.",
eventId: "mock-orphan",
createdAt: 1_700_000_800,
outgoingRefs: ["mem/research/old-panel-sketches"],
},
],
truncated: true,
fetchedAt: Math.floor(Date.now() / 1000),
};
return {
core:
overrides.core === undefined
? listing.core
? cloneEngramEntry(listing.core)
: null
: overrides.core
? cloneEngramEntry(overrides.core)
: null,
memories: (overrides.memories ?? listing.memories).map(cloneEngramEntry),
truncated: overrides.truncated ?? listing.truncated,
fetchedAt: overrides.fetchedAt ?? listing.fetchedAt,
};
}
async function seedOnboardingCompletionForKnownIdentities(page: Page) {
const pubkeys = [
DEFAULT_MOCK_PUBKEY,