Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/memayu-web/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
19 changes: 17 additions & 2 deletions crates/memayu-web/src/pages/home.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -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) }
}
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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) }
}
Expand All @@ -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<chrono::Utc>,
score: Option<f32>,
Expand Down
100 changes: 100 additions & 0 deletions crates/memayu-web/src/pages/memory.rs
Original file line number Diff line number Diff line change
@@ -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<Arc<memayu_core::MemoryService>>,
Path(id): Path<String>,
) -> Result<Html<String>, (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();"
)))
}
}
}
1 change: 1 addition & 0 deletions crates/memayu-web/src/pages/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
95 changes: 95 additions & 0 deletions crates/memayu-web/tests/dashboard.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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}");
}
Loading