diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 76f27b6176d..8c0d3259a1d 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -68,6 +68,7 @@ export default defineConfig({ "**/doctor-cta-screenshots.spec.ts", "**/pubkey-display-screenshots.spec.ts", "**/file-attachment.spec.ts", + "**/markdown-doc-viewer.spec.ts", "**/image-attachment-gallery.spec.ts", "**/composer-image-draw.spec.ts", "**/video-attachment.spec.ts", diff --git a/desktop/src-tauri/src/commands/media_download.rs b/desktop/src-tauri/src/commands/media_download.rs index e841d4b2f54..dfb094ccade 100644 --- a/desktop/src-tauri/src/commands/media_download.rs +++ b/desktop/src-tauri/src/commands/media_download.rs @@ -22,6 +22,15 @@ use crate::relay::{classify_request_error, relay_api_base_url_with_override, rel /// Maximum download size: 50 MiB. Prevents OOM from oversized responses. pub(super) const MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024; +/// Maximum markdown-document size for the in-app viewer: 2 MiB. +/// +/// This is the *enforcement* boundary for the viewer — the frontend's +/// matching `MAX_MARKDOWN_DOC_BYTES` pre-gate reads the untrusted imeta +/// `size` field and is UX-only (a forged or absent size must not buy a +/// larger fetch). Keep in sync with +/// `desktop/src/shared/ui/markdown/markdownDocFile.ts`. +const MAX_MARKDOWN_DOC_BYTES: u64 = 2 * 1024 * 1024; + /// Download request timeout. const DOWNLOAD_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); @@ -141,6 +150,35 @@ pub async fn download_file( save_bytes_with_dialog(&app, &filename, "All Files", &extensions, &bytes).await } +/// Fetch a markdown document attachment for the in-app viewer, enforcing the +/// viewer's 2 MiB ceiling natively during the fetch. +/// +/// The generic `fetch_media_bytes` command streams up to the 50 MiB download +/// cap; routing the viewer through it would let an attachment with a forged +/// or absent imeta `size` occupy 50 MiB of memory and IPC before the +/// frontend's decoder rejected it. This command makes the 2 MiB limit a +/// native boundary: Content-Length over the cap is refused before the body +/// is read, and the streamed byte count aborts mid-transfer when the header +/// is missing or dishonest. +/// +/// Deliberately skips `detect_and_validate_mime`: the bytes are only ever +/// strictly UTF-8-decoded and rendered through the escaping markdown +/// pipeline (never handed to the webview as a document), and a legitimate +/// `.md` file may open with bytes that sniff as a blocked type — e.g. an +/// SVG snippet pasted at the top of the file. Binary payloads fail the +/// frontend's fatal UTF-8 decode and fall back to download. +#[tauri::command] +pub async fn fetch_markdown_doc_bytes( + url: String, + state: State<'_, AppState>, +) -> Result { + let relay_base = relay_api_base_url_with_override(&state); + validate_download_url(&url, &relay_base)?; + + let bytes = fetch_blob_bytes_with_cap(&url, &state, MAX_MARKDOWN_DOC_BYTES, None).await?; + Ok(tauri::ipc::Response::new(bytes)) +} + /// Copy an image from a relay media URL directly to the system clipboard. /// /// Fetches the image, decodes it to RGBA8, and writes it to the clipboard via @@ -293,14 +331,8 @@ pub(super) async fn fetch_blob_bytes_with_cap( } // Check Content-Length header upfront if present. - if let Some(content_length) = resp.content_length() { - if content_length > cap { - return Err(format!( - "file too large ({} MiB, max {} MiB)", - content_length / (1024 * 1024), - cap / (1024 * 1024) - )); - } + if let Some(err) = declared_length_refusal_error(resp.content_length(), cap) { + return Err(err); } // Stream the response with a running byte count to enforce the size cap @@ -320,15 +352,43 @@ pub(super) async fn fetch_blob_bytes_with_cap( break; }; let chunk = chunk.map_err(|e| classify_request_error(&e))?; - if bytes.len() as u64 + chunk.len() as u64 > cap { - return Err(format!("file too large (max {} MiB)", cap / (1024 * 1024))); - } - bytes.extend_from_slice(&chunk); + append_chunk_within_cap(&mut bytes, &chunk, cap)?; } Ok(bytes) } +/// The refusal for a declared Content-Length that exceeds the byte cap, or +/// `None` when the header is absent or within bounds. An absent header is +/// allowed through — `append_chunk_within_cap` still enforces the cap on the +/// streamed bytes, so a missing or dishonest length never buys a larger +/// download. Pulled out of `fetch_blob_bytes_with_cap` so the pre-body +/// refusal is unit-testable without a Tauri `State`. +fn declared_length_refusal_error(content_length: Option, cap: u64) -> Option { + let content_length = content_length?; + (content_length > cap).then(|| { + format!( + "file too large ({} MiB, max {} MiB)", + content_length / (1024 * 1024), + cap / (1024 * 1024) + ) + }) +} + +/// Append a response chunk to the accumulator, refusing once the running +/// total would exceed `cap`. This is the enforcement point that holds even +/// when Content-Length is missing or dishonest: the transfer aborts +/// mid-stream instead of buffering past the cap. Pulled out of +/// `fetch_blob_bytes_with_cap` so the cutoff is unit-testable without a +/// Tauri `State`. +fn append_chunk_within_cap(bytes: &mut Vec, chunk: &[u8], cap: u64) -> Result<(), String> { + if bytes.len() as u64 + chunk.len() as u64 > cap { + return Err(format!("file too large (max {} MiB)", cap / (1024 * 1024))); + } + bytes.extend_from_slice(chunk); + Ok(()) +} + /// The snapshot file format inferred from the sanitized filename suffix. /// Carries the format-specific byte cap used during bounded fetch. #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -521,472 +581,5 @@ pub async fn fetch_snapshot_bytes( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn snapshot_kind_json_returns_json_kind_and_correct_cap() { - let kind = snapshot_kind_for_filename("analyst.agent.json").unwrap(); - assert_eq!(kind, SnapshotFileKind::AgentJson); - assert_eq!(kind.cap(), MAX_SNAPSHOT_JSON_BYTES as u64); - } - - #[test] - fn snapshot_kind_png_returns_png_kind_and_correct_cap() { - let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap(); - assert_eq!(kind, SnapshotFileKind::AgentPng); - assert_eq!(kind.cap(), MAX_SNAPSHOT_PNG_BYTES as u64); - } - - #[test] - fn snapshot_kind_plain_json_rejected() { - assert!(snapshot_kind_for_filename("data.json").is_err()); - } - - #[test] - fn snapshot_kind_deceptive_name_rejected() { - // foo.agent.json.exe must not match .agent.json - assert!(snapshot_kind_for_filename("foo.agent.json.exe").is_err()); - } - - #[test] - fn snapshot_kind_plain_png_rejected() { - assert!(snapshot_kind_for_filename("photo.png").is_err()); - } - - #[test] - fn snapshot_kind_agent_json_only_rejected() { - // "agent.json" without the leading dot — plain filename, not the suffix - assert!(snapshot_kind_for_filename("agentjson").is_err()); - } - - #[test] - fn snapshot_kind_team_extensions_are_case_insensitive_and_scale_caps() { - let json = snapshot_kind_for_filename("review.TEAM.JSON").unwrap(); - let png = snapshot_kind_for_filename("review.TEAM.PNG").unwrap(); - assert_eq!(json, SnapshotFileKind::TeamJson); - assert_eq!(png, SnapshotFileKind::TeamPng); - assert_eq!(json.cap(), 25 * 1024 * 1024); - assert_eq!(png.cap(), 50 * 1024 * 1024); - } - - #[test] - fn fetch_boundary_team_png_filename_with_json_bytes_rejected() { - let bytes = br#"{"format":"buzz-team-snapshot","version":1}"#; - let kind = snapshot_kind_for_filename("review.team.png").unwrap(); - let error = ensure_bytes_match_kind(bytes, kind).unwrap_err(); - assert!(error.contains(".team.png") && error.contains("not a PNG")); - } - - #[test] - fn fetch_boundary_team_declared_size_over_cap_rejected() { - let kind = snapshot_kind_for_filename("review.team.json").unwrap(); - assert!(ensure_declared_size_within_cap(MAX_TEAM_SNAPSHOT_JSON_BYTES, kind).is_ok()); - let error = - ensure_declared_size_within_cap(MAX_TEAM_SNAPSHOT_JSON_BYTES + 1, kind).unwrap_err(); - assert!(error.contains("25 MiB")); - } - - // ── Focused boundary tests: format mismatch and consistency ────────────── - // - // These tests exercise the guard logic that fetch_snapshot_bytes applies - // after the bounded fetch + hash check. The validation has two layers: - // - // 1. Magic-byte kind check: filename kind (from snapshot_kind_for_filename) - // must match the actual byte format (PNG magic or absence of it). - // 2. decode_snapshot_from_bytes: rejects malformed manifests including - // JSON with level:none + non-empty entries. - // - // We verify each rejection path directly — no live HTTP required. - - #[test] - fn fetch_boundary_png_filename_with_json_bytes_rejected() { - use crate::managed_agents::agent_snapshot::{ - encode_snapshot_json, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, - AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION, - }; - let snapshot = AgentSnapshot { - format: FORMAT_DISCRIMINATOR.to_string(), - version: FORMAT_VERSION, - definition: AgentSnapshotDefinition { - session_policy: Default::default(), - name: "test".to_string(), - source_is_builtin: false, - system_prompt: None, - runtime: None, - model: None, - provider: None, - parallelism: None, - respond_to: None, - respond_to_allowlist: vec![], - name_pool: vec![], - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - }, - profile: AgentSnapshotProfile { - display_name: "Test".to_string(), - about: None, - avatar_data_url: None, - avatar_url: None, - }, - memory: AgentSnapshotMemory { - level: crate::managed_agents::agent_snapshot::MemoryLevel::None, - entries: vec![], - }, - }; - let json_bytes = encode_snapshot_json(&snapshot).unwrap(); - // .agent.png filename → Png kind; JSON bytes must be rejected. - let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap(); - let result = ensure_bytes_match_kind(&json_bytes, kind); - assert!( - result.is_err(), - ".agent.png filename with JSON bytes must be rejected by the magic-byte guard" - ); - assert!( - result.unwrap_err().contains("not a PNG"), - "error must describe the mismatch" - ); - } - - #[test] - fn fetch_boundary_png_filename_with_memory_bearing_json_bytes_rejected() { - use crate::managed_agents::agent_snapshot::{ - encode_snapshot_json, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, - AgentSnapshotMemoryEntry, AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION, - }; - // This is the trust-hole case: memory-bearing JSON delivered under a - // .agent.png label to bypass the PNG no-memory policy. - let snapshot = AgentSnapshot { - format: FORMAT_DISCRIMINATOR.to_string(), - version: FORMAT_VERSION, - definition: AgentSnapshotDefinition { - session_policy: Default::default(), - name: "test".to_string(), - source_is_builtin: false, - system_prompt: None, - runtime: None, - model: None, - provider: None, - parallelism: None, - respond_to: None, - respond_to_allowlist: vec![], - name_pool: vec![], - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - }, - profile: AgentSnapshotProfile { - display_name: "Test".to_string(), - about: None, - avatar_data_url: None, - avatar_url: None, - }, - memory: AgentSnapshotMemory { - level: crate::managed_agents::agent_snapshot::MemoryLevel::Everything, - entries: vec![AgentSnapshotMemoryEntry { - slug: "core".to_string(), - body: "Secret memory.".to_string(), - }], - }, - }; - let json_bytes = encode_snapshot_json(&snapshot).unwrap(); - let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap(); - let result = ensure_bytes_match_kind(&json_bytes, kind); - assert!( - result.is_err(), - ".agent.png filename with memory-bearing JSON bytes must be rejected" - ); - } - - #[test] - fn fetch_boundary_json_filename_with_png_bytes_rejected() { - use crate::managed_agents::agent_snapshot::{ - encode_snapshot_png, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, - AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION, - }; - let snapshot = AgentSnapshot { - format: FORMAT_DISCRIMINATOR.to_string(), - version: FORMAT_VERSION, - definition: AgentSnapshotDefinition { - session_policy: Default::default(), - name: "test".to_string(), - source_is_builtin: false, - system_prompt: None, - runtime: None, - model: None, - provider: None, - parallelism: None, - respond_to: None, - respond_to_allowlist: vec![], - name_pool: vec![], - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - }, - profile: AgentSnapshotProfile { - display_name: "Test".to_string(), - about: None, - avatar_data_url: None, - avatar_url: None, - }, - memory: AgentSnapshotMemory { - level: crate::managed_agents::agent_snapshot::MemoryLevel::None, - entries: vec![], - }, - }; - let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); - // .agent.json filename → Json kind; PNG bytes must be rejected. - let kind = snapshot_kind_for_filename("analyst.agent.json").unwrap(); - let result = ensure_bytes_match_kind(&png_bytes, kind); - assert!( - result.is_err(), - ".agent.json filename with PNG bytes must be rejected by the magic-byte guard" - ); - assert!( - result.unwrap_err().contains("bytes are a PNG"), - "error must describe the mismatch" - ); - } - - #[test] - fn decode_boundary_json_none_level_with_entries_rejected() { - use crate::commands::personas::decode_snapshot_from_bytes; - // Construct JSON bytes directly: level=none but entries non-empty. - // encode_snapshot_json does not guard against this, so we can produce it. - let raw = serde_json::json!({ - "format": "buzz-agent-snapshot", - "version": 1, - "definition": { "name": "test" }, - "profile": { "displayName": "Test" }, - "memory": { - "level": "none", - "entries": [{"slug": "core", "body": "leaked"}] - } - }); - let bytes = serde_json::to_vec(&raw).unwrap(); - let result = decode_snapshot_from_bytes(&bytes); - assert!( - result.is_err(), - "JSON with level:none + non-empty entries must be rejected by decode_snapshot_from_bytes" - ); - assert!( - result - .unwrap_err() - .contains("'none' but entries are present"), - "error must describe the consistency violation" - ); - } - - const RELAY_BASE: &str = "https://relay.example.com"; - - #[test] - fn test_validate_download_url_valid_relay_url() { - assert!(validate_download_url( - "https://relay.example.com/media/abcdef1234567890.jpg", - RELAY_BASE, - ) - .is_ok()); - } - - #[test] - fn test_validate_download_url_valid_relay_url_png() { - assert!( - validate_download_url("https://relay.example.com/media/abc123.png", RELAY_BASE,) - .is_ok() - ); - } - - #[test] - fn test_validate_download_url_non_relay_origin_rejected() { - let result = validate_download_url("https://evil.example.com/media/abc123.jpg", RELAY_BASE); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("relay origin")); - } - - #[test] - fn test_validate_download_url_private_ip_rejected() { - let result = validate_download_url("http://169.254.169.254/latest/meta-data/", RELAY_BASE); - assert!(result.is_err()); - } - - #[test] - fn test_validate_download_url_loopback_rejected() { - let result = validate_download_url("http://127.0.0.1/media/abc.jpg", RELAY_BASE); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("relay origin")); - } - - #[test] - fn test_validate_download_url_localhost_allowed_for_localhost_relay() { - assert!(validate_download_url( - "http://localhost:3000/media/abc.jpg", - "http://localhost:3000", - ) - .is_ok()); - } - - #[test] - fn test_validate_download_url_missing_media_path_rejected() { - let result = validate_download_url("https://relay.example.com/other/abc.jpg", RELAY_BASE); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("/media/")); - } - - #[test] - fn test_validate_download_url_non_https_scheme_rejected() { - let result = validate_download_url("ftp://relay.example.com/media/abc.jpg", RELAY_BASE); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("HTTPS")); - } - - #[test] - fn test_validate_download_url_http_non_localhost_rejected() { - let result = validate_download_url("http://relay.example.com/media/abc.jpg", RELAY_BASE); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("HTTPS")); - } - - #[test] - fn test_validate_download_url_root_path_rejected() { - let result = validate_download_url("https://relay.example.com/", RELAY_BASE); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("/media/")); - } - - // Video Download reuses `download_file`, which runs the same - // `validate_download_url` gate as image download. `validate_download_url` - // is extension-agnostic (it only checks scheme, origin, and the `/media/` - // path prefix), so a relay-hosted mp4/webm passes exactly like an image, - // and an off-relay or private-host video is rejected exactly like an - // off-relay image. These cases pin that parity so a future change can't - // silently narrow the video download path's SSRF protection. - #[test] - fn test_validate_download_url_valid_relay_video_mp4() { - assert!(validate_download_url( - "https://relay.example.com/media/abcdef1234567890.mp4", - RELAY_BASE, - ) - .is_ok()); - } - - #[test] - fn test_validate_download_url_valid_relay_video_webm() { - assert!( - validate_download_url("https://relay.example.com/media/abc123.webm", RELAY_BASE) - .is_ok() - ); - } - - #[test] - fn test_validate_download_url_non_relay_video_rejected() { - let result = validate_download_url("https://evil.example.com/media/clip.mp4", RELAY_BASE); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("relay origin")); - } - - #[test] - fn test_validate_download_url_private_host_video_rejected() { - // Off-relay private host serving a video must be rejected before any - // fetch — same SSRF gate as image download. - let result = validate_download_url("http://127.0.0.1/media/clip.mp4", RELAY_BASE); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("relay origin")); - } - - /// Redirect-hop SSRF guard: the media fetch client must NOT follow a 3xx, - /// and the command-facing error must identify the refused redirect. - /// - /// `validate_download_url` only vets the *initial* URL, so a relay that - /// returned a redirect to an off-origin or private host would, under a - /// redirect-following client, forward the minted media Authorization - /// header across origins. The client `build_media_fetch_client()` produces - /// (the same one `fetch_blob_bytes_with_cap` uses via `AppState`) is built - /// with `redirect::Policy::none()`, so the 302 comes back verbatim and - /// `redirect_refusal_error` — the same mapping the command applies — turns - /// it into an actionable redirect error, not a silent cross-origin fetch. - /// - /// A loopback `std::net::TcpListener` (no extra tokio feature) serves one - /// raw `302` pointing at an off-origin target and records how many - /// connections it accepts. - #[tokio::test] - async fn media_fetch_client_does_not_follow_redirects() { - use std::io::{Read, Write}; - use std::sync::atomic::{AtomicUsize, Ordering}; - use std::sync::Arc; - - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - let connections = Arc::new(AtomicUsize::new(0)); - - let server_connections = Arc::clone(&connections); - let server = std::thread::spawn(move || { - // Accept exactly one connection; if the client followed the - // redirect it would open a second one to the (unrelated) target, - // but that target is never this server, so a second accept here - // would only happen on an unexpected retry. We serve one 302 and - // return, so the count stays at 1 for a compliant no-redirect client. - if let Ok((mut stream, _)) = listener.accept() { - server_connections.fetch_add(1, Ordering::SeqCst); - let mut buf = [0u8; 1024]; - let _ = stream.read(&mut buf); - let response = "HTTP/1.1 302 Found\r\n\ - Location: http://169.254.169.254/latest/meta-data/\r\n\ - Content-Length: 0\r\n\ - Connection: close\r\n\r\n"; - let _ = stream.write_all(response.as_bytes()); - let _ = stream.flush(); - } - }); - - // Drive the exact client the command path uses, not an ad-hoc one. - let client = crate::app_state::build_media_fetch_client() - .expect("media fetch client must build with no-redirect policy"); - let resp = client - .get(format!("http://{addr}/media/clip.mp4")) - .timeout(std::time::Duration::from_secs(5)) - .send() - .await - .expect("request should complete without following the redirect"); - - // The 302 is returned verbatim — not the 169.254.x target's response. - assert_eq!(resp.status().as_u16(), 302); - assert!(!resp.status().is_success()); - - // The command maps that status through `redirect_refusal_error`; the - // user-facing error must name the redirect, not read as a generic - // relay failure. - let err = redirect_refusal_error(resp.status()) - .expect("a 3xx must map to a redirect-refusal error"); - assert!( - err.contains("redirect") && err.contains("302"), - "error must identify the refused 302 redirect, got: {err}", - ); - - server.join().unwrap(); - assert_eq!( - connections.load(Ordering::SeqCst), - 1, - "exactly one request must be issued — the redirect must not be followed", - ); - } - - #[test] - fn build_media_fetch_client_succeeds_with_no_redirect_policy() { - // The fail-closed invariant: construction must not silently degrade to - // a redirect-following client. If this ever starts failing, startup - // panics loudly (see `build_app_state`) rather than substituting an - // insecure client. - assert!( - crate::app_state::build_media_fetch_client().is_ok(), - "media fetch client must build; a redirect-following fallback is forbidden", - ); - } - - #[test] - fn redirect_refusal_error_only_fires_for_3xx() { - // 3xx → redirect-identifying error; success/non-3xx → None (fall - // through to the normal success or relay-error handling). - assert!(redirect_refusal_error(reqwest::StatusCode::FOUND).is_some()); - assert!(redirect_refusal_error(reqwest::StatusCode::TEMPORARY_REDIRECT).is_some()); - assert!(redirect_refusal_error(reqwest::StatusCode::OK).is_none()); - assert!(redirect_refusal_error(reqwest::StatusCode::NOT_FOUND).is_none()); - } -} +#[path = "media_download_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/commands/media_download_tests.rs b/desktop/src-tauri/src/commands/media_download_tests.rs new file mode 100644 index 00000000000..19cf0d23613 --- /dev/null +++ b/desktop/src-tauri/src/commands/media_download_tests.rs @@ -0,0 +1,519 @@ +// Tests for commands/media_download.rs — split into a sibling file to keep +// media_download.rs under the per-file line cap. + +use super::*; + +#[test] +fn snapshot_kind_json_returns_json_kind_and_correct_cap() { + let kind = snapshot_kind_for_filename("analyst.agent.json").unwrap(); + assert_eq!(kind, SnapshotFileKind::AgentJson); + assert_eq!(kind.cap(), MAX_SNAPSHOT_JSON_BYTES as u64); +} + +#[test] +fn snapshot_kind_png_returns_png_kind_and_correct_cap() { + let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap(); + assert_eq!(kind, SnapshotFileKind::AgentPng); + assert_eq!(kind.cap(), MAX_SNAPSHOT_PNG_BYTES as u64); +} + +#[test] +fn snapshot_kind_plain_json_rejected() { + assert!(snapshot_kind_for_filename("data.json").is_err()); +} + +#[test] +fn snapshot_kind_deceptive_name_rejected() { + // foo.agent.json.exe must not match .agent.json + assert!(snapshot_kind_for_filename("foo.agent.json.exe").is_err()); +} + +#[test] +fn snapshot_kind_plain_png_rejected() { + assert!(snapshot_kind_for_filename("photo.png").is_err()); +} + +#[test] +fn snapshot_kind_agent_json_only_rejected() { + // "agent.json" without the leading dot — plain filename, not the suffix + assert!(snapshot_kind_for_filename("agentjson").is_err()); +} + +#[test] +fn snapshot_kind_team_extensions_are_case_insensitive_and_scale_caps() { + let json = snapshot_kind_for_filename("review.TEAM.JSON").unwrap(); + let png = snapshot_kind_for_filename("review.TEAM.PNG").unwrap(); + assert_eq!(json, SnapshotFileKind::TeamJson); + assert_eq!(png, SnapshotFileKind::TeamPng); + assert_eq!(json.cap(), 25 * 1024 * 1024); + assert_eq!(png.cap(), 50 * 1024 * 1024); +} + +#[test] +fn fetch_boundary_team_png_filename_with_json_bytes_rejected() { + let bytes = br#"{"format":"buzz-team-snapshot","version":1}"#; + let kind = snapshot_kind_for_filename("review.team.png").unwrap(); + let error = ensure_bytes_match_kind(bytes, kind).unwrap_err(); + assert!(error.contains(".team.png") && error.contains("not a PNG")); +} + +#[test] +fn fetch_boundary_team_declared_size_over_cap_rejected() { + let kind = snapshot_kind_for_filename("review.team.json").unwrap(); + assert!(ensure_declared_size_within_cap(MAX_TEAM_SNAPSHOT_JSON_BYTES, kind).is_ok()); + let error = + ensure_declared_size_within_cap(MAX_TEAM_SNAPSHOT_JSON_BYTES + 1, kind).unwrap_err(); + assert!(error.contains("25 MiB")); +} + +// ── Focused boundary tests: format mismatch and consistency ────────────── +// +// These tests exercise the guard logic that fetch_snapshot_bytes applies +// after the bounded fetch + hash check. The validation has two layers: +// +// 1. Magic-byte kind check: filename kind (from snapshot_kind_for_filename) +// must match the actual byte format (PNG magic or absence of it). +// 2. decode_snapshot_from_bytes: rejects malformed manifests including +// JSON with level:none + non-empty entries. +// +// We verify each rejection path directly — no live HTTP required. + +#[test] +fn fetch_boundary_png_filename_with_json_bytes_rejected() { + use crate::managed_agents::agent_snapshot::{ + encode_snapshot_json, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, + AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION, + }; + let snapshot = AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: "test".to_string(), + source_is_builtin: false, + system_prompt: None, + runtime: None, + model: None, + provider: None, + parallelism: None, + respond_to: None, + respond_to_allowlist: vec![], + name_pool: vec![], + idle_timeout_seconds: None, + session_policy: Default::default(), + max_turn_duration_seconds: None, + }, + profile: AgentSnapshotProfile { + display_name: "Test".to_string(), + about: None, + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: crate::managed_agents::agent_snapshot::MemoryLevel::None, + entries: vec![], + }, + }; + let json_bytes = encode_snapshot_json(&snapshot).unwrap(); + // .agent.png filename → Png kind; JSON bytes must be rejected. + let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap(); + let result = ensure_bytes_match_kind(&json_bytes, kind); + assert!( + result.is_err(), + ".agent.png filename with JSON bytes must be rejected by the magic-byte guard" + ); + assert!( + result.unwrap_err().contains("not a PNG"), + "error must describe the mismatch" + ); +} + +#[test] +fn fetch_boundary_png_filename_with_memory_bearing_json_bytes_rejected() { + use crate::managed_agents::agent_snapshot::{ + encode_snapshot_json, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, + AgentSnapshotMemoryEntry, AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION, + }; + // This is the trust-hole case: memory-bearing JSON delivered under a + // .agent.png label to bypass the PNG no-memory policy. + let snapshot = AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: "test".to_string(), + source_is_builtin: false, + system_prompt: None, + runtime: None, + model: None, + provider: None, + parallelism: None, + respond_to: None, + respond_to_allowlist: vec![], + name_pool: vec![], + idle_timeout_seconds: None, + session_policy: Default::default(), + max_turn_duration_seconds: None, + }, + profile: AgentSnapshotProfile { + display_name: "Test".to_string(), + about: None, + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: crate::managed_agents::agent_snapshot::MemoryLevel::Everything, + entries: vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "Secret memory.".to_string(), + }], + }, + }; + let json_bytes = encode_snapshot_json(&snapshot).unwrap(); + let kind = snapshot_kind_for_filename("analyst.agent.png").unwrap(); + let result = ensure_bytes_match_kind(&json_bytes, kind); + assert!( + result.is_err(), + ".agent.png filename with memory-bearing JSON bytes must be rejected" + ); +} + +#[test] +fn fetch_boundary_json_filename_with_png_bytes_rejected() { + use crate::managed_agents::agent_snapshot::{ + encode_snapshot_png, AgentSnapshot, AgentSnapshotDefinition, AgentSnapshotMemory, + AgentSnapshotProfile, FORMAT_DISCRIMINATOR, FORMAT_VERSION, + }; + let snapshot = AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: "test".to_string(), + source_is_builtin: false, + system_prompt: None, + runtime: None, + model: None, + provider: None, + parallelism: None, + respond_to: None, + respond_to_allowlist: vec![], + name_pool: vec![], + idle_timeout_seconds: None, + session_policy: Default::default(), + max_turn_duration_seconds: None, + }, + profile: AgentSnapshotProfile { + display_name: "Test".to_string(), + about: None, + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: crate::managed_agents::agent_snapshot::MemoryLevel::None, + entries: vec![], + }, + }; + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + // .agent.json filename → Json kind; PNG bytes must be rejected. + let kind = snapshot_kind_for_filename("analyst.agent.json").unwrap(); + let result = ensure_bytes_match_kind(&png_bytes, kind); + assert!( + result.is_err(), + ".agent.json filename with PNG bytes must be rejected by the magic-byte guard" + ); + assert!( + result.unwrap_err().contains("bytes are a PNG"), + "error must describe the mismatch" + ); +} + +#[test] +fn decode_boundary_json_none_level_with_entries_rejected() { + use crate::commands::personas::decode_snapshot_from_bytes; + // Construct JSON bytes directly: level=none but entries non-empty. + // encode_snapshot_json does not guard against this, so we can produce it. + let raw = serde_json::json!({ + "format": "buzz-agent-snapshot", + "version": 1, + "definition": { "name": "test" }, + "profile": { "displayName": "Test" }, + "memory": { + "level": "none", + "entries": [{"slug": "core", "body": "leaked"}] + } + }); + let bytes = serde_json::to_vec(&raw).unwrap(); + let result = decode_snapshot_from_bytes(&bytes); + assert!( + result.is_err(), + "JSON with level:none + non-empty entries must be rejected by decode_snapshot_from_bytes" + ); + assert!( + result + .unwrap_err() + .contains("'none' but entries are present"), + "error must describe the consistency violation" + ); +} + +const RELAY_BASE: &str = "https://relay.example.com"; + +#[test] +fn test_validate_download_url_valid_relay_url() { + assert!(validate_download_url( + "https://relay.example.com/media/abcdef1234567890.jpg", + RELAY_BASE, + ) + .is_ok()); +} + +#[test] +fn test_validate_download_url_valid_relay_url_png() { + assert!( + validate_download_url("https://relay.example.com/media/abc123.png", RELAY_BASE,).is_ok() + ); +} + +#[test] +fn test_validate_download_url_non_relay_origin_rejected() { + let result = validate_download_url("https://evil.example.com/media/abc123.jpg", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("relay origin")); +} + +#[test] +fn test_validate_download_url_private_ip_rejected() { + let result = validate_download_url("http://169.254.169.254/latest/meta-data/", RELAY_BASE); + assert!(result.is_err()); +} + +#[test] +fn test_validate_download_url_loopback_rejected() { + let result = validate_download_url("http://127.0.0.1/media/abc.jpg", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("relay origin")); +} + +#[test] +fn test_validate_download_url_localhost_allowed_for_localhost_relay() { + assert!(validate_download_url( + "http://localhost:3000/media/abc.jpg", + "http://localhost:3000", + ) + .is_ok()); +} + +#[test] +fn test_validate_download_url_missing_media_path_rejected() { + let result = validate_download_url("https://relay.example.com/other/abc.jpg", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("/media/")); +} + +#[test] +fn test_validate_download_url_non_https_scheme_rejected() { + let result = validate_download_url("ftp://relay.example.com/media/abc.jpg", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("HTTPS")); +} + +#[test] +fn test_validate_download_url_http_non_localhost_rejected() { + let result = validate_download_url("http://relay.example.com/media/abc.jpg", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("HTTPS")); +} + +#[test] +fn test_validate_download_url_root_path_rejected() { + let result = validate_download_url("https://relay.example.com/", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("/media/")); +} + +// Video Download reuses `download_file`, which runs the same +// `validate_download_url` gate as image download. `validate_download_url` +// is extension-agnostic (it only checks scheme, origin, and the `/media/` +// path prefix), so a relay-hosted mp4/webm passes exactly like an image, +// and an off-relay or private-host video is rejected exactly like an +// off-relay image. These cases pin that parity so a future change can't +// silently narrow the video download path's SSRF protection. +#[test] +fn test_validate_download_url_valid_relay_video_mp4() { + assert!(validate_download_url( + "https://relay.example.com/media/abcdef1234567890.mp4", + RELAY_BASE, + ) + .is_ok()); +} + +#[test] +fn test_validate_download_url_valid_relay_video_webm() { + assert!( + validate_download_url("https://relay.example.com/media/abc123.webm", RELAY_BASE).is_ok() + ); +} + +#[test] +fn test_validate_download_url_non_relay_video_rejected() { + let result = validate_download_url("https://evil.example.com/media/clip.mp4", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("relay origin")); +} + +#[test] +fn test_validate_download_url_private_host_video_rejected() { + // Off-relay private host serving a video must be rejected before any + // fetch — same SSRF gate as image download. + let result = validate_download_url("http://127.0.0.1/media/clip.mp4", RELAY_BASE); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("relay origin")); +} + +/// Redirect-hop SSRF guard: the media fetch client must NOT follow a 3xx, +/// and the command-facing error must identify the refused redirect. +/// +/// `validate_download_url` only vets the *initial* URL, so a relay that +/// returned a redirect to an off-origin or private host would, under a +/// redirect-following client, forward the minted media Authorization +/// header across origins. The client `build_media_fetch_client()` produces +/// (the same one `fetch_blob_bytes_with_cap` uses via `AppState`) is built +/// with `redirect::Policy::none()`, so the 302 comes back verbatim and +/// `redirect_refusal_error` — the same mapping the command applies — turns +/// it into an actionable redirect error, not a silent cross-origin fetch. +/// +/// A loopback `std::net::TcpListener` (no extra tokio feature) serves one +/// raw `302` pointing at an off-origin target and records how many +/// connections it accepts. +#[tokio::test] +async fn media_fetch_client_does_not_follow_redirects() { + use std::io::{Read, Write}; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let connections = Arc::new(AtomicUsize::new(0)); + + let server_connections = Arc::clone(&connections); + let server = std::thread::spawn(move || { + // Accept exactly one connection; if the client followed the + // redirect it would open a second one to the (unrelated) target, + // but that target is never this server, so a second accept here + // would only happen on an unexpected retry. We serve one 302 and + // return, so the count stays at 1 for a compliant no-redirect client. + if let Ok((mut stream, _)) = listener.accept() { + server_connections.fetch_add(1, Ordering::SeqCst); + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + let response = "HTTP/1.1 302 Found\r\n\ + Location: http://169.254.169.254/latest/meta-data/\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\r\n"; + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + } + }); + + // Drive the exact client the command path uses, not an ad-hoc one. + let client = crate::app_state::build_media_fetch_client() + .expect("media fetch client must build with no-redirect policy"); + let resp = client + .get(format!("http://{addr}/media/clip.mp4")) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + .expect("request should complete without following the redirect"); + + // The 302 is returned verbatim — not the 169.254.x target's response. + assert_eq!(resp.status().as_u16(), 302); + assert!(!resp.status().is_success()); + + // The command maps that status through `redirect_refusal_error`; the + // user-facing error must name the redirect, not read as a generic + // relay failure. + let err = + redirect_refusal_error(resp.status()).expect("a 3xx must map to a redirect-refusal error"); + assert!( + err.contains("redirect") && err.contains("302"), + "error must identify the refused 302 redirect, got: {err}", + ); + + server.join().unwrap(); + assert_eq!( + connections.load(Ordering::SeqCst), + 1, + "exactly one request must be issued — the redirect must not be followed", + ); +} + +#[test] +fn build_media_fetch_client_succeeds_with_no_redirect_policy() { + // The fail-closed invariant: construction must not silently degrade to + // a redirect-following client. If this ever starts failing, startup + // panics loudly (see `build_app_state`) rather than substituting an + // insecure client. + assert!( + crate::app_state::build_media_fetch_client().is_ok(), + "media fetch client must build; a redirect-following fallback is forbidden", + ); +} + +#[test] +fn redirect_refusal_error_only_fires_for_3xx() { + // 3xx → redirect-identifying error; success/non-3xx → None (fall + // through to the normal success or relay-error handling). + assert!(redirect_refusal_error(reqwest::StatusCode::FOUND).is_some()); + assert!(redirect_refusal_error(reqwest::StatusCode::TEMPORARY_REDIRECT).is_some()); + assert!(redirect_refusal_error(reqwest::StatusCode::OK).is_none()); + assert!(redirect_refusal_error(reqwest::StatusCode::NOT_FOUND).is_none()); +} + +#[test] +fn markdown_doc_cap_is_two_mib() { + assert_eq!(MAX_MARKDOWN_DOC_BYTES, 2 * 1024 * 1024); +} + +#[test] +fn declared_length_over_markdown_cap_refused_before_body() { + let err = + declared_length_refusal_error(Some(MAX_MARKDOWN_DOC_BYTES + 1), MAX_MARKDOWN_DOC_BYTES) + .expect("length just over the cap must be refused"); + assert!(err.contains("too large"), "{err}"); + // A 50 MiB advertisement passes the generic download cap but must be + // refused by the markdown viewer's cap before any body bytes are read. + assert!( + declared_length_refusal_error(Some(MAX_DOWNLOAD_BYTES), MAX_MARKDOWN_DOC_BYTES).is_some() + ); +} + +#[test] +fn declared_length_at_cap_or_absent_is_admitted() { + assert!( + declared_length_refusal_error(Some(MAX_MARKDOWN_DOC_BYTES), MAX_MARKDOWN_DOC_BYTES) + .is_none() + ); + // Absent Content-Length is admitted here by design — the streaming + // accumulator below still owns the cap, so a stripped or dishonest + // header cannot buy a larger download. + assert!(declared_length_refusal_error(None, MAX_MARKDOWN_DOC_BYTES).is_none()); +} + +#[test] +fn streamed_bytes_hit_markdown_cap_without_content_length() { + // Simulate a chunked transfer with no (or dishonest) Content-Length: the + // running total must abort the moment a chunk would cross the cap, and + // nothing past the cap may be buffered. + let cap = MAX_MARKDOWN_DOC_BYTES; + let mut bytes = Vec::new(); + let chunk = vec![b'a'; 1024 * 1024]; // 1 MiB chunks + append_chunk_within_cap(&mut bytes, &chunk, cap).expect("first MiB fits"); + append_chunk_within_cap(&mut bytes, &chunk, cap).expect("exactly at the cap is allowed"); + assert_eq!(bytes.len() as u64, cap); + let err = append_chunk_within_cap(&mut bytes, b"x", cap) + .expect_err("one byte past the cap must abort"); + assert!(err.contains("too large"), "{err}"); + assert_eq!( + bytes.len() as u64, + cap, + "no bytes past the cap were buffered" + ); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 43d7b038577..8bf331ac07f 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -678,6 +678,7 @@ pub fn run() { save_png_data_url, download_file, fetch_media_bytes, + fetch_markdown_doc_bytes, cancel_media_fetch, release_media_fetch, copy_image_to_clipboard, diff --git a/desktop/src/features/channels/ui/AgentSessionAuxiliaryPanel.tsx b/desktop/src/features/channels/ui/AgentSessionAuxiliaryPanel.tsx new file mode 100644 index 00000000000..4ee5a6b879d --- /dev/null +++ b/desktop/src/features/channels/ui/AgentSessionAuxiliaryPanel.tsx @@ -0,0 +1,72 @@ +import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; +import * as agentSessionSelection from "@/features/channels/ui/agentSessionSelection"; +import type { ChannelPaneProps } from "@/features/channels/ui/ChannelPane.types"; + +type AgentSessionAuxiliaryPanelProps = Pick< + ChannelPaneProps, + "openAgentSessionChannelId" | "profiles" +> & { + activeChannel: NonNullable; + // ChannelPane defaults this prop, so it is always defined at this call site. + activityAgents: NonNullable; + isSinglePanelView: boolean; + onBack: ChannelPaneProps["onBackFromAgentSession"]; + onClose: ChannelPaneProps["onCloseAgentSession"]; + selectedAgent: NonNullable< + ReturnType + >; + useSplitAuxiliaryPane: boolean; + widthPx: number; +}; + +/** + * Assembles the agent-session auxiliary pane for ChannelPane's pane chain. + * Split out of ChannelPane.tsx to keep it under the per-file line cap. + */ +export function AgentSessionAuxiliaryPanel({ + activeChannel, + activityAgents, + isSinglePanelView, + onBack, + onClose, + openAgentSessionChannelId, + profiles, + selectedAgent, + useSplitAuxiliaryPane, + widthPx, +}: AgentSessionAuxiliaryPanelProps) { + // When the panel was opened from a different channel than the currently + // active one, re-scope it to the active channel so that both the + // content/header AND channel-backed actions (e.g. Stop current turn) + // operate on the same channel object. + const effectiveAgentSessionChannelId = + openAgentSessionChannelId && activeChannel.id !== openAgentSessionChannelId + ? activeChannel.id + : openAgentSessionChannelId; + return ( + + ); +} diff --git a/desktop/src/features/channels/ui/ChannelMarkdownDocPanels.tsx b/desktop/src/features/channels/ui/ChannelMarkdownDocPanels.tsx new file mode 100644 index 00000000000..64fa04b46a7 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelMarkdownDocPanels.tsx @@ -0,0 +1,47 @@ +import type * as React from "react"; + +import { MarkdownDocAuxiliaryPanel } from "@/features/channels/ui/MarkdownDocAuxiliaryPanel"; +import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; +import type { MarkdownDocTarget } from "@/shared/ui/markdown/markdownDocViewerContext"; + +type ChannelMarkdownDocPanelProps = { + canResetWidth: boolean; + doc: MarkdownDocTarget; + onClose: () => void; + onResetWidth: () => void; + onResizeStart: (event: React.PointerEvent) => void; + stacked?: boolean; + widthPx: number; +}; + +/** Document pane shown beside, or stacked over, a still-mounted thread. */ +export function ChannelMarkdownDocPanel({ + canResetWidth, + doc, + onClose, + onResetWidth, + onResizeStart, + stacked = false, + widthPx, +}: ChannelMarkdownDocPanelProps) { + return ( + + + + ); +} diff --git a/desktop/src/features/channels/ui/ChannelMarkdownDocSurfaces.tsx b/desktop/src/features/channels/ui/ChannelMarkdownDocSurfaces.tsx new file mode 100644 index 00000000000..0e7cf6f26c8 --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelMarkdownDocSurfaces.tsx @@ -0,0 +1,51 @@ +import type * as React from "react"; +import { AnimatePresence } from "motion/react"; + +import { ChannelMarkdownDocPanel } from "@/features/channels/ui/ChannelMarkdownDocPanels"; +import type { MarkdownDocTarget } from "@/shared/ui/markdown/markdownDocViewerContext"; + +type Props = { + canResetThreadPanelWidth: boolean; + idleAuxiliarySurface: React.ReactNode; + onCloseMarkdownDoc?: () => void; + onResetThreadPanelWidth: () => void; + onThreadPanelResizeStart: ( + event: React.PointerEvent, + ) => void; + openMarkdownDoc: MarkdownDocTarget | null; + showIdleAuxiliaryOverThread: boolean; + showMarkdownBesideThread: boolean; + threadPanelWidthPx: number; + threadSurface: { markExitComplete: () => void }; + useStackedMarkdownPanel: boolean; +}; + +/** Presence boundaries for responsive Markdown panes around an open thread. */ +export function ChannelMarkdownDocSurfaces(props: Props) { + const renderPanel = (stacked = false) => + props.openMarkdownDoc && props.onCloseMarkdownDoc ? ( + + ) : null; + return ( + <> + + {props.showMarkdownBesideThread ? renderPanel() : null} + + + {props.useStackedMarkdownPanel + ? renderPanel(true) + : props.showIdleAuxiliaryOverThread + ? props.idleAuxiliarySurface + : null} + + + ); +} diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs index 79fd44e00b7..4488636cda4 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.test.mjs @@ -24,6 +24,7 @@ test("focus idle drawers yield to every higher-priority auxiliary surface", () = hasIdleAuxiliaryPanel: true, hasIdlePanelCloseHandler: true, hasProfilePanel: false, + hasMarkdownDoc: false, hasThreadSurface: false, useSplitAuxiliaryPane: true, }; @@ -32,6 +33,7 @@ test("focus idle drawers yield to every higher-priority auxiliary surface", () = for (const surface of [ "channelManagementOpen", "hasAgentSession", + "hasMarkdownDoc", "hasProfilePanel", "hasThreadSurface", ]) { @@ -51,6 +53,7 @@ test("an explicit thread override keeps the idle panel in its own focus drawer", hasIdleAuxiliaryPanel: true, hasIdlePanelCloseHandler: true, hasProfilePanel: false, + hasMarkdownDoc: false, hasThreadSurface: true, overrideThread: true, useSplitAuxiliaryPane: false, diff --git a/desktop/src/features/channels/ui/ChannelPane.helpers.ts b/desktop/src/features/channels/ui/ChannelPane.helpers.ts index a93eed6837a..102d1bfbae7 100644 --- a/desktop/src/features/channels/ui/ChannelPane.helpers.ts +++ b/desktop/src/features/channels/ui/ChannelPane.helpers.ts @@ -10,6 +10,7 @@ export function shouldUseFocusIdleDrawer({ hasAgentSession, hasIdleAuxiliaryPanel, hasIdlePanelCloseHandler, + hasMarkdownDoc, hasProfilePanel, hasThreadSurface, overrideThread = false, @@ -19,6 +20,7 @@ export function shouldUseFocusIdleDrawer({ hasAgentSession: boolean; hasIdleAuxiliaryPanel: boolean; hasIdlePanelCloseHandler: boolean; + hasMarkdownDoc: boolean; hasProfilePanel: boolean; hasThreadSurface: boolean; overrideThread?: boolean; @@ -28,6 +30,7 @@ export function shouldUseFocusIdleDrawer({ (useSplitAuxiliaryPane || overrideThread) && !channelManagementOpen && !hasAgentSession && + !hasMarkdownDoc && !hasProfilePanel && (!hasThreadSurface || overrideThread) && hasIdleAuxiliaryPanel && diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index ea3c2647865..e06dac60917 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -29,7 +29,10 @@ import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel"; import { IdleAuxiliaryPanel } from "@/features/channels/ui/IdleAuxiliaryPanel"; +import { MarkdownDocAuxiliaryPanel } from "@/features/channels/ui/MarkdownDocAuxiliaryPanel"; +import { ChannelMarkdownDocSurfaces } from "@/features/channels/ui/ChannelMarkdownDocSurfaces"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; +import { createChannelPaneAuxiliaryLayout } from "@/features/channels/ui/channelPaneAuxiliaryLayout"; import { ThreadPanelSurface, useThreadPanelSurface, @@ -41,6 +44,7 @@ import { getThreadPanelLayout } from "@/features/channels/lib/threadPanelLayout" import { useThreadViewMode } from "@/features/channels/lib/threadViewModePreference"; import { useThreadViewModeSwitch } from "@/features/channels/ui/useThreadViewModeSwitch"; import { useFocusDrawerPresence } from "@/features/channels/ui/useFocusDrawerPresence"; +import { AUXILIARY_PANEL_MIN_WIDTH_PX } from "@/shared/layout/AuxiliaryPanel"; import { useChannelWorkingAgentPubkeys } from "@/features/agents/agentWorkingSignal"; import { useCardMintJobs } from "@/features/agents/cardMintStore"; import { BotActivityComposerAction } from "@/features/channels/ui/BotActivityBar"; @@ -53,8 +57,6 @@ import { useWelcomeComposerBanner } from "@/features/channels/ui/useWelcomeCompo import { mentionsKnownAgent, selectThreadComposerBotTypingPubkeys, - shouldPrioritizeIdleAuxiliary, - shouldUseFocusIdleDrawer, } from "@/features/channels/ui/ChannelPane.helpers"; import { HuddleStartingView, HuddleTranscriptIntro } from "@/features/huddle"; import { ChannelGlyph } from "@/features/channels/ui/ChannelGlyph"; @@ -85,6 +87,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onAutoSendComplete = null, botTypingEntries, channelManagementOpen = false, + channelContentWidthPx, currentPubkey, editTarget = null, fetchOlder, @@ -125,6 +128,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onCloseChannelManagement, onChannelManagementDeleted, onCloseIdleAuxiliaryPanel, + onCloseMarkdownDoc, onCloseProfilePanel, onAddAgent, onAddFiles, @@ -165,6 +169,8 @@ export const ChannelPane = React.memo(function ChannelPane({ openAgentSessionPubkey, onProfilePanelViewChange, onProfilePanelTabChange, + markdownDocName, + markdownDocUrl, profilePanelPubkey, profilePanelTab, profilePanelView, @@ -420,12 +426,7 @@ export const ChannelPane = React.memo(function ChannelPane({ threadHeadMessage, ]); const isOverlay = useIsThreadPanelOverlay(); - const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay; const threadViewMode = useThreadViewMode(); - const hasThreadSurface = - Boolean(threadHeadMessage) || shouldShowThreadSkeleton; - const useFocusThreadDrawer = - threadViewMode === "focus" && useSplitAuxiliaryPane && hasThreadSurface; const selectedAgent = React.useMemo( () => agentSessionSelection.resolveSelectedAgentSession({ @@ -436,38 +437,46 @@ export const ChannelPane = React.memo(function ChannelPane({ }), [agentSessionAgents, openAgentSessionPubkey, profilePanelPubkey, profiles], ); - const hasIdleAuxiliary = - Boolean(idleAuxiliaryPanel) && Boolean(onCloseIdleAuxiliaryPanel); - const priorityIdleAuxiliary = shouldPrioritizeIdleAuxiliary( - idleAuxiliaryOverridesThread, - hasIdleAuxiliary, - ); - const overlayIdleAuxiliaryOverThread = - priorityIdleAuxiliary && hasThreadSurface && !isOverlay; - const replaceThreadWithIdleAuxiliary = - priorityIdleAuxiliary && hasThreadSurface && isOverlay; - const useFocusIdleDrawer = shouldUseFocusIdleDrawer({ + const { + hasSplitAuxiliaryPane, + openMarkdownDoc, + priorityIdleAuxiliary, + replaceThreadWithIdleAuxiliary, + showIdleAuxiliaryOverThread, + showMarkdownBesideThread, + useFocusIdleDrawer, + useFocusThreadDrawer, + useStackedMarkdownPanel, + useSplitAuxiliaryPane, + } = createChannelPaneAuxiliaryLayout({ + canFitThirdPanel: + channelContentWidthPx >= + threadPanelWidthPx * 2 + AUXILIARY_PANEL_MIN_WIDTH_PX, channelManagementOpen, hasAgentSession: Boolean(activeChannel && selectedAgent), hasIdleAuxiliaryPanel: Boolean(idleAuxiliaryPanel), hasIdlePanelCloseHandler: Boolean(onCloseIdleAuxiliaryPanel), hasProfilePanel: Boolean(profilePanelPubkey), - hasThreadSurface, - overrideThread: overlayIdleAuxiliaryOverThread, - useSplitAuxiliaryPane, + hasThreadSurface: Boolean(threadHeadMessage) || shouldShowThreadSkeleton, + idleAuxiliaryOverridesThread, + isOverlay, + isSinglePanelView, + markdownDocName, + markdownDocUrl, + threadViewMode, }); - const showIdleAuxiliaryOverThread = - overlayIdleAuxiliaryOverThread && useFocusIdleDrawer; const { channelIsCovered, markExitComplete } = useFocusDrawerPresence( useFocusThreadDrawer || useFocusIdleDrawer, - priorityIdleAuxiliary - ? (onCloseIdleAuxiliaryPanel ?? onCloseThread) - : useFocusThreadDrawer - ? onCloseThread - : (onCloseIdleAuxiliaryPanel ?? onCloseThread), + useStackedMarkdownPanel && onCloseMarkdownDoc + ? onCloseMarkdownDoc + : priorityIdleAuxiliary + ? (onCloseIdleAuxiliaryPanel ?? onCloseThread) + : useFocusThreadDrawer + ? onCloseThread + : (onCloseIdleAuxiliaryPanel ?? onCloseThread), ); const threadSurface = useThreadPanelSurface( - showIdleAuxiliaryOverThread, + showIdleAuxiliaryOverThread || useStackedMarkdownPanel, markExitComplete, ); const { changeThreadViewMode, layoutScrollTargetId, resolveScrollTarget } = @@ -494,13 +503,6 @@ export const ChannelPane = React.memo(function ChannelPane({ threadMessages: threadMessages.map((entry) => entry.message), useFocusThreadDrawer, }); - const hasSplitAuxiliaryPane = - useSplitAuxiliaryPane && - (channelManagementOpen || - Boolean(threadHeadMessage) || - shouldShowThreadSkeleton || - Boolean(activeChannel && selectedAgent) || - Boolean(profilePanelPubkey)); const wrapAux = ( panel: React.ReactNode, testId: string, @@ -816,7 +818,6 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : null} - {/* Serialize replacements so focus drawers keep one travel direction. */} {channelManagementOpen && activeChannel ? ( , + "markdown-doc-panel", + ) ) : ( idleAuxiliarySurface )} - - {showIdleAuxiliaryOverThread ? idleAuxiliarySurface : null} - + {/* biome-ignore format: line-count ratchet in this legacy component */} + ); }); diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 1fe5bf751b8..902335e8ace 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -36,6 +36,8 @@ export type ChannelPaneProps = { onAutoSendComplete?: (() => void) | null; botTypingEntries: TypingIndicatorEntry[]; channelManagementOpen?: boolean; + /** Width of the channel content container, used for responsive pane topology. */ + channelContentWidthPx: number; currentPubkey?: string; editTarget?: MessageComposerEditTarget | null; fetchOlder?: () => Promise; @@ -95,6 +97,7 @@ export type ChannelPaneProps = { onCloseChannelManagement?: () => void; onChannelManagementDeleted?: () => void; onCloseIdleAuxiliaryPanel?: () => void; + onCloseMarkdownDoc?: () => void; onCloseProfilePanel: () => void; onAddAgent?: (options?: { beforeSend?: () => void }) => void; onAddFiles?: () => void; @@ -180,6 +183,8 @@ export type ChannelPaneProps = { tab: ProfilePanelTab, options?: { replace?: boolean }, ) => void; + markdownDocName?: string | null; + markdownDocUrl?: string | null; profilePanelPubkey?: string | null; profilePanelTab: ProfilePanelTab; profilePanelView: ProfilePanelView; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index c9330bc74c0..bebea1198b2 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -84,6 +84,7 @@ import { useChannelTargetReset } from "./useChannelTargetReset"; import { useChannelRouteTarget } from "./useChannelRouteTarget"; import { useChannelOpenReadState } from "./useChannelOpenReadState"; import { useChannelUnreadState } from "./useChannelUnreadState"; +import { useChannelPaneOpeners } from "./useChannelPaneOpeners"; import type { ChannelScreenProps } from "./ChannelScreen.types"; import { GuardedChannelPane } from "./GuardedChannelPane"; import { useNavigationGuard } from "./useNavigationGuard"; import * as searchForwarding from "./searchTargetForwarding"; const EMPTY_RELAY_EVENTS: RelayEvent[] = []; @@ -127,6 +128,10 @@ export function ChannelScreen({ channelManagementOpen, clearAutoSend, clearMessageRouteTarget, + closeMarkdownDoc, + markdownDocName, + markdownDocUrl, + openMarkdownDoc, openAgentSessionChannelId, openAgentSessionPubkey, openProfilePanel, @@ -678,7 +683,8 @@ export function ChannelScreen({ effectiveOpenThreadHeadId || openAgentSessionPubkey || profilePanelPubkey || - channelManagementOpen, + channelManagementOpen || + (markdownDocUrl && markdownDocName), ); const displayedThreadHeadMessage = threadPanelData.threadHead; const displayedThreadAllMessages = threadPanelData.messages; @@ -707,33 +713,20 @@ export function ChannelScreen({ resetKey: activeChannelId, enabled: !isSinglePanelView, }); - const handleManageChannel = React.useCallback(() => { - if (!requireThreadEditResolution()) return; - if (activeChannel?.channelType === "forum") { - openGlobalChannelManagement(); - return; - } - if (channelManagementOpen) { - setChannelManagementOpen(false); - return; - } - setOpenThreadHeadId(null); - setExpandedThreadReplyIds(new Set()); - setThreadScrollTargetId(null); - setThreadReplyTargetId(null); - handleCloseAgentSession(); - setProfilePanelPubkey(null); - setChannelManagementOpen(true); - }, [ - activeChannel?.channelType, + const { handleManageChannel, handleOpenMarkdownDoc } = useChannelPaneOpeners({ + channelType: activeChannel?.channelType, channelManagementOpen, + closeAgentSession: handleCloseAgentSession, openGlobalChannelManagement, + openMarkdownDoc, requireThreadEditResolution, setChannelManagementOpen, + setExpandedThreadReplyIds, setOpenThreadHeadId, - handleCloseAgentSession, setProfilePanelPubkey, - ]); + setThreadReplyTargetId, + setThreadScrollTargetId, + }); const handleToggleMembers = React.useCallback( () => setIsMembersSidebarOpen((prev) => !prev), [], @@ -839,7 +832,9 @@ export function ChannelScreen({ > {searchForwarding.renderSearchAwareChannel( , -) { - return ; +/** + * Hosts the markdown-doc viewer context for the channel pane — the surface + * that renders the doc auxiliary panel. The forum branch never mounts this + * wrapper, so forum FileCards keep plain download behavior instead of a + * dead open-in-viewer click. + */ +export function GuardedChannelPane({ + onOpenMarkdownDoc, + ...props +}: React.ComponentProps & { + onOpenMarkdownDoc: (doc: MarkdownDocTarget) => void; +}) { + return ( + + + + ); } diff --git a/desktop/src/features/channels/ui/MarkdownDocAuxiliaryPanel.tsx b/desktop/src/features/channels/ui/MarkdownDocAuxiliaryPanel.tsx new file mode 100644 index 00000000000..5326108a02e --- /dev/null +++ b/desktop/src/features/channels/ui/MarkdownDocAuxiliaryPanel.tsx @@ -0,0 +1,49 @@ +import { MarkdownDocPanel } from "@/features/channels/ui/MarkdownDocPanel"; +import type { MarkdownDocTarget } from "@/shared/ui/markdown/markdownDocViewerContext"; + +type MarkdownDocAuxiliaryPanelProps = { + doc: MarkdownDocTarget; + /** Render chrome for the full focus drawer rather than a narrow pane. */ + isFocusDrawer?: boolean; + isSinglePanelView: boolean; + onClose: () => void; + useSplitAuxiliaryPane: boolean; + widthPx: number; +}; + +/** + * Assembles the markdown-document auxiliary pane for ChannelPane's pane + * chain. Split out of ChannelPane.tsx to keep it under the per-file line + * cap. + * + * This is the chain's lowest-priority pane: a higher-priority pane opened + * afterwards (thread, activity, profile) shows immediately, and the document + * reappears when it closes. Opening a document clears competitors in the + * screen-level handler, so it is never dead on arrival. + */ +export function MarkdownDocAuxiliaryPanel({ + doc, + isFocusDrawer = false, + isSinglePanelView, + onClose, + useSplitAuxiliaryPane, + widthPx, +}: MarkdownDocAuxiliaryPanelProps) { + return ( + // Keyed by URL so opening a different document resets the Preview/Code + // toggle instead of inheriting the previous document's. + + ); +} diff --git a/desktop/src/features/channels/ui/MarkdownDocPanel.tsx b/desktop/src/features/channels/ui/MarkdownDocPanel.tsx new file mode 100644 index 00000000000..f20f247aa55 --- /dev/null +++ b/desktop/src/features/channels/ui/MarkdownDocPanel.tsx @@ -0,0 +1,233 @@ +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Download, FileText, Loader2 } from "lucide-react"; +import { toast } from "sonner"; + +import { invokeTauri } from "@/shared/api/tauri"; +import { + fetchMarkdownDocBytes, + isMediaTooLargeError, +} from "@/shared/api/tauriMedia"; +import { + focusMarkdownDocPanelClose, + restoreFocusToMarkdownDocOpener, +} from "@/features/channels/ui/markdownDocFocus"; +import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; +import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; +import { cn } from "@/shared/lib/cn"; +import { + AuxiliaryPanel, + AuxiliaryPanelBody, + AuxiliaryPanelHeader, + AuxiliaryPanelHeaderActions, + AuxiliaryPanelHeaderGroup, + AuxiliaryPanelHeaderTitleBlock, +} from "@/shared/layout/AuxiliaryPanel"; +import { Button } from "@/shared/ui/button"; +import { Markdown, SyntaxHighlightedCode } from "@/shared/ui/markdown"; +import { + decodeMarkdownDocBytes, + type MarkdownDocDecodeResult, +} from "@/shared/ui/markdown/markdownDocFile"; +import { SegmentedControl } from "@/shared/ui/segmented-control"; + +type MarkdownDocView = "preview" | "code"; + +type MarkdownDocPanelProps = { + /** Raw relay `/media/` URL of the attachment. */ + url: string; + /** Human-readable filename from the imeta `filename` field. */ + filename: string; + /** Fill a parent focus drawer and center the document reading column. */ + isFocusMode?: boolean; + isSinglePanelView?: boolean; + layout?: "standalone" | "split"; + onClose: () => void; + transparentChrome?: boolean; + widthPx: number; +}; + +const VIEW_OPTIONS = [ + { value: "preview", label: "Preview" }, + { value: "code", label: "Code" }, +] as const; + +function decodeErrorMessage(kind: "too-large" | "binary"): string { + return kind === "too-large" + ? "This file is too large to preview." + : "This file isn't valid text, so it can't be previewed."; +} + +/** + * Right auxiliary panel rendering a shared markdown attachment in-app. + * + * Relay media URLs require relay auth (plain browser requests 401), so the + * content is fetched through the authenticated `fetch_markdown_doc_bytes` + * Tauri command — which enforces the viewer's 2 MiB cap natively during the + * fetch — and rendered with the same markdown pipeline chat messages use. + * The Preview/Code toggle switches between rendered markdown and the + * syntax-highlighted source. + */ +export function MarkdownDocPanel({ + url, + filename, + isFocusMode = false, + isSinglePanelView = false, + layout = "standalone", + onClose, + transparentChrome = false, + widthPx, +}: MarkdownDocPanelProps) { + const isOverlay = useIsThreadPanelOverlay(); + useEscapeKey(onClose, isOverlay || isSinglePanelView); + const [view, setView] = React.useState("preview"); + + // Opening can unmount the section holding the focused attachment card + // (narrow layout swaps the whole channel out), and closing unmounts this + // panel — move focus in on mount and hand it back to the opener card on + // unmount so keyboard users never fall to . + React.useEffect(() => { + const cancel = focusMarkdownDocPanelClose(); + return () => { + cancel(); + restoreFocusToMarkdownDocOpener(url); + }; + }, [url]); + + // Blob URLs are content-addressed (`/media/{sha256}.{ext}`), so a fetched + // document never changes under its URL — cache it for the session. + const docQuery = useQuery({ + queryKey: ["markdown-doc", url], + queryFn: async () => { + try { + return decodeMarkdownDocBytes(await fetchMarkdownDocBytes(url)); + } catch (err) { + // The native 2 MiB cap refuses oversized documents during the fetch + // (the in-frontend decode check never sees their bytes). Surface it + // as the too-large fallback rather than a generic fetch failure. + if (isMediaTooLargeError(err)) return { kind: "too-large" }; + throw err; + } + }, + staleTime: Number.POSITIVE_INFINITY, + retry: 1, + }); + + const handleDownload = React.useCallback(() => { + invokeTauri("download_file", { url, filename }).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : "Download failed"; + toast.error(msg); + }); + }, [url, filename]); + + const decoded = docQuery.data; + const errorMessage = docQuery.isError + ? "Couldn't load this file from the relay." + : decoded && decoded.kind !== "ok" + ? decodeErrorMessage(decoded.kind) + : null; + + return ( + + + + + + + + + + } + > + + {/* The view picker gets its own pinned row below the title: sharing + the title row squeezed the filename out, and the header chrome + band overlays anything placed directly after it in the header + slot — so the row lives inside the chrome-padded body instead. */} + {decoded?.kind === "ok" ? ( +
+ +
+ ) : null} +
+
+ {docQuery.isPending ? ( +
+ +
+ ) : errorMessage !== null ? ( +
+

{errorMessage}

+ +
+ ) : decoded?.kind === "ok" ? ( + view === "preview" ? ( + + ) : ( +
+                  {/* Shiki's synchronous-tokenization guard caps highlighting at
+                  150 lines; longer documents render as plain text here. */}
+                  
+                
+ ) + ) : null} +
+
+
+
+ ); +} diff --git a/desktop/src/features/channels/ui/channelPaneAuxiliaryLayout.test.mjs b/desktop/src/features/channels/ui/channelPaneAuxiliaryLayout.test.mjs new file mode 100644 index 00000000000..00bc34d6202 --- /dev/null +++ b/desktop/src/features/channels/ui/channelPaneAuxiliaryLayout.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createChannelPaneAuxiliaryLayout } from "./channelPaneAuxiliaryLayout.ts"; + +const base = { + canFitThirdPanel: false, + channelManagementOpen: false, + hasAgentSession: false, + hasIdleAuxiliaryPanel: true, + hasIdlePanelCloseHandler: true, + hasProfilePanel: false, + hasThreadSurface: false, + idleAuxiliaryOverridesThread: false, + isOverlay: false, + isSinglePanelView: false, + markdownDocName: "notes.md", + markdownDocUrl: "http://localhost/media/notes.bin", + threadViewMode: "focus", +}; + +test("an open document suppresses idle-drawer coverage when the idle pane yields", () => { + const layout = createChannelPaneAuxiliaryLayout(base); + + assert.deepEqual(layout.openMarkdownDoc, { + filename: "notes.md", + url: "http://localhost/media/notes.bin", + }); + assert.equal(layout.useFocusIdleDrawer, false); +}); + +test("an open document stacks over an existing split thread", () => { + const layout = createChannelPaneAuxiliaryLayout({ + ...base, + hasThreadSurface: true, + }); + + assert.equal(layout.useStackedMarkdownPanel, true); + assert.deepEqual(layout.openMarkdownDoc, { + filename: "notes.md", + url: "http://localhost/media/notes.bin", + }); +}); + +test("a wide layout shows the document beside the existing thread", () => { + const layout = createChannelPaneAuxiliaryLayout({ + ...base, + canFitThirdPanel: true, + hasThreadSurface: true, + }); + + assert.equal(layout.showMarkdownBesideThread, true); + assert.equal(layout.useStackedMarkdownPanel, false); +}); + +test("a document without a thread remains in the ordinary auxiliary pane", () => { + const layout = createChannelPaneAuxiliaryLayout(base); + assert.equal(layout.useStackedMarkdownPanel, false); +}); + +test("an explicitly selected idle pane wins rendering and owns drawer coverage", () => { + const layout = createChannelPaneAuxiliaryLayout({ + ...base, + idleAuxiliaryOverridesThread: true, + }); + + assert.equal(layout.openMarkdownDoc, null); + assert.equal(layout.priorityIdleAuxiliary, true); + assert.equal(layout.useFocusIdleDrawer, true); +}); diff --git a/desktop/src/features/channels/ui/channelPaneAuxiliaryLayout.ts b/desktop/src/features/channels/ui/channelPaneAuxiliaryLayout.ts new file mode 100644 index 00000000000..2824535e933 --- /dev/null +++ b/desktop/src/features/channels/ui/channelPaneAuxiliaryLayout.ts @@ -0,0 +1,98 @@ +import { + shouldPrioritizeIdleAuxiliary, + shouldUseFocusIdleDrawer, +} from "./ChannelPane.helpers"; + +type ChannelPaneAuxiliaryLayoutOptions = { + canFitThirdPanel: boolean; + channelManagementOpen: boolean; + hasAgentSession: boolean; + hasIdleAuxiliaryPanel: boolean; + hasIdlePanelCloseHandler: boolean; + hasProfilePanel: boolean; + hasThreadSurface: boolean; + idleAuxiliaryOverridesThread: boolean; + isOverlay: boolean; + isSinglePanelView: boolean; + markdownDocName?: string | null; + markdownDocUrl?: string | null; + threadViewMode: string; +}; + +export function createChannelPaneAuxiliaryLayout({ + canFitThirdPanel, + channelManagementOpen, + hasAgentSession, + hasIdleAuxiliaryPanel, + hasIdlePanelCloseHandler, + hasProfilePanel, + hasThreadSurface, + idleAuxiliaryOverridesThread, + isOverlay, + isSinglePanelView, + markdownDocName, + markdownDocUrl, + threadViewMode, +}: ChannelPaneAuxiliaryLayoutOptions) { + const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay; + const useFocusThreadDrawer = + threadViewMode === "focus" && useSplitAuxiliaryPane && hasThreadSurface; + const hasIdleAuxiliary = hasIdleAuxiliaryPanel && hasIdlePanelCloseHandler; + const priorityIdleAuxiliary = shouldPrioritizeIdleAuxiliary( + idleAuxiliaryOverridesThread, + hasIdleAuxiliary, + ); + const overlayIdleAuxiliaryOverThread = + priorityIdleAuxiliary && hasThreadSurface && !isOverlay; + const replaceThreadWithIdleAuxiliary = + priorityIdleAuxiliary && hasThreadSurface && isOverlay; + const openMarkdownDoc = + markdownDocUrl && markdownDocName + ? { filename: markdownDocName, url: markdownDocUrl } + : null; + const useFocusIdleDrawer = shouldUseFocusIdleDrawer({ + channelManagementOpen, + hasAgentSession, + hasIdleAuxiliaryPanel, + hasIdlePanelCloseHandler, + hasMarkdownDoc: Boolean(openMarkdownDoc) && !priorityIdleAuxiliary, + hasProfilePanel, + hasThreadSurface, + overrideThread: overlayIdleAuxiliaryOverThread, + useSplitAuxiliaryPane, + }); + + const displayedMarkdownDoc = priorityIdleAuxiliary ? null : openMarkdownDoc; + const showMarkdownBesideThread = Boolean( + displayedMarkdownDoc && + hasThreadSurface && + useSplitAuxiliaryPane && + canFitThirdPanel, + ); + const useStackedMarkdownPanel = Boolean( + displayedMarkdownDoc && + hasThreadSurface && + useSplitAuxiliaryPane && + !showMarkdownBesideThread, + ); + const hasSplitAuxiliaryPane = + useSplitAuxiliaryPane && + (channelManagementOpen || + hasThreadSurface || + hasAgentSession || + hasProfilePanel); + + return { + hasSplitAuxiliaryPane, + openMarkdownDoc: displayedMarkdownDoc, + priorityIdleAuxiliary, + replaceThreadWithIdleAuxiliary, + showIdleAuxiliaryOverThread: + overlayIdleAuxiliaryOverThread && useFocusIdleDrawer, + showMarkdownBesideThread, + useFocusIdleDrawer, + useFocusThreadDrawer, + useStackedMarkdownPanel, + useSplitAuxiliaryPane, + }; +} diff --git a/desktop/src/features/channels/ui/channelSearchKeys.ts b/desktop/src/features/channels/ui/channelSearchKeys.ts index 0828d9fec6d..25b763a7c00 100644 --- a/desktop/src/features/channels/ui/channelSearchKeys.ts +++ b/desktop/src/features/channels/ui/channelSearchKeys.ts @@ -10,6 +10,8 @@ export const CHANNEL_SEARCH_KEYS = [ "agentSessionChannel", "autoSend", "channelManagement", + "doc", + "docName", "messageId", "profile", "profileTab", diff --git a/desktop/src/features/channels/ui/markdownDocFocus.test.mjs b/desktop/src/features/channels/ui/markdownDocFocus.test.mjs new file mode 100644 index 00000000000..ffaa7d39150 --- /dev/null +++ b/desktop/src/features/channels/ui/markdownDocFocus.test.mjs @@ -0,0 +1,190 @@ +/** + * Opener-identity tests for the markdown panel focus restore (PR #6731 P2 + * follow-up). The same attachment can appear in several messages — several + * cards sharing one `data-doc-url` — so restoring by URL alone always lands + * on the first DOM match. These tests pin the recorded per-invocation + * identity: the invoking card wins, the record is consumed after one + * restore, a missing record falls back to the first match, and a claimed + * focus target aborts the restore entirely. + */ + +import assert from "node:assert/strict"; +import { after, before, beforeEach, test } from "node:test"; + +import { JSDOM } from "jsdom"; + +// pretendToBeVisual gives the module its requestAnimationFrame loop. +const dom = new JSDOM("", { + pretendToBeVisual: true, + url: "http://localhost", +}); + +before(() => { + Object.assign(globalThis, { + // This jsdom build has no window.CSS; quote-safe escaping is all the + // module's attribute selectors need. + CSS: { escape: (value) => String(value).replace(/["\\]/g, "\\$&") }, + cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window), + document: dom.window.document, + HTMLElement: dom.window.HTMLElement, + requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), + window: dom.window, + }); +}); + +after(() => dom.window.close()); + +const DOC_URL = "http://localhost:3000/media/deadbeef.bin"; + +let nextMessageId = 0; +function addCard(url = DOC_URL, messageId = `message-${nextMessageId++}`) { + const row = dom.window.document.createElement("article"); + row.setAttribute("data-testid", "message-row"); + row.setAttribute("data-message-id", messageId); + const card = dom.window.document.createElement("button"); + card.setAttribute("data-testid", "file-card"); + card.setAttribute("data-doc-url", url); + row.appendChild(card); + dom.window.document.body.appendChild(row); + return card; +} + +function settleFrames(count = 3) { + let done = Promise.resolve(); + for (let i = 0; i < count; i += 1) { + done = done.then( + () => new Promise((resolve) => dom.window.requestAnimationFrame(resolve)), + ); + } + return done; +} + +async function loadModule() { + return import("./markdownDocFocus.ts"); +} + +beforeEach(async () => { + dom.window.document.body.innerHTML = ""; + // Park focus on (focus is "free") and clear any leftover record. + const { recordMarkdownDocOpener } = await loadModule(); + recordMarkdownDocOpener(DOC_URL, null); +}); + +test("restores the recorded invoking card, not the first URL match", async () => { + const { recordMarkdownDocOpener, restoreFocusToMarkdownDocOpener } = + await loadModule(); + addCard(); + const second = addCard(); + + recordMarkdownDocOpener(DOC_URL, second); + restoreFocusToMarkdownDocOpener(DOC_URL); + await settleFrames(); + + assert.equal(dom.window.document.activeElement, second); +}); + +test("consumes the record: a later restore without one takes the first match", async () => { + const { recordMarkdownDocOpener, restoreFocusToMarkdownDocOpener } = + await loadModule(); + const first = addCard(); + const second = addCard(); + + recordMarkdownDocOpener(DOC_URL, second); + restoreFocusToMarkdownDocOpener(DOC_URL); + await settleFrames(); + assert.equal(dom.window.document.activeElement, second); + + // Free the focus again, then restore with no fresh record (deep link / + // reload open): the stale index must not survive the first consumption. + second.blur(); + restoreFocusToMarkdownDocOpener(DOC_URL); + await settleFrames(); + assert.equal(dom.window.document.activeElement, first); +}); + +test("tracks the opener by message id when a preceding same-URL card disappears", async () => { + const { recordMarkdownDocOpener, restoreFocusToMarkdownDocOpener } = + await loadModule(); + const first = addCard(DOC_URL, "message-a"); + const opener = addCard(DOC_URL, "message-b"); + const following = addCard(DOC_URL, "message-c"); + + recordMarkdownDocOpener(DOC_URL, opener); + first.closest('[data-testid="message-row"]').remove(); + restoreFocusToMarkdownDocOpener(DOC_URL); + await settleFrames(); + + assert.equal(dom.window.document.activeElement, opener); + assert.notEqual(dom.window.document.activeElement, following); +}); + +test("falls back to a surviving same-URL card when the opener is gone", async () => { + const { recordMarkdownDocOpener, restoreFocusToMarkdownDocOpener } = + await loadModule(); + const first = addCard(); + const second = addCard(); + + recordMarkdownDocOpener(DOC_URL, second); + second.remove(); + restoreFocusToMarkdownDocOpener(DOC_URL); + await settleFrames(); + + assert.equal(dom.window.document.activeElement, first); +}); + +test("restores a thread-only opener to its surviving summary control", async () => { + const { recordMarkdownDocOpener, restoreFocusToMarkdownDocOpener } = + await loadModule(); + const threadPanel = dom.window.document.createElement("section"); + threadPanel.setAttribute("data-testid", "message-thread-panel"); + dom.window.document.body.appendChild(threadPanel); + const head = addCard(DOC_URL, "thread-head").closest( + '[data-testid="message-row"]', + ); + const reply = addCard(DOC_URL, "thread-reply").closest( + '[data-testid="message-row"]', + ); + threadPanel.append(head, reply); + const opener = reply.querySelector('[data-testid="file-card"]'); + const summary = dom.window.document.createElement("button"); + summary.setAttribute("data-testid", "message-thread-summary"); + summary.setAttribute("data-thread-head-id", "thread-head"); + dom.window.document.body.appendChild(summary); + + recordMarkdownDocOpener(DOC_URL, opener); + threadPanel.remove(); + restoreFocusToMarkdownDocOpener(DOC_URL); + await settleFrames(); + + assert.equal(dom.window.document.activeElement, summary); +}); + +test("ignores a record made for a different document URL", async () => { + const { recordMarkdownDocOpener, restoreFocusToMarkdownDocOpener } = + await loadModule(); + const first = addCard(); + addCard(); + const otherCard = addCard("http://localhost:3000/media/cafe.bin"); + + recordMarkdownDocOpener("http://localhost:3000/media/cafe.bin", otherCard); + restoreFocusToMarkdownDocOpener(DOC_URL); + await settleFrames(); + + assert.equal(dom.window.document.activeElement, first); +}); + +test("aborts when another control already claimed focus", async () => { + const { recordMarkdownDocOpener, restoreFocusToMarkdownDocOpener } = + await loadModule(); + addCard(); + const second = addCard(); + const claimed = dom.window.document.createElement("button"); + dom.window.document.body.appendChild(claimed); + + recordMarkdownDocOpener(DOC_URL, second); + claimed.focus(); + restoreFocusToMarkdownDocOpener(DOC_URL); + await settleFrames(); + + assert.equal(dom.window.document.activeElement, claimed); +}); diff --git a/desktop/src/features/channels/ui/markdownDocFocus.ts b/desktop/src/features/channels/ui/markdownDocFocus.ts new file mode 100644 index 00000000000..ce8acc3717b --- /dev/null +++ b/desktop/src/features/channels/ui/markdownDocFocus.ts @@ -0,0 +1,151 @@ +/** + * Focus choreography for the markdown document panel (PR #6731 P2). + * + * In the narrow single-panel layout, opening a document unmounts the channel + * section containing the focused attachment card, and closing unmounts the + * panel that held focus — in both directions focus falls to `` and + * keyboard/screen-reader users lose their place. On open, focus moves to the + * panel's close control; on close, it returns to the attachment card that + * opened the document (by recorded identity, since the original element was + * unmounted meanwhile and the URL alone can match several cards). + */ + +const PANEL_CLOSE_SELECTOR = + '[data-testid="markdown-doc-panel"] [data-testid="auxiliary-panel-close"]'; + +/** + * Identity of the surface that invoked the current open. Message ids survive + * timeline insertions/deletions and narrow-layout remounts; URL ordinals do + * not. For a thread-only card, the thread head identifies the surviving + * summary control used as the deliberate return target after the pane closes. + */ +type OpenerRecord = { + messageId: string; + threadHeadId: string | null; + url: string; +}; + +let lastOpenerRecord: OpenerRecord | null = null; + +function findCard(messageId: string, url: string): HTMLElement | null { + const row = document.querySelector( + `[data-testid="message-row"][data-message-id="${CSS.escape(messageId)}"]`, + ); + return ( + row?.querySelector( + `[data-testid="file-card"][data-doc-url="${CSS.escape(url)}"]`, + ) ?? null + ); +} + +function findFallback(url: string): HTMLElement | null { + return document.querySelector( + `[data-testid="file-card"][data-doc-url="${CSS.escape(url)}"]`, + ); +} + +function findThreadSummary(threadHeadId: string): HTMLElement | null { + return document.querySelector( + `[data-testid="message-thread-summary"][data-thread-head-id="${CSS.escape(threadHeadId)}"]`, + ); +} + +/** + * Remember the logical surface that invoked the open while its card is still + * mounted. A detached/null opener (for example URL history restoration) clears + * the record, leaving close to use the first matching card when available. + */ +export function recordMarkdownDocOpener( + url: string, + opener: HTMLElement | null, +): void { + if (!opener?.isConnected) { + lastOpenerRecord = null; + return; + } + const row = opener.closest( + '[data-testid="message-row"][data-message-id]', + ); + const messageId = row?.dataset.messageId; + if (!messageId) { + lastOpenerRecord = null; + return; + } + const threadPanel = opener.closest( + '[data-testid="message-thread-panel"]', + ); + lastOpenerRecord = { + messageId, + threadHeadId: + threadPanel?.querySelector( + '[data-testid="message-row"][data-message-id]', + )?.dataset.messageId ?? null, + url, + }; +} + +/** Frames to wait for the target to (re)mount before giving up. */ +const FOCUS_SEARCH_FRAMES = 12; + +function scheduleFocusSearch( + find: () => HTMLElement | null, + shouldAbort: () => boolean, +): () => void { + let frame = 0; + let attempts = 0; + const tick = () => { + if (shouldAbort()) return; + const target = find(); + if (target) { + target.focus(); + return; + } + attempts += 1; + if (attempts < FOCUS_SEARCH_FRAMES) frame = requestAnimationFrame(tick); + }; + frame = requestAnimationFrame(tick); + return () => cancelAnimationFrame(frame); +} + +/** + * True when moving focus is a restoration, not a steal. ``/null means + * focus fell off an unmounted subtree. The composer counts as free too: the + * remounting channel autofocuses it, which is exactly the "lands on the + * composer rather than the invoking attachment" behavior being fixed — + * anything else (another panel's control, a clicked button) keeps focus. + */ +function focusIsFree(): boolean { + const active = document.activeElement; + if (active === null || active === document.body) return true; + return active.closest('[data-testid="message-composer"]') !== null; +} + +/** + * Move focus onto the open panel's close control. Returns a canceler for + * effect cleanup so an unmounting panel stops hunting for its own button. + */ +export function focusMarkdownDocPanelClose(): () => void { + return scheduleFocusSearch( + () => document.querySelector(PANEL_CLOSE_SELECTOR), + // Never abort: the open was user-initiated, the panel is the destination. + () => false, + ); +} + +/** + * After the panel closes, return focus to the exact invoking message/card. + * If its thread surface was closed to make room for the document, focus the + * surviving thread-summary control. Only if the logical opener disappeared do + * we fall back to another card for the same immutable attachment URL. + */ +export function restoreFocusToMarkdownDocOpener(url: string): void { + const record = lastOpenerRecord?.url === url ? lastOpenerRecord : null; + lastOpenerRecord = null; + scheduleFocusSearch( + () => + (record ? findCard(record.messageId, url) : null) ?? + (record?.threadHeadId ? findThreadSummary(record.threadHeadId) : null) ?? + findFallback(url), + () => !focusIsFree(), + ); +} diff --git a/desktop/src/features/channels/ui/useChannelPaneOpeners.ts b/desktop/src/features/channels/ui/useChannelPaneOpeners.ts new file mode 100644 index 00000000000..b399b970b11 --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelPaneOpeners.ts @@ -0,0 +1,118 @@ +import * as React from "react"; + +import type { MarkdownDocTarget } from "@/shared/ui/markdown/markdownDocViewerContext"; + +import { recordMarkdownDocOpener } from "./markdownDocFocus"; + +type UseChannelPaneOpenersOptions = { + channelType: string | undefined; + channelManagementOpen: boolean; + closeAgentSession: () => void; + openGlobalChannelManagement: () => void; + openMarkdownDoc: (url: string, filename: string) => void; + /** Prompts for an unresolved in-progress thread edit; false blocks the open. */ + requireThreadEditResolution: () => boolean; + setChannelManagementOpen: (open: boolean) => void; + setExpandedThreadReplyIds: React.Dispatch>>; + setOpenThreadHeadId: (id: string | null) => void; + setProfilePanelPubkey: (pubkey: string | null) => void; + setThreadReplyTargetId: (id: string | null) => void; + setThreadScrollTargetId: (id: string | null) => void; +}; + +/** + * Open handlers for ChannelPane's mutually exclusive auxiliary panes. + * + * Each opener clears every competing pane before opening its own (mirroring + * useChannelProfilePanel) so a newly opened pane is never dead behind a + * higher-priority sibling in ChannelPane's pane priority chain. + */ +export function useChannelPaneOpeners({ + channelType, + channelManagementOpen, + closeAgentSession, + openGlobalChannelManagement, + openMarkdownDoc, + requireThreadEditResolution, + setChannelManagementOpen, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setProfilePanelPubkey, + setThreadReplyTargetId, + setThreadScrollTargetId, +}: UseChannelPaneOpenersOptions) { + const clearCompetingPanes = React.useCallback(() => { + setOpenThreadHeadId(null); + setExpandedThreadReplyIds(new Set()); + setThreadScrollTargetId(null); + setThreadReplyTargetId(null); + closeAgentSession(); + setProfilePanelPubkey(null); + }, [ + closeAgentSession, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setProfilePanelPubkey, + setThreadReplyTargetId, + setThreadScrollTargetId, + ]); + + const handleManageChannel = React.useCallback(() => { + if (!requireThreadEditResolution()) return; + if (channelType === "forum") { + openGlobalChannelManagement(); + return; + } + if (channelManagementOpen) { + setChannelManagementOpen(false); + return; + } + clearCompetingPanes(); + setChannelManagementOpen(true); + }, [ + channelType, + channelManagementOpen, + clearCompetingPanes, + openGlobalChannelManagement, + requireThreadEditResolution, + setChannelManagementOpen, + ]); + + const handleOpenMarkdownDoc = React.useCallback( + (doc: MarkdownDocTarget) => { + // Capture the invoking card's identity before a narrow-layout panel swap + // can unmount it. In wide layouts an open thread remains mounted beneath + // the document focus drawer, preserving its scroll and reply state. + recordMarkdownDocOpener(doc.url, doc.opener ?? null); + // A document opened from the center timeline takes the ordinary right + // pane, replacing any open thread. A thread card (or URL-restored Inbox + // context, which has no live opener element) preserves that thread so + // ChannelPane can choose side-by-side vs stacked responsively. + if ( + doc.opener && + !doc.opener.closest('[data-testid="message-thread-panel"]') + ) { + setOpenThreadHeadId(null); + setExpandedThreadReplyIds(new Set()); + setThreadScrollTargetId(null); + setThreadReplyTargetId(null); + } + closeAgentSession(); + setProfilePanelPubkey(null); + setChannelManagementOpen(false); + openMarkdownDoc(doc.url, doc.filename); + }, + [ + closeAgentSession, + openMarkdownDoc, + setChannelManagementOpen, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setProfilePanelPubkey, + setThreadReplyTargetId, + setThreadScrollTargetId, + ], + ); + + return { handleManageChannel, handleOpenMarkdownDoc }; +} diff --git a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts index 60e3a291b21..4481d61160b 100644 --- a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts +++ b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts @@ -29,7 +29,8 @@ export type { ChannelSearchKey } from "./channelSearchKeys"; * (presence flag for the channel-management panel — open/closed only, so it * carries a sentinel `"1"` rather than an id), `autoSend` (draft auto-submit * trigger — cleared surgically after the auto-submit fires so `thread` and - * all other panel state are preserved). + * all other panel state are preserved), `doc` + `docName` (markdown document + * viewer panel — relay media URL and imeta filename). */ export type PanelSetterOptions = HistorySearchSetterOptions; @@ -96,6 +97,21 @@ export function useChannelPanelHistoryState() { [applyPatch], ); + // `doc` + `docName` travel together: the URL identifies the attachment and + // the imeta filename is the only human-readable name (blob URLs are content + // hashes), so a doc without a name can't label its panel. + const openMarkdownDoc = React.useCallback( + (url: string, filename: string, options?: PanelSetterOptions) => + applyPatch({ doc: url, docName: filename }, options), + [applyPatch], + ); + + const closeMarkdownDoc = React.useCallback( + (options?: PanelSetterOptions) => + applyPatch({ doc: null, docName: null }, options), + [applyPatch], + ); + const setChannelManagementOpen = React.useCallback( (open: boolean, options?: PanelSetterOptions) => applyPatch( @@ -125,6 +141,10 @@ export function useChannelPanelHistoryState() { channelManagementOpen: values.channelManagement != null, clearAutoSend, clearMessageRouteTarget, + closeMarkdownDoc, + markdownDocName: values.docName, + markdownDocUrl: values.doc, + openMarkdownDoc, openAgentSessionChannelId: values.agentSessionChannel, openAgentSessionPubkey: values.agentSession, openProfilePanel, diff --git a/desktop/src/features/home/ui/HomeMarkdownDocPanel.tsx b/desktop/src/features/home/ui/HomeMarkdownDocPanel.tsx new file mode 100644 index 00000000000..7613b4e7212 --- /dev/null +++ b/desktop/src/features/home/ui/HomeMarkdownDocPanel.tsx @@ -0,0 +1,48 @@ +import type * as React from "react"; + +import { MarkdownDocAuxiliaryPanel } from "@/features/channels/ui/MarkdownDocAuxiliaryPanel"; +import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; +import type { MarkdownDocTarget } from "@/shared/ui/markdown/markdownDocViewerContext"; + +type HomeMarkdownDocPanelProps = { + canResetWidth: boolean; + className?: string; + doc: MarkdownDocTarget; + onClose: () => void; + onResetWidth: () => void; + onResizeStart: (event: React.PointerEvent) => void; + testId: string; + widthPx: number; +}; + +/** Inbox-local Markdown viewer, rendered either as a third pane or a stack. */ +export function HomeMarkdownDocPanel({ + canResetWidth, + className, + doc, + onClose, + onResetWidth, + onResizeStart, + testId, + widthPx, +}: HomeMarkdownDocPanelProps) { + return ( + + + + ); +} diff --git a/desktop/src/features/home/ui/HomeMarkdownDocSurfaces.tsx b/desktop/src/features/home/ui/HomeMarkdownDocSurfaces.tsx new file mode 100644 index 00000000000..c3dba8796e8 --- /dev/null +++ b/desktop/src/features/home/ui/HomeMarkdownDocSurfaces.tsx @@ -0,0 +1,42 @@ +import type * as React from "react"; + +import type { MarkdownDocTarget } from "@/shared/ui/markdown/markdownDocViewerContext"; +import { HomeMarkdownDocPanel } from "./HomeMarkdownDocPanel"; + +type Props = { + besideDetail: boolean; + canResetWidth: boolean; + doc: MarkdownDocTarget | null; + onClose: () => void; + onResetWidth: () => void; + onResizeStart: (event: React.PointerEvent) => void; + showDetail: boolean; + widthPx: number; +}; + +/** Responsive Inbox document surfaces: third pane when roomy, stack otherwise. */ +export function HomeMarkdownDocSurfaces(props: Props) { + if (!props.doc) return null; + return props.besideDetail ? ( + + ) : props.showDetail ? ( + + ) : null; +} diff --git a/desktop/src/features/home/ui/HomeView.tsx b/desktop/src/features/home/ui/HomeView.tsx index 0a16f27c4d0..2dfebcd86f2 100644 --- a/desktop/src/features/home/ui/HomeView.tsx +++ b/desktop/src/features/home/ui/HomeView.tsx @@ -1,6 +1,5 @@ import * as React from "react"; import { RefreshCcw } from "lucide-react"; - import { useAppShell } from "@/app/AppShellContext"; import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import { useChannelsQuery } from "@/features/channels/hooks"; @@ -27,6 +26,7 @@ import { useHomeInboxAutoSelection } from "@/features/home/useHomeInboxAutoSelec import { useHomeInboxContextMessages } from "@/features/home/useHomeInboxContextMessages"; import { useHomePersonalInbox } from "@/features/home/useHomePersonalInbox"; import { useInboxThreadContext } from "@/features/home/useInboxThreadContext"; +import { useInboxMarkdownDoc } from "@/features/home/useInboxMarkdownDoc"; import { useHiddenDmInboxNavigation } from "@/features/home/useHiddenDmInboxNavigation"; import { type ProfilePanelTab, @@ -47,6 +47,7 @@ import { HomeLoadingState } from "@/features/home/ui/HomeLoadingState"; import { InboxDetailPane } from "@/features/home/ui/InboxDetailPane"; import { InboxListPane } from "@/features/home/ui/InboxListPane"; import { HomePersonalInboxDetail } from "@/features/home/ui/HomePersonalInboxDetail"; +import { HomeMarkdownDocSurfaces } from "@/features/home/ui/HomeMarkdownDocSurfaces"; import { useChannelMessagesQuery, useToggleReactionMutation, @@ -73,14 +74,12 @@ import { useHistorySearchState } from "@/shared/hooks/useHistorySearchState"; import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; import { Button } from "@/shared/ui/button"; import { HomeMembersSidebarOverlay } from "./HomeMembersSidebarOverlay"; - const INBOX_SEARCH_KEYS = [ "item", "profile", "profileTab", "profileView", ] as const; - type HomeViewProps = { feed?: HomeFeedResponse; isLoading?: boolean; @@ -94,7 +93,6 @@ type HomeViewProps = { ) => void; onRefresh: () => void; }; - export function HomeView({ feed, isLoading = false, @@ -253,7 +251,6 @@ export function HomeView({ selectedEventId, availableChannelIds, }); - const threadContextFeedItem = activeLatchedItem; // Derive the default composer parent from the active anchor's own tags so // that InboxDetailPane can recover the original reply target even when the @@ -282,13 +279,6 @@ export function HomeView({ return channels.find((channel) => channel.id === managedChannelId) ?? null; }, [channels, managedChannelId]); const isChannelManagementOpen = managedChannel !== null; - const hasAuxiliaryPane = - isChannelManagementOpen || profilePanelPubkey !== null; - const isSinglePanelAuxiliaryView = - hasAuxiliaryPane && - homeInboxWidthPx > 0 && - homeInboxWidthPx < AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX; - const channelMessagesQuery = useChannelMessagesQuery(selectedChannel); const toggleReactionMutation = useToggleReactionMutation(); const channelMessages = channelMessagesQuery.data; @@ -307,7 +297,6 @@ export function HomeView({ selectedChannel, threadContext.refreshStructuralEvents, ); - const feedProfilePubkeys = React.useMemo( () => [ ...new Set([ @@ -349,13 +338,11 @@ export function HomeView({ const communityAgentPubkeys = useKnownAgentPubkeys(); const inboxAgentPubkeys = React.useMemo(() => { const pubkeys = new Set(communityAgentPubkeys); - for (const [pubkey, profile] of Object.entries(feedProfiles ?? {})) { if (profile.isAgent) { pubkeys.add(normalizePubkey(pubkey)); } } - return pubkeys; }, [feedProfiles, communityAgentPubkeys]); // biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion invalidates the stable getChannelReadAt callback @@ -417,7 +404,25 @@ export function HomeView({ : null; const selectedConversationId = selectedItemFromAll?.conversationId ?? latchedConversationId; - + const { + besideDetail: inboxDocCanFitBesideDetail, + close: closeMarkdownDoc, + doc: markdownDoc, + open: openMarkdownDoc, + } = useInboxMarkdownDoc({ + conversationId: selectedConversationId, + homeWidthPx: homeInboxWidthPx, + inboxListWidthPx, + panelWidthPx: threadPanelWidthPx, + }); + const hasAuxiliaryPane = + isChannelManagementOpen || + profilePanelPubkey !== null || + inboxDocCanFitBesideDetail; + const isSinglePanelAuxiliaryView = + hasAuxiliaryPane && + homeInboxWidthPx > 0 && + homeInboxWidthPx < AUXILIARY_PANEL_SINGLE_COLUMN_BREAKPOINT_PX; const filteredItems = React.useMemo(() => { return inboxItems.filter( (item) => @@ -523,7 +528,6 @@ export function HomeView({ setAutoSelectedEventId, urlSelectedItemId, }); - React.useEffect(() => { void selectedConversationId; setEmptyDeleteId(null); @@ -531,7 +535,6 @@ export function HomeView({ setIsDeletingMessage(false); setIsSendingReply(false); }, [selectedConversationId]); - const handleFilterChange = React.useCallback( (nextFilter: InboxFilter) => { const nextItems = inboxItems.filter( @@ -546,12 +549,10 @@ export function HomeView({ items: nextItems, selectedConversationId, }); - setUnreadBoundary(null); setSelectedDraftKey(null); setSelectedReminderId(null); setFilter(nextFilter); - if ( nextFilter === "reminders" || nextFilter === "drafts" || @@ -563,7 +564,6 @@ export function HomeView({ } return; } - applyInboxSearchPatch({ item: null }); setAutoSelectedEventId(selection.autoSelectedEventId); }, @@ -579,11 +579,9 @@ export function HomeView({ unreadOnly, ], ); - if (isLoading && !feed) { return ; } - if (!feed) { return (
@@ -604,7 +602,6 @@ export function HomeView({
); } - const { canDelete, canReact, canReply, disabledReplyReason } = getHomeMessageCapabilities( selectedItem, @@ -642,7 +639,6 @@ export function HomeView({ selectedReminder: selectedReminder !== null, threadPanelWidthPx, }); - return ( ) : null} - {showListPane ? ( ) : null} - - {showDetailPane && detailMode === "messages" ? ( ) : null} + void; + onOpenMarkdownDoc: (doc: MarkdownDocTarget) => void; /** True while the selected hidden DM is being reopened on the relay. */ reopenPending?: boolean; /** True when the last reopen of the selected hidden DM failed. */ @@ -158,7 +163,9 @@ export function InboxDetailPane(props: InboxDetailPaneProps) { return ( - + + + ); } diff --git a/desktop/src/features/home/useInboxMarkdownDoc.ts b/desktop/src/features/home/useInboxMarkdownDoc.ts new file mode 100644 index 00000000000..696ffea4cd2 --- /dev/null +++ b/desktop/src/features/home/useInboxMarkdownDoc.ts @@ -0,0 +1,37 @@ +import * as React from "react"; + +import type { MarkdownDocTarget } from "@/shared/ui/markdown/markdownDocViewerContext"; +import { AUXILIARY_PANEL_MIN_WIDTH_PX } from "@/shared/layout/AuxiliaryPanel"; + +/** Owns Inbox-local document state and its responsive third-pane threshold. */ +export function useInboxMarkdownDoc({ + conversationId, + homeWidthPx, + inboxListWidthPx, + panelWidthPx, +}: { + conversationId: string | null; + homeWidthPx: number; + inboxListWidthPx: number; + panelWidthPx: number; +}) { + const [state, setState] = React.useState<{ + conversationId: string | null; + doc: MarkdownDocTarget | null; + }>({ conversationId, doc: null }); + const doc = state.conversationId === conversationId ? state.doc : null; + const open = React.useCallback( + (next: MarkdownDocTarget | null) => setState({ conversationId, doc: next }), + [conversationId], + ); + const besideDetail = + doc !== null && + homeWidthPx >= + inboxListWidthPx + panelWidthPx + AUXILIARY_PANEL_MIN_WIDTH_PX; + return { + besideDetail, + close: React.useCallback(() => open(null), [open]), + doc, + open, + }; +} diff --git a/desktop/src/shared/api/tauriMedia.ts b/desktop/src/shared/api/tauriMedia.ts index 01ef53d3c1c..3a5e5476c59 100644 --- a/desktop/src/shared/api/tauriMedia.ts +++ b/desktop/src/shared/api/tauriMedia.ts @@ -125,6 +125,34 @@ export async function fetchMediaBytes( } } +/** + * Fetch a markdown document attachment for the in-app viewer. + * + * Unlike the generic `fetchMediaBytes` (50 MiB cap), the Rust command + * enforces the viewer's 2 MiB ceiling natively — refusing an oversized + * Content-Length before the body is read and aborting mid-stream when the + * header is missing or dishonest — so a forged imeta `size` can never buy + * a 50 MiB download and IPC copy. + */ +export async function fetchMarkdownDocBytes( + url: string, +): Promise> { + const bytes = await invokeTauri("fetch_markdown_doc_bytes", { + url, + }); + return new Uint8Array(bytes); +} + +/** + * Whether a markdown-doc fetch failure is the native size-cap refusal, as + * opposed to a network or relay error. Matches the stable "file too large" + * prefix produced by the Rust cap checks. + */ +export function isMediaTooLargeError(err: unknown): boolean { + const message = err instanceof Error ? err.message : String(err); + return message.includes("file too large"); +} + /** Read plain text without depending on embedded-webview clipboard grants. */ export async function readTextFromSystemClipboard(): Promise { // E2E installs Tauri's mocked IPC surface in a browser page, where the SDK's diff --git a/desktop/src/shared/ui/markdown/FileCard.tsx b/desktop/src/shared/ui/markdown/FileCard.tsx index 889847193ec..b96cf28c933 100644 --- a/desktop/src/shared/ui/markdown/FileCard.tsx +++ b/desktop/src/shared/ui/markdown/FileCard.tsx @@ -1,10 +1,16 @@ import * as React from "react"; -import { Download, FileText } from "lucide-react"; +import { Download, FileText, PanelRight } from "lucide-react"; import { toast } from "sonner"; import { invokeTauri } from "@/shared/api/tauri"; import { useSmoothCorners } from "@/shared/ui/smoothCorners"; +import { + isMarkdownDocFilename, + MAX_MARKDOWN_DOC_BYTES, +} from "./markdownDocFile"; +import { useMarkdownDocViewer } from "./markdownDocViewerContext"; + /** Human-readable byte size: "820 B", "12.4 KB", "3.1 MB". */ function formatFileSize(bytes: number): string { if (!Number.isFinite(bytes) || bytes < 0) return ""; @@ -28,6 +34,12 @@ function formatFileSize(bytes: number): string { * link navigates the webview to the blob URL, which escapes to the OS browser * and gets bounced to a corporate CDN interstitial ("browser not supported"). * The native command mirrors the image-download path. + * + * Markdown attachments (`.md`/`.markdown`/`.mdx` by imeta filename) open the + * in-app markdown viewer panel instead when a hosting surface provides one. + * The authenticated `fetch_markdown_doc_bytes` command owns the authoritative + * relay-origin check and native 2 MiB cap; duplicating its origin check here + * makes the affordance depend on asynchronous frontend cache timing. */ export function FileCard({ href, @@ -41,12 +53,28 @@ export function FileCard({ const cardRef = React.useRef(null); const sizeLabel = size != null ? formatFileSize(size) : ""; useSmoothCorners(cardRef); + const openMarkdownDoc = useMarkdownDocViewer(); + const isMarkdownDoc = isMarkdownDocFilename(filename); + const isWithinPreviewSize = size == null || size <= MAX_MARKDOWN_DOC_BYTES; + const opensInViewer = + openMarkdownDoc !== null && isMarkdownDoc && isWithinPreviewSize; + const downloadReason = !isMarkdownDoc + ? "not-markdown" + : !isWithinPreviewSize + ? "too-large" + : openMarkdownDoc === null + ? "viewer-unavailable" + : undefined; return ( ); } diff --git a/desktop/src/shared/ui/markdown/markdownDocFile.test.mjs b/desktop/src/shared/ui/markdown/markdownDocFile.test.mjs new file mode 100644 index 00000000000..58c7e05e915 --- /dev/null +++ b/desktop/src/shared/ui/markdown/markdownDocFile.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + decodeMarkdownDocBytes, + isMarkdownDocFilename, + MAX_MARKDOWN_DOC_BYTES, +} from "./markdownDocFile.ts"; + +// ── isMarkdownDocFilename ───────────────────────────────────────────────── + +test("isMarkdownDocFilename: accepts .md, .markdown, .mdx", () => { + assert.equal(isMarkdownDocFilename("README.md"), true); + assert.equal(isMarkdownDocFilename("notes.markdown"), true); + assert.equal(isMarkdownDocFilename("page.mdx"), true); +}); + +test("isMarkdownDocFilename: case-insensitive and whitespace-tolerant", () => { + assert.equal(isMarkdownDocFilename("PLAN.MD"), true); + assert.equal(isMarkdownDocFilename(" design.Md "), true); +}); + +test("isMarkdownDocFilename: rejects other extensions", () => { + assert.equal(isMarkdownDocFilename("report.pdf"), false); + assert.equal(isMarkdownDocFilename("archive.zip"), false); + assert.equal(isMarkdownDocFilename("script.mjs"), false); + // Extension must be a suffix with a stem, not the whole name. + assert.equal(isMarkdownDocFilename(".md"), false); + assert.equal(isMarkdownDocFilename(""), false); +}); + +test("isMarkdownDocFilename: does not match mid-name extensions", () => { + assert.equal(isMarkdownDocFilename("notes.md.zip"), false); + assert.equal(isMarkdownDocFilename("mdfile.txt"), false); +}); + +// ── decodeMarkdownDocBytes ──────────────────────────────────────────────── + +test("decodeMarkdownDocBytes: decodes UTF-8 text", () => { + const bytes = new TextEncoder().encode("# Hello 🐝\n\n- item"); + assert.deepEqual(decodeMarkdownDocBytes(bytes), { + kind: "ok", + text: "# Hello 🐝\n\n- item", + }); +}); + +test("decodeMarkdownDocBytes: rejects oversized payloads", () => { + const bytes = new Uint8Array(MAX_MARKDOWN_DOC_BYTES + 1); + assert.deepEqual(decodeMarkdownDocBytes(bytes), { kind: "too-large" }); +}); + +test("decodeMarkdownDocBytes: accepts a payload exactly at the cap", () => { + const bytes = new Uint8Array(MAX_MARKDOWN_DOC_BYTES).fill(0x61); + const result = decodeMarkdownDocBytes(bytes); + assert.equal(result.kind, "ok"); +}); + +test("decodeMarkdownDocBytes: strict decode reports binary content", () => { + // 0xFF is never valid in UTF-8. + const bytes = new Uint8Array([0x23, 0x20, 0xff, 0xfe, 0x00]); + assert.deepEqual(decodeMarkdownDocBytes(bytes), { kind: "binary" }); +}); diff --git a/desktop/src/shared/ui/markdown/markdownDocFile.ts b/desktop/src/shared/ui/markdown/markdownDocFile.ts new file mode 100644 index 00000000000..ba7b2df1532 --- /dev/null +++ b/desktop/src/shared/ui/markdown/markdownDocFile.ts @@ -0,0 +1,62 @@ +/** + * Pure classification and decoding for viewable markdown document + * attachments. + * + * A markdown file uploaded to the relay has no magic bytes, so Blossom + * stores it as `application/octet-stream` under a `{sha256}.bin` blob key — + * the original `.md` name survives only in the message's imeta `filename` + * field. Classification therefore keys off the imeta filename, never the + * blob-URL extension or the MIME type. + * + * Kept DOM-free (TextDecoder is available in both the webview and Node) + * so the branch logic is unit-testable without a webview. + */ + +/** Filename extensions rendered by the in-app markdown viewer. */ +const MARKDOWN_DOC_EXTENSIONS = [".md", ".markdown", ".mdx"] as const; + +/** + * Maximum attachment size the viewer will render. Larger files fall back + * to the download card path. + * + * This constant powers the untrusted-imeta pre-gate (UX only) and the + * defense-in-depth decode check. The *enforcement* boundary is the native + * `fetch_markdown_doc_bytes` command's matching `MAX_MARKDOWN_DOC_BYTES` + * cap in `media_download.rs`, which refuses oversized documents during the + * streamed fetch — keep the two in sync. + */ +export const MAX_MARKDOWN_DOC_BYTES = 2 * 1024 * 1024; + +/** Whether an imeta filename should open in the in-app markdown viewer. */ +export function isMarkdownDocFilename(filename: string): boolean { + const lower = filename.trim().toLowerCase(); + return MARKDOWN_DOC_EXTENSIONS.some( + (extension) => lower.endsWith(extension) && lower.length > extension.length, + ); +} + +export type MarkdownDocDecodeResult = + | { kind: "ok"; text: string } + | { kind: "too-large" } + | { kind: "binary" }; + +/** + * Decode fetched attachment bytes for the viewer. + * + * Strict UTF-8: a file that merely *claims* to be markdown by name but is + * actually binary fails decoding and reports `binary`, so the panel can fall + * back to the download action instead of rendering mojibake. + */ +export function decodeMarkdownDocBytes( + bytes: Uint8Array, +): MarkdownDocDecodeResult { + if (bytes.byteLength > MAX_MARKDOWN_DOC_BYTES) { + return { kind: "too-large" }; + } + try { + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + return { kind: "ok", text }; + } catch { + return { kind: "binary" }; + } +} diff --git a/desktop/src/shared/ui/markdown/markdownDocViewerContext.ts b/desktop/src/shared/ui/markdown/markdownDocViewerContext.ts new file mode 100644 index 00000000000..ef3914a7da9 --- /dev/null +++ b/desktop/src/shared/ui/markdown/markdownDocViewerContext.ts @@ -0,0 +1,37 @@ +import * as React from "react"; + +/** A relay-hosted markdown attachment the in-app viewer can open. */ +export type MarkdownDocTarget = { + /** Raw relay `/media/` URL of the attachment (pre-proxy-rewrite). */ + url: string; + /** Human-readable filename from the message's imeta `filename` field. */ + filename: string; + /** + * The card element that invoked the open, for focus restoration when the + * panel closes. The URL alone cannot identify the opener: the same + * attachment can appear in several messages, giving several cards one URL. + */ + opener?: HTMLElement | null; +}; + +/** + * Open-in-viewer callback for markdown document attachments. + * + * Provided by surfaces that host a markdown-doc auxiliary panel (the channel + * screen). Where no provider exists — project PR/issue bodies, read-only + * previews — the context is `null` and `FileCard` keeps its default + * download-only behavior, so the viewer affordance can never appear + * somewhere it has no panel to open. + */ +const MarkdownDocViewerContext = React.createContext< + ((doc: MarkdownDocTarget) => void) | null +>(null); + +export const MarkdownDocViewerProvider = MarkdownDocViewerContext.Provider; + +/** The active surface's open-in-viewer callback, or null when unhosted. */ +export function useMarkdownDocViewer(): + | ((doc: MarkdownDocTarget) => void) + | null { + return React.useContext(MarkdownDocViewerContext); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 9eda4f08fd4..2755310add8 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -14469,6 +14469,18 @@ export function maybeInstallE2eTauriMocks() { } return null; } + case "fetch_markdown_doc_bytes": { + // Mirrors the real command's native 2 MiB viewer cap (enforced in + // Rust during the streamed fetch) so specs can prove the oversized + // fallback: the refusal message must match the Rust cap error shape. + const response = await fetch((payload as { url: string }).url); + if (!response.ok) throw new Error(`fetch failed: ${response.status}`); + const buffer = await response.arrayBuffer(); + if (buffer.byteLength > 2 * 1024 * 1024) { + throw new Error("file too large (max 2 MiB)"); + } + return buffer; + } case "fetch_snapshot_bytes": { // The real command fetches + validates a snapshot attachment in memory // (size cap, SHA-256, decode). In E2E the bridge returns a minimal diff --git a/desktop/tests/e2e/markdown-doc-viewer.spec.ts b/desktop/tests/e2e/markdown-doc-viewer.spec.ts new file mode 100644 index 00000000000..54395750980 --- /dev/null +++ b/desktop/tests/e2e/markdown-doc-viewer.spec.ts @@ -0,0 +1,348 @@ +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +// Exercises the markdown-attachment viewer end-to-end through the mock Tauri +// bridge: upload a `.md` file → send → FileCard opens the in-app markdown +// viewer panel (not the download dialog) → Preview renders, Code shows the +// source, Download still works from the panel header. +// +// The attachment URL deliberately mirrors production shape: the relay stores +// extension-less text as `{sha256}.bin` (markdown has no magic bytes), so the +// `.md` identity lives only in the imeta filename. The mock upload descriptor +// reproduces that. + +const RELAY_HTTP_URL = + process.env.BUZZ_E2E_RELAY_URL ?? "http://localhost:3000"; +const DOC_SHA = "b".repeat(64); +const DOC_URL = `${RELAY_HTTP_URL}/media/${DOC_SHA}.bin`; +const DOC_MARKDOWN = [ + "# Release Notes", + "", + "Some **bold** text and a table:", + "", + "| Feature | Works |", + "| --- | --- |", + "| Headings | Yes |", + "", + "```js", + 'console.log("hi");', + "```", + "", +].join("\n"); + +test.beforeEach(async ({ page }) => { + await installMockBridge(page, { + // Route attach through the DOM file input (Playwright's filechooser) + // instead of the native pick_and_upload_media dialog path. + deferredComposerUploads: true, + uploadDescriptors: [ + { + url: DOC_URL, + sha256: DOC_SHA, + size: DOC_MARKDOWN.length, + type: "application/octet-stream", + uploaded: Math.floor(Date.now() / 1000), + filename: "release-notes.md", + }, + ], + }); + // The bridge's `fetch_media_bytes` mock fetches the URL in-browser; serve + // the document body from the spec instead of a real relay. + await page.route(`**/media/${DOC_SHA}.bin`, (route) => + route.fulfill({ + body: DOC_MARKDOWN, + contentType: "application/octet-stream", + }), + ); +}); + +async function sendMarkdownAttachment(page: Page) { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await attachAndSendMarkdown(page); +} + +/** Attach + send in the already-open channel (mock re-serves one descriptor, + * so every send yields a card with the same document URL). */ +async function attachAndSendMarkdown(page: Page) { + const [chooser] = await Promise.all([ + page.waitForEvent("filechooser"), + page.getByRole("button", { name: "Attach file" }).click(), + ]); + await chooser.setFiles({ + buffer: Buffer.from(DOC_MARKDOWN), + mimeType: "text/markdown", + name: "release-notes.md", + }); + await expect(page.getByTestId("message-composer")).toContainText( + "release-notes.md", + ); + await page.getByTestId("send-message").click(); + await expect(page.getByText("Sending")).toHaveCount(0); +} + +test("markdown attachment sent as a thread reply keeps the viewer action", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + + await page.locator('[data-testid^="reply-message-"]').first().click({ + force: true, + }); + const threadPanel = page.getByTestId("message-thread-panel"); + await expect(threadPanel).toBeVisible(); + + const [chooser] = await Promise.all([ + page.waitForEvent("filechooser"), + threadPanel.getByRole("button", { name: "Attach file" }).click(), + ]); + await chooser.setFiles({ + buffer: Buffer.from(DOC_MARKDOWN), + mimeType: "text/markdown", + name: "release-notes.md", + }); + await threadPanel.getByTestId("send-message").click(); + await expect(threadPanel.getByText("Sending")).toHaveCount(0); + + const card = threadPanel.getByTestId("file-card").last(); + await expect(card).toContainText("release-notes.md"); + await expect(card).toHaveAttribute("aria-label", "Open release-notes.md"); +}); + +test("markdown attachment opens the in-app viewer with Preview/Code toggle", async ({ + page, +}) => { + await sendMarkdownAttachment(page); + + // The card advertises open-in-viewer, not download. + const card = page.getByTestId("file-card").last(); + await expect(card).toContainText("release-notes.md"); + await expect(card).toHaveAttribute("aria-label", "Open release-notes.md"); + await card.click(); + + // The viewer panel opens with the rendered document (no download dialog). + const panel = page.getByTestId("markdown-doc-panel"); + await expect(panel).toBeVisible(); + await expect(panel).toContainText("release-notes.md"); + await expect( + panel.getByRole("heading", { name: "Release Notes" }), + ).toBeVisible(); + // GFM table rendered as a real table, not pipes. + await expect(panel.locator("table")).toContainText("Headings"); + const commands = () => + page.evaluate( + () => + (window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }) + .__BUZZ_E2E_COMMANDS__ ?? [], + ); + expect(await commands()).not.toContain("download_file"); + + // Code view shows the raw source. + await page.getByTestId("markdown-doc-view-code").click(); + await expect(page.getByTestId("markdown-doc-code")).toContainText( + "# Release Notes", + ); + await page.getByTestId("markdown-doc-view-preview").click(); + await expect( + panel.getByRole("heading", { name: "Release Notes" }), + ).toBeVisible(); + + // Download stays available from the panel header. + await page.getByTestId("markdown-doc-download").click(); + await expect.poll(commands).toContain("download_file"); + + // Close returns to the plain channel view. + await page.getByTestId("auxiliary-panel-close").click(); + await expect(page.getByTestId("markdown-doc-panel")).toHaveCount(0); +}); + +test("narrow layout moves focus into the panel and returns it to the card", async ({ + page, +}) => { + await sendMarkdownAttachment(page); + + // Below the split-pane threshold the panel replaces the channel section, + // unmounting the focused attachment card — the focus contract under test. + await page.setViewportSize({ width: 560, height: 720 }); + + const card = page.getByTestId("file-card").last(); + await card.focus(); + await expect(card).toBeFocused(); + await page.keyboard.press("Enter"); + + // Focus lands on the panel's close control, not . + const panel = page.getByTestId("markdown-doc-panel"); + await expect(panel).toBeVisible(); + await expect(page.getByTestId("auxiliary-panel-close")).toBeFocused(); + + // Escape closes the panel and hands focus back to the invoking card — + // overriding the remounted channel's composer autofocus. + await page.keyboard.press("Escape"); + await expect(page.getByTestId("markdown-doc-panel")).toHaveCount(0); + await expect(page.getByTestId("file-card").last()).toBeFocused(); +}); + +test("close restores focus to the invoking card when the document appears twice", async ({ + page, +}) => { + // The same attachment in two messages gives two cards with one URL, so a + // URL-only anchor would always restore the first DOM match. The invoking + // card's recorded identity must win. + await sendMarkdownAttachment(page); + await attachAndSendMarkdown(page); + + await page.setViewportSize({ width: 560, height: 720 }); + + const docCards = page.locator( + `[data-testid="file-card"][data-doc-url="${DOC_URL}"]`, + ); + await expect(docCards).toHaveCount(2); + + // Open from the SECOND card. + await docCards.nth(1).focus(); + await page.keyboard.press("Enter"); + await expect(page.getByTestId("auxiliary-panel-close")).toBeFocused(); + + await page.keyboard.press("Escape"); + await expect(page.getByTestId("markdown-doc-panel")).toHaveCount(0); + await expect(docCards).toHaveCount(2); + await expect(docCards.nth(1)).toBeFocused(); + await expect(docCards.nth(0)).not.toBeFocused(); +}); + +test("channel rehydration keeps the viewer action", async ({ page }) => { + await sendMarkdownAttachment(page); + + // Remount the channel while relay-origin discovery catches up. The known + // markdown attachment must not temporarily downgrade to Download. + await page.getByTestId("channel-random").click(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await expect(page.getByTestId("file-card").last()).toHaveAttribute( + "aria-label", + "Open release-notes.md", + ); +}); + +test("open document survives reload and back/forward navigation", async ({ + page, +}) => { + await sendMarkdownAttachment(page); + await page.getByTestId("file-card").last().click(); + + const panel = () => page.getByTestId("markdown-doc-panel"); + await expect( + panel().getByRole("heading", { name: "Release Notes" }), + ).toBeVisible(); + + // The document lives in the URL (`doc`/`docName` params), so a reload + // must restore the open panel with its content. + await page.reload(); + await expect( + panel().getByRole("heading", { name: "Release Notes" }), + ).toBeVisible(); + + // Opening the panel pushed a history entry: back closes it, forward + // restores it — the advertised back/forward contract. + await page.goBack(); + await expect(page.getByTestId("markdown-doc-panel")).toHaveCount(0); + await page.goForward(); + await expect( + panel().getByRole("heading", { name: "Release Notes" }), + ).toBeVisible(); +}); + +test("a document over the native 2 MiB cap falls back to download", async ({ + page, +}) => { + // The imeta `size` is untrusted and here it lies small — the card offers + // the viewer. The served body is over the cap, so the (mocked) native + // fetch refuses it mid-transfer and the panel must show the too-large + // fallback instead of rendering, proving enforcement does not rest on + // the advertised size. + await page.unroute(`**/media/${DOC_SHA}.bin`); + await page.route(`**/media/${DOC_SHA}.bin`, (route) => + route.fulfill({ + body: Buffer.alloc(2 * 1024 * 1024 + 1, 0x61), + contentType: "application/octet-stream", + }), + ); + await sendMarkdownAttachment(page); + + const card = page.getByTestId("file-card").last(); + await expect(card).toHaveAttribute("aria-label", "Open release-notes.md"); + await card.click(); + + const panel = page.getByTestId("markdown-doc-panel"); + await expect(panel).toBeVisible(); + await expect(panel).toContainText("This file is too large to preview."); + await expect( + panel.getByRole("button", { name: "Download file" }), + ).toBeVisible(); +}); + +test("non-markdown attachments keep the download-card behavior", async ({ + page, +}) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + + // Re-point the mock upload at a PDF: same flow, no viewer affordance. + await page.evaluate(() => { + const e2e = ( + window as Window & { + __BUZZ_E2E__?: { + mock?: { uploadDescriptors?: Array> }; + }; + } + ).__BUZZ_E2E__; + if (e2e?.mock) { + e2e.mock.uploadDescriptors = [ + { + url: `http://localhost:3000/media/${"c".repeat(64)}.pdf`, + sha256: "c".repeat(64), + size: 128, + type: "application/pdf", + uploaded: 1_700_000_000, + filename: "report.pdf", + }, + ]; + } + }); + + const [chooser] = await Promise.all([ + page.waitForEvent("filechooser"), + page.getByRole("button", { name: "Attach file" }).click(), + ]); + await chooser.setFiles({ + buffer: Buffer.from("pdf bytes"), + mimeType: "application/pdf", + name: "report.pdf", + }); + await expect(page.getByTestId("message-composer")).toContainText( + "report.pdf", + ); + await page.getByTestId("send-message").click(); + await expect(page.getByText("Sending")).toHaveCount(0); + + const card = page.getByTestId("file-card").last(); + await expect(card).toContainText("report.pdf"); + await expect(card).toHaveAttribute("aria-label", "Download report.pdf"); + await card.click(); + await expect + .poll(() => + page.evaluate( + () => + (window as Window & { __BUZZ_E2E_COMMANDS__?: string[] }) + .__BUZZ_E2E_COMMANDS__ ?? [], + ), + ) + .toContain("download_file"); + await expect(page.getByTestId("markdown-doc-panel")).toHaveCount(0); +});