diff --git a/crates/memayu-web/src/lib.rs b/crates/memayu-web/src/lib.rs index 9166415..72fb800 100644 --- a/crates/memayu-web/src/lib.rs +++ b/crates/memayu-web/src/lib.rs @@ -76,6 +76,7 @@ pub fn build_web_router( .route("/", get(|| async { Redirect::permanent("/home") })) .route("/home", get(pages::home::get_home)) .route("/home/list", get(pages::home::get_home_list)) + .route("/home/memory/{id}", get(pages::memory::get_memory_detail)) .route("/home/search", post(pages::home::post_search)) .route("/requests", get(pages::requests::get_requests)) .route( diff --git a/crates/memayu-web/src/pages/home.rs b/crates/memayu-web/src/pages/home.rs index 175b3ae..03230f9 100644 --- a/crates/memayu-web/src/pages/home.rs +++ b/crates/memayu-web/src/pages/home.rs @@ -115,6 +115,8 @@ pub async fn get_home( } // Memory list (swappable HTMX fragment) (list_fragment(&page)) + // HTMX target for the memory-detail modal (loaded on row click). + div id="memory-detail-target" {} // Pager controls (kept outside the swap target): total on the left, // Prev/Next on the right. div class="flex items-center justify-between gap-2 mt-4 w-full" { @@ -176,7 +178,11 @@ fn list_fragment(page: &MemoryPage) -> maud::Markup { } tbody { @for mem in &page.memories { - tr { + tr class="cursor-pointer hover:bg-base-200" + hx-get=(format!("/home/memory/{}", mem.id)) + hx-target="#memory-detail-target" + hx-swap="innerHTML" + title="View full memory" { td { p class="truncate" { (mem.content) } } @@ -269,6 +275,7 @@ pub async fn post_search( })? .into_iter() .map(|m| SearchResultRow { + id: m.id.clone(), content: m.content, created_at: m.created_at, score: None, @@ -286,6 +293,7 @@ pub async fn post_search( })? .into_iter() .map(|(m, score)| SearchResultRow { + id: m.id.clone(), content: m.content, created_at: m.created_at, score: Some(score), @@ -327,7 +335,11 @@ pub async fn post_search( } tbody { @for r in &results { - tr { + tr class="cursor-pointer hover:bg-base-200" + hx-get=(format!("/home/memory/{}", r.id)) + hx-target="#memory-detail-target" + hx-swap="innerHTML" + title="View full memory" { td { p class="truncate" { (r.content) } } @@ -346,12 +358,15 @@ pub async fn post_search( } } } + // HTMX target for the memory-detail modal (loaded on row click). + div id="memory-detail-target" {} } }, ))) } struct SearchResultRow { + id: String, content: String, created_at: chrono::DateTime, score: Option, diff --git a/crates/memayu-web/src/pages/memory.rs b/crates/memayu-web/src/pages/memory.rs new file mode 100644 index 0000000..661871d --- /dev/null +++ b/crates/memayu-web/src/pages/memory.rs @@ -0,0 +1,100 @@ +//! Memory detail view for the dashboard. Returns an HTMX modal fragment so the +//! full, untruncated content (plus metadata) can be read even for multi-KB +//! memories — the list rows only ever render a `truncate` snippet (issue #56). + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::Html; +use memayu_core::Memory; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// Render a single metadata entry as a small key/value badge. +fn meta_badge(key: &str, value: &str) -> maud::Markup { + maud::html! { + div class="badge badge-outline badge-sm gap-1 py-3 font-mono" { + span class="text-base-content/50" { (key) ": " } + span { (value) } + } + } +} + +/// `GET /home/memory/{id}` — modal fragment showing the full memory. +/// +/// The list row only shows a truncated snippet; this endpoint returns a dialog +/// fragment with the complete content in a scrollable region plus created_at, +/// updated_at, and metadata badges. +pub async fn get_memory_detail( + user: crate::auth::CurrentUser, + State(service): State>, + Path(id): Path, +) -> Result, (StatusCode, String)> { + match service.get_memory(&id).await { + Ok(mem) if mem.user_id == user.id => Ok(Html(detail_modal(&mem).into_string())), + Ok(_) | Err(_) => Err((StatusCode::NOT_FOUND, format!("memory {id} not found"))), + } +} + +/// Assemble the modal markup for a memory detail. +fn detail_modal(mem: &Memory) -> maud::Markup { + // Sort metadata keys deterministically so tags render in a stable order. + let sorted_meta: BTreeMap<&String, &String> = mem.metadata.iter().collect(); + let created = mem.created_at.format("%b %d, %Y %H:%M").to_string(); + let updated = mem.updated_at.format("%b %d, %Y %H:%M").to_string(); + let modal_id = format!("memory-detail-{}", mem.id); + + maud::html! { + dialog id=(modal_id) class="modal" { + div class="modal-box max-w-3xl w-full max-h-[85vh] flex flex-col" { + // Header + div class="flex items-start justify-between gap-4" { + h3 class="text-lg font-bold break-words" { "Memory detail" } + button type="button" class="btn btn-ghost btn-sm btn-circle" + onclick=(format!("document.getElementById('{modal_id}').close();")) { + "✕" + } + } + + // Full, scrollable content — never clipped. + div class="mt-4 overflow-y-auto grow pr-2 rounded-lg bg-base-200/50 p-4" + style="max-height:50vh; white-space:pre-wrap; word-break:break-word;" { + p class="text-sm leading-relaxed whitespace-pre-wrap" { (mem.content) } + } + + // Metadata + div class="mt-4 pt-3 border-t border-base-300" { + div class="flex flex-wrap gap-2 items-center" { + span class="text-xs text-base-content/60" { "Created:" } + span class="text-xs font-mono" { (created) } + span class="text-xs text-base-content/40 mx-1" { "·" } + span class="text-xs text-base-content/60" { "Updated:" } + span class="text-xs font-mono" { (updated) } + } + @if sorted_meta.is_empty() { + div class="mt-2" { + span class="text-xs text-base-content/40" { "No metadata" } + } + } @else { + div class="flex flex-wrap gap-2 items-center mt-2" { + span class="text-xs text-base-content/60" { "Metadata:" } + @for (k, v) in &sorted_meta { + (meta_badge(k, v)) + } + } + } + } + } + form method="dialog" class="modal-backdrop" { + button type="button" onclick=(format!("document.getElementById('{modal_id}').close();")) { + "close" + } + } + } + // Runs after HTMX swaps the fragment in; the dialog is present by then. + script { + (maud::PreEscaped(format!( + "var m = document.getElementById('{modal_id}'); if (m && typeof m.showModal === 'function') m.showModal();" + ))) + } + } +} diff --git a/crates/memayu-web/src/pages/mod.rs b/crates/memayu-web/src/pages/mod.rs index cf496e6..cf9be3f 100644 --- a/crates/memayu-web/src/pages/mod.rs +++ b/crates/memayu-web/src/pages/mod.rs @@ -2,6 +2,7 @@ pub mod api_keys; pub mod home; pub mod login; pub mod logout; +pub mod memory; pub mod providers; pub mod requests; pub mod setup; diff --git a/crates/memayu-web/tests/dashboard.rs b/crates/memayu-web/tests/dashboard.rs index 8cc2005..27097fd 100644 --- a/crates/memayu-web/tests/dashboard.rs +++ b/crates/memayu-web/tests/dashboard.rs @@ -546,3 +546,98 @@ async fn setup_router_exposes_health() { "unexpected health body: {body}" ); } + +#[tokio::test] +async fn memory_detail_shows_full_long_content_and_metadata() { + // Issue #56: the memory detail view must render the full, untruncated + // content even when it is multi-KB, plus created_at/updated_at and metadata, + // without any clipping. + let (app, db, storage) = build_raw_test_app().await; + + // Set up admin + capture session cookie. + let resp = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/setup") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from( + "email=admin@memayu.test&password=Secret12&confirm=Secret12", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::SEE_OTHER); + let cookie = resp + .headers() + .get("set-cookie") + .unwrap() + .to_str() + .unwrap() + .to_string(); + + // Resolve the admin's user id from the session. + let token = cookie + .split(';') + .find_map(|p| p.trim().strip_prefix("memayu_session=")) + .unwrap() + .to_string(); + let user_id = memayu_api::WebServices::new(db) + .auth_resolve_session(&token) + .await + .unwrap(); + + // Seed one memory with content long enough that the list row would + // truncate it, plus some metadata. + let long_content = format!("start {}", "x".repeat(20_000)); + let now = chrono::Utc::now(); + let mut metadata = std::collections::HashMap::new(); + metadata.insert("source".to_string(), "detail-test".to_string()); + metadata.insert("tag".to_string(), "long".to_string()); + storage + .save_memory(&memayu_core::Memory { + id: "detail-mem-1".to_string(), + user_id: user_id.clone(), + content: long_content.clone(), + vector: vec![1.0, 0.0, 0.0], + metadata, + created_at: now, + updated_at: now, + }) + .await + .unwrap(); + + // Open the detail modal fragment for this memory. + let resp = app + .oneshot( + Request::builder() + .uri("/home/memory/detail-mem-1") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = axum::body::to_bytes(resp.into_body(), 1024 * 1024) + .await + .unwrap(); + let body_str = String::from_utf8_lossy(&body); + + // The full content is present, not truncated. + assert!( + body_str.contains(&long_content), + "detail view must contain the full content" + ); + assert!(body_str.contains("memory-detail-detail-mem-1")); + // Metadata keys and values are visible (key and value render in sibling spans). + assert!(body_str.contains("source: "), "stdout: {body_str}"); + assert!(body_str.contains("detail-test"), "stdout: {body_str}"); + assert!(body_str.contains("tag: "), "stdout: {body_str}"); + assert!(body_str.contains(">long<"), "stdout: {body_str}"); + // Created/updated timestamps render. + assert!(body_str.contains("Created:"), "stdout: {body_str}"); + assert!(body_str.contains("Updated:"), "stdout: {body_str}"); +}