mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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()]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
227
desktop/src-tauri/src/commands/engrams.rs
Normal file
227
desktop/src-tauri/src/commands/engrams.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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");
|
||||
|
||||
96
desktop/src/features/agent-memory/hooks.ts
Normal file
96
desktop/src/features/agent-memory/hooks.ts
Normal 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 };
|
||||
}
|
||||
144
desktop/src/features/agent-memory/lib/buildMemoryGraph.test.mjs
Normal file
144
desktop/src/features/agent-memory/lib/buildMemoryGraph.test.mjs
Normal 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, []);
|
||||
});
|
||||
121
desktop/src/features/agent-memory/lib/buildMemoryGraph.ts
Normal file
121
desktop/src/features/agent-memory/lib/buildMemoryGraph.ts
Normal 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 };
|
||||
}
|
||||
563
desktop/src/features/agent-memory/ui/MemorySection.tsx
Normal file
563
desktop/src/features/agent-memory/ui/MemorySection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
39
desktop/src/features/agents/ui/AgentStatusBadge.tsx
Normal file
39
desktop/src/features/agents/ui/AgentStatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -565,6 +565,7 @@ export function ChannelScreen({
|
||||
onExpandThreadReplies={handleExpandThreadReplies}
|
||||
onOpenAgentSession={handleOpenAgentSession}
|
||||
onOpenDm={handleOpenDm}
|
||||
onOpenProfilePanel={handleOpenProfilePanel}
|
||||
onResetThreadPanelWidth={handleThreadPanelWidthReset}
|
||||
onCloseProfilePanel={handleCloseProfilePanel}
|
||||
onOpenThread={handleOpenThreadAndCloseAgentSession}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
889
desktop/src/features/profile/ui/UserProfilePanelSections.tsx
Normal file
889
desktop/src/features/profile/ui/UserProfilePanelSections.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
desktop/src/shared/api/tauriEngrams.ts
Normal file
54
desktop/src/shared/api/tauriEngrams.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user