From c76a76b5c1600e98e6c06e38fcd69f73fbdfe03b Mon Sep 17 00:00:00 2001 From: Qinghao Huang Date: Fri, 4 Sep 2026 00:27:17 +0800 Subject: [PATCH 1/8] feat(desktop): capture web sources as Markdown --- web/src-tauri/Cargo.lock | 154 ++++++++++++++++++++++++- web/src-tauri/Cargo.toml | 3 +- web/src-tauri/src/lib.rs | 12 +- web/src-tauri/src/local_engine.rs | 123 +++++++++++++++++--- web/src-tauri/src/web_source.rs | 115 ++++++++++++++++++ web/src/components/AddSourceDialog.tsx | 2 +- web/src/lib/page-frontmatter.ts | 27 +++++ web/src/pages/MainLayout.tsx | 20 +++- web/tests/page-frontmatter.test.ts | 26 ++++- 9 files changed, 452 insertions(+), 30 deletions(-) create mode 100644 web/src-tauri/src/web_source.rs diff --git a/web/src-tauri/Cargo.lock b/web/src-tauri/Cargo.lock index 976d5658..86857b0a 100644 --- a/web/src-tauri/Cargo.lock +++ b/web/src-tauri/Cargo.lock @@ -715,6 +715,7 @@ dependencies = [ "calamine", "docx-rs", "git2", + "htmlmd-core", "libc", "pdf-extract", "percent-encoding", @@ -804,7 +805,20 @@ version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" dependencies = [ - "cssparser-macros", + "cssparser-macros 0.6.1", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "cssparser-macros 0.7.0", "dtoa-short", "itoa", "phf", @@ -821,6 +835,16 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "cssparser-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a2a99df6e410a8ff4245aa2006499ea662245f967cc7c0a38c83ef8eb44dbf" +dependencies = [ + "quote", + "syn 2.0.118", +] + [[package]] name = "ctor" version = "0.8.0" @@ -1028,11 +1052,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", - "cssparser", + "cssparser 0.36.0", "foldhash", - "html5ever", + "html5ever 0.38.0", "precomputed-hash", - "selectors", + "selectors 0.36.1", "tendril", ] @@ -1102,6 +1126,12 @@ dependencies = [ "cipher", ] +[[package]] +name = "ego-tree" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b04dc5a38e4f151a79d9f2451ae6037fb6eaf5cba34771f44781f80e508498e3" + [[package]] name = "embed-resource" version = "3.0.9" @@ -1820,6 +1850,17 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "htmd" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5a1c7113c831fec68cbd79cd8bf281a84e5b6943f51473dc266b0b88a6a017e" +dependencies = [ + "html5ever 0.38.0", + "markup5ever_rcdom", + "phf", +] + [[package]] name = "html5ever" version = "0.38.0" @@ -1827,7 +1868,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" dependencies = [ "log", - "markup5ever", + "markup5ever 0.38.0", +] + +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever 0.39.0", +] + +[[package]] +name = "htmlmd-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd5b4fe9c07b7def9969a02f11c070cb8a16b7b9adf3b605bc84fa5f036e05ab" +dependencies = [ + "ego-tree", + "htmd", + "html5ever 0.38.0", + "indexmap 2.14.0", + "log", + "markup5ever 0.39.0", + "markup5ever_rcdom", + "once_cell", + "regex", + "scraper", + "serde", + "serde_json", + "thiserror 2.0.18", + "unicode-normalization", + "url", ] [[package]] @@ -2450,6 +2524,29 @@ dependencies = [ "web_atoms", ] +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "markup5ever_rcdom" +version = "0.38.0+unofficial" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333171ccdf66e915257740d44e38ea5b1b19ce7b45d33cc35cb6f118fbd981ff" +dependencies = [ + "html5ever 0.38.0", + "markup5ever 0.38.0", + "tendril", + "xml5ever", +] + [[package]] name = "md-5" version = "0.10.6" @@ -3748,6 +3845,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scraper" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd0be4d296f048bfb06dd01bbc80ef789ddd2e55583e8d2e6b804942abfabc2" +dependencies = [ + "cssparser 0.37.0", + "ego-tree", + "getopts", + "html5ever 0.39.0", + "precomputed-hash", + "selectors 0.38.0", + "tendril", +] + [[package]] name = "selectors" version = "0.36.1" @@ -3755,7 +3867,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ "bitflags 2.13.0", - "cssparser", + "cssparser 0.36.0", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" +dependencies = [ + "bitflags 2.13.0", + "cssparser 0.37.0", "derive_more", "log", "new_debug_unreachable", @@ -4117,6 +4248,7 @@ dependencies = [ "parking_lot", "phf_shared", "precomputed-hash", + "serde", ] [[package]] @@ -5887,6 +6019,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xml5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dc9559429edf0cd3f327cc0afd9d6b36fa8cec6d93107b7fbe64f806b5f2d9" +dependencies = [ + "log", + "markup5ever 0.38.0", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/web/src-tauri/Cargo.toml b/web/src-tauri/Cargo.toml index ac62eacb..09deb933 100644 --- a/web/src-tauri/Cargo.toml +++ b/web/src-tauri/Cargo.toml @@ -20,8 +20,9 @@ serde_json = "1" serde_yaml = "0.9" sha2 = "0.10" tauri = { version = "2", features = [] } -time = "=0.3.44" +time = { version = "=0.3.44", features = ["formatting"] } url = "2" +htmlmd-core = "0.1.2" git2 = "0.20" libc = "0.2" portable-pty = "0.9" diff --git a/web/src-tauri/src/lib.rs b/web/src-tauri/src/lib.rs index 3aaaa305..df28abb7 100644 --- a/web/src-tauri/src/lib.rs +++ b/web/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod local_engine; mod mcp; mod okf; mod terminal; +mod web_source; use local_engine::{ AgentChange, BrokenLink, Checkpoint, FileDiff, IngestFileOutcome, LocalEngine, PageFull, @@ -145,15 +146,20 @@ fn local_get_source( } #[tauri::command] -fn local_ingest( +async fn local_ingest( engine: State<'_, LocalEngine>, space_slug: String, source_type: String, content: String, filename: Option, ) -> Result { - let _mutation = engine.lock_mutations()?; - engine.ingest(&space_slug, &source_type, &content, filename.as_deref()) + let engine = engine.inner().clone(); + tauri::async_runtime::spawn_blocking(move || { + let _mutation = engine.lock_mutations()?; + engine.ingest(&space_slug, &source_type, &content, filename.as_deref()) + }) + .await + .map_err(|error| format!("local source import task failed: {error}"))? } #[tauri::command] diff --git a/web/src-tauri/src/local_engine.rs b/web/src-tauri/src/local_engine.rs index febbeeb8..9ac44003 100644 --- a/web/src-tauri/src/local_engine.rs +++ b/web/src-tauri/src/local_engine.rs @@ -15,6 +15,7 @@ use uuid::Uuid; use crate::knowledge_index::{self, SearchHit}; use crate::okf::{self, DocumentKind}; +use crate::web_source::{self, WebSourceSnapshot}; mod agent_changes; pub use crate::knowledge_index::BrokenLink; @@ -623,20 +624,26 @@ impl LocalEngine { ) -> Result { let space = self.find_space(space_slug)?; okf::ensure_supported_for_write(&space.local_path)?; - let fallback = match source_type { - "url" => url::Url::parse(content.trim()) - .ok() - .and_then(|value| value.host_str().map(ToOwned::to_owned)) - .unwrap_or_else(|| "web-source".to_string()), - _ => "source".to_string(), - }; + if source_type == "url" { + let snapshot = web_source::fetch_markdown_snapshot(content)?; + let content_hash = sha256_bytes(snapshot.markdown.as_bytes()); + let requested = filename.unwrap_or(&snapshot.title); + let source = self.write_source_document( + &space, + requested, + &snapshot.title, + &snapshot.markdown, + Some(&content_hash), + Some(&snapshot), + )?; + self.refresh_source_views(&space)?; + return Ok(source); + } + let fallback = "source".to_string(); let requested = filename.unwrap_or(&fallback); - let title = if source_type == "url" { - content.trim() - } else { - requested.trim() - }; - let source = self.write_source_document(&space, requested, title, content.trim(), None)?; + let title = requested.trim(); + let source = + self.write_source_document(&space, requested, title, content.trim(), None, None)?; self.refresh_source_views(&space)?; Ok(source) } @@ -695,7 +702,14 @@ impl LocalEngine { .file_stem() .and_then(|stem| stem.to_str()) .unwrap_or(original_name); - self.write_source_document(space, original_name, title, &text, Some(&content_hash)) + self.write_source_document( + space, + original_name, + title, + &text, + Some(&content_hash), + None, + ) } /// Shared write path for every ingest flavor: pasted text, a URL @@ -718,6 +732,7 @@ impl LocalEngine { title: &str, body_text: &str, content_hash: Option<&str>, + web_snapshot: Option<&WebSourceSnapshot>, ) -> Result { let relative = okf::source_storage_path(requested_filename)?; let mut candidate = checked_space_path(&space.local_path, &relative)?; @@ -767,7 +782,17 @@ impl LocalEngine { } let mut frontmatter = format!("title: {}\ntype: Source\n", yaml_string(title)); - if let Some(hash) = content_hash { + if let Some(snapshot) = web_snapshot { + frontmatter.push_str(&format!( + "source_url: {}\ncaptured_at: {}\nextractor: {}\n", + yaml_string(&snapshot.source_url), + yaml_string(&snapshot.captured_at), + yaml_string(web_source::EXTRACTOR_NAME), + )); + if let Some(hash) = content_hash { + frontmatter.push_str(&format!("content_hash: {}\n", yaml_string(hash))); + } + } else if let Some(hash) = content_hash { frontmatter.push_str(&format!("source_hash: {}\n", yaml_string(hash))); } let body = format!("---\n{frontmatter}---\n\n{}\n", body_text.trim()); @@ -2097,7 +2122,10 @@ fn sha256_file(path: &Path) -> Result { fn source_hash_matches(path: &Path, expected_hash: &str) -> bool { std::fs::read_to_string(path) .ok() - .and_then(|existing| frontmatter_field(&existing, "source_hash")) + .and_then(|existing| { + frontmatter_field(&existing, "source_hash") + .or_else(|| frontmatter_field(&existing, "content_hash")) + }) .is_some_and(|existing_hash| existing_hash == expected_hash) } @@ -2282,7 +2310,10 @@ pub(crate) fn markdown_title(body: &str) -> Option { mod tests { use super::{sha256_file, CloudLink, LocalEngine}; use git2::Repository; + use std::io::{Read, Write}; + use std::net::TcpListener; use std::sync::{mpsc, Arc, Barrier}; + use std::thread; use std::time::Duration; #[test] @@ -4169,6 +4200,66 @@ mod tests { assert!(!engine.has_uncommitted_changes(&space.slug).unwrap()); } + #[test] + fn url_ingest_captures_a_markdown_snapshot_with_provenance() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let html = r#" + + CoWiki & Local AI + + +
+

Knowledge stays yours

+

Agents can build on portable knowledge.

+
+ + +"#; + let html_bytes = html.as_bytes().to_vec(); + let server = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = [0_u8; 2048]; + let _ = stream.read(&mut request).unwrap(); + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + html_bytes.len() + ) + .unwrap(); + stream.write_all(&html_bytes).unwrap(); + }); + + let temp = tempfile::tempdir().unwrap(); + let engine = LocalEngine::open(&temp.path().join("metadata")).unwrap(); + let folder = temp.path().join("knowledge"); + std::fs::create_dir_all(&folder).unwrap(); + let space = engine.add_space("Knowledge", "knowledge", &folder).unwrap(); + let source_url = format!("http://{address}/article"); + + let item = engine + .ingest(&space.slug, "url", &source_url, None) + .unwrap(); + assert_eq!(item.title, "CoWiki & Local AI"); + server.join().unwrap(); + let source = engine.get_source(&space.slug, &item.filename).unwrap(); + assert!(source + .content + .contains(&format!("source_url: \"{source_url}\""))); + assert!(source.content.contains("captured_at:")); + assert!(source + .content + .contains("extractor: \"cowiki-html-to-markdown-v1\"")); + assert!(source.content.contains("content_hash:")); + assert!(source.content.contains("# Knowledge stays yours")); + assert!(source.content.contains(&format!( + "[portable knowledge](http://{address}/principles)" + ))); + assert!(!source.content.contains("Site navigation")); + assert!(!source.content.contains("must not be stored")); + assert!(!folder.join(".cowiki/sources").join("article.html").exists()); + } + #[test] fn reimporting_same_content_reuses_source_and_different_content_stays_separate() { let temp = tempfile::tempdir().unwrap(); diff --git a/web/src-tauri/src/web_source.rs b/web/src-tauri/src/web_source.rs new file mode 100644 index 00000000..44ecd599 --- /dev/null +++ b/web/src-tauri/src/web_source.rs @@ -0,0 +1,115 @@ +use htmlmd_core::{convert, ConversionOptions}; +use std::io::Read; +use std::time::Duration; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +use url::Url; + +pub const EXTRACTOR_NAME: &str = "cowiki-html-to-markdown-v1"; +const MAX_HTML_BYTES: u64 = 5 * 1024 * 1024; +const MAX_MARKDOWN_BYTES: u64 = 2 * 1024 * 1024; + +pub struct WebSourceSnapshot { + pub title: String, + pub source_url: String, + pub captured_at: String, + pub markdown: String, +} + +pub fn fetch_markdown_snapshot(value: &str) -> Result { + let requested_url = validate_url(value)?; + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(20)) + .user_agent("CoWiki/0.1 web-source-ingest") + .build() + .map_err(|error| format!("cannot prepare web request: {error}"))?; + let response = client + .get(requested_url) + .send() + .map_err(|error| format!("cannot fetch web source: {error}"))?; + if !response.status().is_success() { + return Err(format!( + "web source returned HTTP {}", + response.status().as_u16() + )); + } + if response + .content_length() + .is_some_and(|length| length > MAX_HTML_BYTES) + { + return Err(format!( + "web source is too large (maximum {MAX_HTML_BYTES} bytes)" + )); + } + if let Some(content_type) = response.headers().get(reqwest::header::CONTENT_TYPE) { + let content_type = content_type + .to_str() + .unwrap_or_default() + .to_ascii_lowercase(); + if !content_type.starts_with("text/html") + && !content_type.starts_with("application/xhtml+xml") + { + return Err("URL does not point to an HTML page".to_string()); + } + } + + let final_url = response.url().clone(); + let mut html = Vec::new(); + response + .take(MAX_HTML_BYTES + 1) + .read_to_end(&mut html) + .map_err(|error| format!("cannot read web source: {error}"))?; + if html.len() as u64 > MAX_HTML_BYTES { + return Err(format!( + "web source is too large (maximum {MAX_HTML_BYTES} bytes)" + )); + } + + let html = String::from_utf8_lossy(&html); + let mut options = ConversionOptions::gfm(); + options.cleanup.metadata.title = true; + options.cleanup.base_url = Some(final_url.to_string()); + options.cleanup.main_content_selector = Some("article, main, [role=main]".to_string()); + options.cleanup.remove_tags.extend( + ["nav", "header", "footer", "aside"] + .into_iter() + .map(str::to_string), + ); + options.limits.max_input_bytes = MAX_HTML_BYTES; + options.limits.max_output_bytes = MAX_MARKDOWN_BYTES; + options.limits.max_node_count = 200_000; + options.limits.max_attribute_len = 100_000; + options.strict = true; + + let converted = convert(&html, &options) + .map_err(|error| format!("cannot convert web source to Markdown: {error}"))?; + let markdown = converted.markdown.trim().to_string(); + if markdown.is_empty() { + return Err("web source contains no readable content".to_string()); + } + let title = converted + .title + .filter(|title| !title.trim().is_empty()) + .unwrap_or_else(|| final_url.host_str().unwrap_or("Web source").to_string()); + let captured_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .map_err(|error| format!("cannot format capture time: {error}"))?; + + Ok(WebSourceSnapshot { + title, + source_url: final_url.to_string(), + captured_at, + markdown, + }) +} + +fn validate_url(value: &str) -> Result { + let url = Url::parse(value.trim()).map_err(|_| "Enter a valid HTTP(S) URL".to_string())?; + if !matches!(url.scheme(), "http" | "https") + || url.host().is_none() + || !url.username().is_empty() + || url.password().is_some() + { + return Err("Enter a valid HTTP(S) URL without embedded credentials".to_string()); + } + Ok(url) +} diff --git a/web/src/components/AddSourceDialog.tsx b/web/src/components/AddSourceDialog.tsx index 68ec1024..bea0736a 100644 --- a/web/src/components/AddSourceDialog.tsx +++ b/web/src/components/AddSourceDialog.tsx @@ -234,7 +234,7 @@ export function AddSourceDialog({ placeholder="https://example.com/article" />

- The URL is kept as an OKF source. Codex or Claude can read it and organize durable knowledge. + The page is captured as Markdown with its URL and capture time. Codex or Claude can then organize durable knowledge.

diff --git a/web/src/lib/page-frontmatter.ts b/web/src/lib/page-frontmatter.ts index 6908d9b2..2b93bbfc 100644 --- a/web/src/lib/page-frontmatter.ts +++ b/web/src/lib/page-frontmatter.ts @@ -16,3 +16,30 @@ export function splitSystemFrontmatter(document: string): EditablePage { export function restoreSystemFrontmatter(systemFrontmatter: string, body: string): string { return `${systemFrontmatter}${body}`; } + +/** Return the safe HTTP(S) origin link recorded for a captured web Source. */ +export function sourceUrlFromDocument(document: string): string | null { + const { systemFrontmatter } = splitSystemFrontmatter(document); + const line = systemFrontmatter + .split(/\r?\n/) + .find((candidate) => candidate.startsWith('source_url:')); + if (!line) return null; + + const encoded = line.slice('source_url:'.length).trim(); + let value = encoded.replace(/^['"]|['"]$/g, ''); + if (encoded.startsWith('"') && encoded.endsWith('"')) { + try { + value = JSON.parse(encoded) as string; + } catch { + return null; + } + } + + try { + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null; + return url.toString(); + } catch { + return null; + } +} diff --git a/web/src/pages/MainLayout.tsx b/web/src/pages/MainLayout.tsx index 926565b8..a90da3c3 100644 --- a/web/src/pages/MainLayout.tsx +++ b/web/src/pages/MainLayout.tsx @@ -3,6 +3,7 @@ import { useNavigate, useLocation } from 'react-router-dom'; import { Compass, Pencil, FolderOpen, PanelLeft, Bot, HardDrive, FolderInput, Cloud, UserPlus, ChevronLeft, + ExternalLink, } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, @@ -79,9 +80,10 @@ import { saveClientSettings, type DefaultAgent, } from '@/lib/client-settings'; -import { splitSystemFrontmatter } from '@/lib/page-frontmatter'; +import { sourceUrlFromDocument, splitSystemFrontmatter } from '@/lib/page-frontmatter'; import { sourceOrganizationTask } from '@/lib/source-ingest'; import { resolveWorkspaceSwitchTarget } from '@/lib/workspace-navigation'; +import { openExternalUrl } from '@/external-links'; type ActiveView = | { kind: 'page'; slug: string; path?: string; content: PageFull | null } @@ -867,6 +869,9 @@ export function MainLayout() { // Determine active page/source for panel highlight const currentActivePage = activeView?.kind === 'page' ? activeView.slug : null; const currentActiveSource = activeView?.kind === 'source' ? activeView.filename : null; + const activeSourceUrl = activeView?.kind === 'source' && activeView.content + ? sourceUrlFromDocument(activeView.content.content) + : null; const selectedAgentChange = versionSelection.kind === 'agent' ? openAgentChanges.find((change) => change.id === versionSelection.changeId) : undefined; @@ -1282,13 +1287,24 @@ export function MainLayout() { /* Source view */ ) : activeView?.kind === 'source' && activeView.content ? (
-
+
Source File + {activeSourceUrl && ( + + )}

diff --git a/web/tests/page-frontmatter.test.ts b/web/tests/page-frontmatter.test.ts index 9ab312b0..e3996b99 100644 --- a/web/tests/page-frontmatter.test.ts +++ b/web/tests/page-frontmatter.test.ts @@ -1,7 +1,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { restoreSystemFrontmatter, splitSystemFrontmatter } from '../src/lib/page-frontmatter.ts'; +import { + restoreSystemFrontmatter, + sourceUrlFromDocument, + splitSystemFrontmatter, +} from '../src/lib/page-frontmatter.ts'; test('system frontmatter is hidden from the editable document and restored exactly', () => { const original = '---\ntype: Note\ntitle: "Test"\nsummary: ""\n---\n\nEditable text\n- [ ] Task'; @@ -22,3 +26,23 @@ test('read-only Source rendering hides OKF frontmatter', () => { assert.equal(source.body, 'https://example.com'); }); + +test('a captured web Source exposes its original URL without leaking other metadata', () => { + const source = [ + '---', + 'title: "Example: \\"quoted\\""', + 'type: Source', + 'source_url: "https://example.com/article?q=local%20first"', + 'captured_at: "2026-09-04T12:00:00Z"', + '---', + '', + '# Article', + ].join('\n'); + + assert.equal(sourceUrlFromDocument(source), 'https://example.com/article?q=local%20first'); + assert.equal(sourceUrlFromDocument('---\ntype: Source\n---\n\nPlain source'), null); + assert.equal( + sourceUrlFromDocument('---\nsource_url: "javascript:alert(1)"\n---\n'), + null, + ); +}); From 796a78acc0673d16f8b314833a6377bd7abd8a75 Mon Sep 17 00:00:00 2001 From: Qinghao Huang Date: Fri, 4 Sep 2026 00:54:54 +0800 Subject: [PATCH 2/8] feat(review): add rendered Markdown preview --- web/package.json | 3 +- web/src/cloud/client.ts | 2 + web/src/cloud/cloud-review-model.ts | 4 +- web/src/components/review/DiffView.tsx | 99 +++++++++++++++++++++++++- web/src/lib/review-preview.ts | 35 +++++++++ web/tests/cloud-review-model.test.ts | 24 +++++-- web/tests/review-preview.test.ts | 46 ++++++++++++ 7 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 web/src/lib/review-preview.ts create mode 100644 web/tests/review-preview.test.ts diff --git a/web/package.json b/web/package.json index 854ab453..3684c070 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,7 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "test": "npm run test:auth-flow && npm run test:versioned-document && npm run test:local-space && npm run test:sidebar-layout && npm run test:client-settings && npm run test:design-system && npm run test:page-frontmatter && npm run test:agent-terminal && npm run test:agent-contract && npm run test:okf-pages && npm run test:workspace-navigation && npm run test:window-chrome && npm run test:page-header && npm run test:source-ingest && npm run test:desktop-workflow && npm run test:history && npm run test:link-diagnostics && npm run test:cloud-sync && npm run test:cloud-client && npm run test:cloud-shell && npm run test:cloud-review-model && npm run test:skill-hosting && npm run test:cloud-space-dialog", + "test": "npm run test:auth-flow && npm run test:versioned-document && npm run test:local-space && npm run test:sidebar-layout && npm run test:client-settings && npm run test:design-system && npm run test:page-frontmatter && npm run test:agent-terminal && npm run test:agent-contract && npm run test:okf-pages && npm run test:workspace-navigation && npm run test:window-chrome && npm run test:page-header && npm run test:source-ingest && npm run test:desktop-workflow && npm run test:history && npm run test:link-diagnostics && npm run test:cloud-sync && npm run test:cloud-client && npm run test:cloud-shell && npm run test:cloud-review-model && npm run test:review-preview && npm run test:skill-hosting && npm run test:cloud-space-dialog", "test:auth-flow": "node --experimental-strip-types --test tests/auth-flow.test.ts", "test:versioned-document": "node --experimental-strip-types --test tests/versioned-document.test.ts", "test:local-space": "node --experimental-strip-types --test tests/local-space.test.ts", @@ -30,6 +30,7 @@ "test:cloud-client": "node --experimental-strip-types --test tests/cloud-client.test.ts", "test:cloud-shell": "node --experimental-strip-types --test tests/cloud-shell.test.ts", "test:cloud-review-model": "node --experimental-strip-types --test tests/cloud-review-model.test.ts", + "test:review-preview": "node --experimental-strip-types --test tests/review-preview.test.ts", "test:skill-hosting": "node --experimental-strip-types --test tests/skill-hosting.test.ts", "test:cloud-space-dialog": "node --experimental-strip-types --test tests/cloud-space-dialog.test.ts", "tauri": "tauri", diff --git a/web/src/cloud/client.ts b/web/src/cloud/client.ts index ac11c388..908e400a 100644 --- a/web/src/cloud/client.ts +++ b/web/src/cloud/client.ts @@ -123,6 +123,8 @@ export interface CloudPullRequestDiff { status: string; additions: number; deletions: number; + oldContent?: string | null; + newContent?: string | null; }>; patch: string; } diff --git a/web/src/cloud/cloud-review-model.ts b/web/src/cloud/cloud-review-model.ts index 22be3da8..1b158ee4 100644 --- a/web/src/cloud/cloud-review-model.ts +++ b/web/src/cloud/cloud-review-model.ts @@ -13,8 +13,8 @@ export function cloudDiffToFileDiffs(diff: CloudPullRequestDiff): FileDiff[] { const [oldContent, newContent] = contentPresence(file.status); return { path: file.path, - old_content: oldContent, - new_content: newContent, + old_content: file.oldContent === undefined ? oldContent : file.oldContent, + new_content: file.newContent === undefined ? newContent : file.newContent, hunks: parsed?.hunks.map(toDiffHunk) ?? [], additions: file.additions, deletions: file.deletions, diff --git a/web/src/components/review/DiffView.tsx b/web/src/components/review/DiffView.tsx index f277a507..b7871b12 100644 --- a/web/src/components/review/DiffView.tsx +++ b/web/src/components/review/DiffView.tsx @@ -1,7 +1,10 @@ import { useState, useCallback } from 'react'; import { MessageSquare, Check, ChevronDown, ChevronRight, Send, X, FileText, Pencil } from 'lucide-react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; import type { FileDiff, DiffHunk, DiffLine, ReviewComment } from '../../api'; import { C, fonts } from '@/lib/design'; +import { reviewPreviewPanes, type ReviewPreviewPane } from '@/lib/review-preview'; import { timeAgo } from '../../lib/time'; const mono = fonts.mono; @@ -282,6 +285,7 @@ function renderLineText(text: string): React.ReactNode { /* ── FileDiffCard ── */ function FileDiffCard({ file, + mode, comments, onAddComment, onResolve, @@ -290,6 +294,7 @@ function FileDiffCard({ onEditSave, }: { file: FileDiff; + mode: 'source' | 'rendered'; comments: ReviewComment[]; onAddComment?: (filePath: string, lineNumber: number, body: string) => void; onResolve?: (id: string) => void; @@ -306,6 +311,7 @@ function FileDiffCard({ const status = fileStatus(file); const sc = statusColors[status]; const fileComments = comments.filter((c) => c.file_path === file.path); + const previewPanes = reviewPreviewPanes(file); const handleSubmitComment = useCallback((body: string, lineNumber: number) => { onAddComment?.(file.path, lineNumber, body); @@ -422,7 +428,15 @@ function FileDiffCard({ )} {/* Diff body */} {!collapsed && ( -
+ mode === 'rendered' ? ( + previewPanes.length ? ( + + ) : ( +
+ Rendered preview is available for text Markdown files. +
+ ) + ) :
@@ -462,6 +476,44 @@ function FileDiffCard({ ); } +function RenderedPreview({ panes }: { panes: ReviewPreviewPane[] }) { + return ( +
+ {panes.map((pane, index) => ( +
0 ? `1px solid ${C.line}` : undefined, + background: pane.key === 'before' ? C.redBgSoft : C.greenBgSoft, + }} + > +
+ {pane.label} +
+
+ {pane.content} +
+
+ ))} +
+ ); +} + /* ── Exports ── */ export function DiffView({ diffs, @@ -480,16 +532,30 @@ export function DiffView({ onReply?: (body: string, parentId: string) => void; onEditSave?: (path: string, content: string) => Promise; }) { + const [mode, setMode] = useState<'source' | 'rendered'>('source'); if (!diffs.length) { return

No file changes.

; } return (
+
+ setMode('source')}> + Source diff + + setMode('rendered')}> + Rendered preview + +
{diffs.map((f) => ( void; +}) { + return ( + + ); +} + export default DiffView; diff --git a/web/src/lib/review-preview.ts b/web/src/lib/review-preview.ts new file mode 100644 index 00000000..aa7406a8 --- /dev/null +++ b/web/src/lib/review-preview.ts @@ -0,0 +1,35 @@ +export interface ReviewPreviewInput { + path: string; + old_content: string | null; + new_content: string | null; + is_binary?: boolean; +} + +export interface ReviewPreviewPane { + key: 'before' | 'after'; + label: string; + content: string; +} + +export function reviewPreviewPanes(file: ReviewPreviewInput): ReviewPreviewPane[] { + if (file.is_binary || !file.path.toLowerCase().endsWith('.md')) return []; + + if (file.old_content == null && file.new_content != null) { + return [{ key: 'after', label: 'Added', content: markdownBody(file.new_content) }]; + } + if (file.old_content != null && file.new_content == null) { + return [{ key: 'before', label: 'Deleted', content: markdownBody(file.old_content) }]; + } + if (file.old_content != null && file.new_content != null) { + return [ + { key: 'before', label: 'Before', content: markdownBody(file.old_content) }, + { key: 'after', label: 'After', content: markdownBody(file.new_content) }, + ]; + } + return []; +} + +function markdownBody(content: string): string { + return splitSystemFrontmatter(content).body; +} +import { splitSystemFrontmatter } from './page-frontmatter.ts'; diff --git a/web/tests/cloud-review-model.test.ts b/web/tests/cloud-review-model.test.ts index f1e1af40..3b2a8987 100644 --- a/web/tests/cloud-review-model.test.ts +++ b/web/tests/cloud-review-model.test.ts @@ -8,8 +8,22 @@ test('Cloud unified patches adapt to the shared client DiffView model', () => { baseOid: 'base', headOid: 'head', files: [ - { path: 'index.md', status: 'modified', additions: 2, deletions: 1 }, - { path: 'new.md', status: 'added', additions: 1, deletions: 0 }, + { + path: 'index.md', + status: 'modified', + additions: 2, + deletions: 1, + oldContent: '# Title\n\nOld\n', + newContent: '# Title\n\nNew\nMore\n', + }, + { + path: 'new.md', + status: 'added', + additions: 1, + deletions: 0, + oldContent: null, + newContent: 'Hello\n', + }, ], patch: [ 'diff --git a/index.md b/index.md', @@ -39,8 +53,8 @@ test('Cloud unified patches adapt to the shared client DiffView model', () => { { kind: 'add', old_line: null, new_line: 2, text: 'New' }, { kind: 'add', old_line: null, new_line: 3, text: 'More' }, ]); - assert.equal(diffs[0]?.old_content, ''); - assert.equal(diffs[0]?.new_content, ''); + assert.equal(diffs[0]?.old_content, '# Title\n\nOld\n'); + assert.equal(diffs[0]?.new_content, '# Title\n\nNew\nMore\n'); assert.equal(diffs[1]?.old_content, null); - assert.equal(diffs[1]?.new_content, ''); + assert.equal(diffs[1]?.new_content, 'Hello\n'); }); diff --git a/web/tests/review-preview.test.ts b/web/tests/review-preview.test.ts new file mode 100644 index 00000000..1d95441e --- /dev/null +++ b/web/tests/review-preview.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { reviewPreviewPanes } from '../src/lib/review-preview.ts'; + +test('rendered Review preview compares complete Markdown before and after content', () => { + assert.deepEqual(reviewPreviewPanes({ + path: 'wiki/topic.md', + old_content: '---\ntitle: Old\n---\n\n# Old\n', + new_content: '---\ntitle: New\n---\n\n# New\n', + }), [ + { key: 'before', label: 'Before', content: '# Old\n' }, + { key: 'after', label: 'After', content: '# New\n' }, + ]); +}); + +test('rendered Review preview uses one pane for added and deleted Markdown', () => { + assert.deepEqual(reviewPreviewPanes({ + path: 'wiki/added.md', + old_content: null, + new_content: '# Added\n', + }), [ + { key: 'after', label: 'Added', content: '# Added\n' }, + ]); + assert.deepEqual(reviewPreviewPanes({ + path: 'wiki/deleted.md', + old_content: '# Deleted\n', + new_content: null, + }), [ + { key: 'before', label: 'Deleted', content: '# Deleted\n' }, + ]); +}); + +test('rendered Review preview is unavailable for non-Markdown or binary changes', () => { + assert.deepEqual(reviewPreviewPanes({ + path: 'diagram.png', + old_content: null, + new_content: null, + is_binary: true, + }), []); + assert.deepEqual(reviewPreviewPanes({ + path: 'report.html', + old_content: '

Before

', + new_content: '

After

', + }), []); +}); From 5384cdb0b0dc2fe446c0cd1cefbf824a1f22e53b Mon Sep 17 00:00:00 2001 From: Qinghao Huang Date: Fri, 4 Sep 2026 01:21:42 +0800 Subject: [PATCH 3/8] feat(lineage): trace knowledge to Sources and Agents --- web/package.json | 3 +- web/src-tauri/src/lib.rs | 7 +- web/src-tauri/src/local_engine.rs | 277 +++++++++++++++++- .../src/local_engine/agent_changes.rs | 117 +++++++- web/src/api.ts | 7 +- web/src/cloud/CloudWikiView.tsx | 32 +- web/src/cloud/PublicCloudSpacePage.tsx | 6 +- web/src/cloud/client.ts | 7 + web/src/components/PageLineage.tsx | 96 ++++++ web/src/components/PageReader.tsx | 15 + web/src/components/SourcePreviewDialog.tsx | 55 ++++ .../terminal/AgentTerminalPanel.tsx | 6 +- web/src/lib/page-lineage.ts | 100 +++++++ web/src/lib/source-ingest.ts | 10 +- web/src/local-api.ts | 4 +- web/src/pages/MainLayout.tsx | 8 + web/tests/cloud-client.test.ts | 18 ++ web/tests/page-lineage.test.ts | 62 ++++ web/tests/source-ingest.test.ts | 5 +- 19 files changed, 804 insertions(+), 31 deletions(-) create mode 100644 web/src/components/PageLineage.tsx create mode 100644 web/src/components/SourcePreviewDialog.tsx create mode 100644 web/src/lib/page-lineage.ts create mode 100644 web/tests/page-lineage.test.ts diff --git a/web/package.json b/web/package.json index 3684c070..47ceedc5 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,7 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "test": "npm run test:auth-flow && npm run test:versioned-document && npm run test:local-space && npm run test:sidebar-layout && npm run test:client-settings && npm run test:design-system && npm run test:page-frontmatter && npm run test:agent-terminal && npm run test:agent-contract && npm run test:okf-pages && npm run test:workspace-navigation && npm run test:window-chrome && npm run test:page-header && npm run test:source-ingest && npm run test:desktop-workflow && npm run test:history && npm run test:link-diagnostics && npm run test:cloud-sync && npm run test:cloud-client && npm run test:cloud-shell && npm run test:cloud-review-model && npm run test:review-preview && npm run test:skill-hosting && npm run test:cloud-space-dialog", + "test": "npm run test:auth-flow && npm run test:versioned-document && npm run test:local-space && npm run test:sidebar-layout && npm run test:client-settings && npm run test:design-system && npm run test:page-frontmatter && npm run test:page-lineage && npm run test:agent-terminal && npm run test:agent-contract && npm run test:okf-pages && npm run test:workspace-navigation && npm run test:window-chrome && npm run test:page-header && npm run test:source-ingest && npm run test:desktop-workflow && npm run test:history && npm run test:link-diagnostics && npm run test:cloud-sync && npm run test:cloud-client && npm run test:cloud-shell && npm run test:cloud-review-model && npm run test:review-preview && npm run test:skill-hosting && npm run test:cloud-space-dialog", "test:auth-flow": "node --experimental-strip-types --test tests/auth-flow.test.ts", "test:versioned-document": "node --experimental-strip-types --test tests/versioned-document.test.ts", "test:local-space": "node --experimental-strip-types --test tests/local-space.test.ts", @@ -16,6 +16,7 @@ "test:client-settings": "node --experimental-strip-types --test tests/client-settings.test.ts", "test:design-system": "node --experimental-strip-types --test tests/design-system.test.ts", "test:page-frontmatter": "node --experimental-strip-types --test tests/page-frontmatter.test.ts", + "test:page-lineage": "node --experimental-strip-types --test tests/page-lineage.test.ts", "test:agent-terminal": "node --experimental-strip-types --test tests/agent-terminal.test.ts", "test:agent-contract": "node --experimental-strip-types --test tests/agent-contract.test.ts", "test:okf-pages": "node --experimental-strip-types --test tests/okf-pages.test.ts", diff --git a/web/src-tauri/src/lib.rs b/web/src-tauri/src/lib.rs index df28abb7..8a33ca10 100644 --- a/web/src-tauri/src/lib.rs +++ b/web/src-tauri/src/lib.rs @@ -269,9 +269,14 @@ fn local_create_agent_change( engine: State<'_, LocalEngine>, space_slug: String, agent_name: String, + title: Option, ) -> Result { let _mutation = engine.lock_mutations()?; - engine.create_agent_change(&space_slug, &agent_name) + engine.create_agent_change_with_identity( + &space_slug, + title.as_deref().unwrap_or(&agent_name), + &agent_name, + ) } #[tauri::command] diff --git a/web/src-tauri/src/local_engine.rs b/web/src-tauri/src/local_engine.rs index 9ac44003..d62908fc 100644 --- a/web/src-tauri/src/local_engine.rs +++ b/web/src-tauri/src/local_engine.rs @@ -187,6 +187,31 @@ pub struct PageFull { pub body: String, pub edited_by: Option, pub edited_at: Option, + pub provenance: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PageProvenance { + pub commit: Option, + pub agents: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct CommitProvenance { + pub oid: String, + pub summary: String, + pub author: String, + pub committed_at: i64, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AgentProvenance { + pub name: String, + pub change_id: String, + pub task: String, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] @@ -872,6 +897,10 @@ impl LocalEngine { .to_string_lossy() .to_string() }); + let provenance = page_provenance( + &Repository::open(&space.local_path).map_err(|e| e.to_string())?, + &relative, + )?; Ok(PageFull { meta: PageMeta { slug: slug.to_string(), @@ -885,6 +914,7 @@ impl LocalEngine { body, edited_by: None, edited_at: None, + provenance, }) } @@ -1092,6 +1122,10 @@ impl LocalEngine { DocumentKind::Concept if normalized.starts_with(".cowiki/sources/") => "source", _ => "page", }; + let provenance = page_provenance( + &Repository::open(&space.local_path).map_err(|e| e.to_string())?, + relative, + )?; Ok(PageFull { meta: PageMeta { slug, @@ -1105,6 +1139,7 @@ impl LocalEngine { body, edited_by: None, edited_at: None, + provenance, }) } @@ -1286,17 +1321,24 @@ impl LocalEngine { let signature = Signature::now(author_name.trim(), author_email.trim()) .map_err(|e| format!("invalid commit identity: {e}"))?; let parents: Vec<&git2::Commit<'_>> = parent.iter().collect(); - if let Err(error) = repo.commit( + let agent_contributions = + agent_changes::pending_merged_agent_contributions(&repo, parent.as_ref(), &tree)?; + let commit_message = commit_message_with_agent_trailers(message, &agent_contributions); + let commit_oid = match repo.commit( Some("HEAD"), &signature, &signature, - message, + &commit_message, &tree, &parents, ) { - rollback_migration(&repo, root, &backup)?; - return Err(error.to_string()); - } + Ok(oid) => oid, + Err(error) => { + rollback_migration(&repo, root, &backup)?; + return Err(error.to_string()); + } + }; + agent_changes::mark_agent_contributions_committed(&repo, commit_oid, &agent_contributions); delete_migration_backup(&repo, &backup.reference)?; Ok(SubmitResult { committed: true }) } @@ -1483,15 +1525,20 @@ fn commit_index(repo: &Repository, index: &mut git2::Index) -> Result> = parent.iter().collect(); - repo.commit( - Some("HEAD"), - &signature, - &signature, - "Update local Space", - &tree, - &parents, - ) - .map_err(|error| error.to_string())?; + let contributions = + agent_changes::pending_merged_agent_contributions(repo, parent.as_ref(), &tree)?; + let message = commit_message_with_agent_trailers("Update local Space", &contributions); + let commit_oid = repo + .commit( + Some("HEAD"), + &signature, + &signature, + &message, + &tree, + &parents, + ) + .map_err(|error| error.to_string())?; + agent_changes::mark_agent_contributions_committed(repo, commit_oid, &contributions); Ok(SubmitResult { committed: true }) } @@ -2259,6 +2306,118 @@ fn normalize_path(path: &Path) -> String { .join("/") } +fn page_provenance(repo: &Repository, relative: &Path) -> Result, String> { + let Some(commit) = last_commit_for_path(repo, relative)? else { + return Ok(None); + }; + let message = commit.message().unwrap_or_default(); + let author = commit.author().name().unwrap_or("Unknown").to_string(); + Ok(Some(PageProvenance { + commit: Some(CommitProvenance { + oid: commit.id().to_string(), + summary: commit.summary().unwrap_or("Update Space").to_string(), + author, + committed_at: commit.time().seconds(), + }), + agents: agent_provenance_from_message(message), + })) +} + +fn last_commit_for_path<'repo>( + repo: &'repo Repository, + relative: &Path, +) -> Result>, String> { + let mut walk = repo.revwalk().map_err(|error| error.to_string())?; + if walk.push_head().is_err() { + return Ok(None); + } + walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME) + .map_err(|error| error.to_string())?; + for oid in walk { + let commit = repo + .find_commit(oid.map_err(|error| error.to_string())?) + .map_err(|error| error.to_string())?; + let current = tree_blob_oid(&commit.tree().map_err(|error| error.to_string())?, relative); + if current.is_none() { + continue; + } + let previous = if commit.parent_count() == 0 { + None + } else { + let parent = commit.parent(0).map_err(|error| error.to_string())?; + tree_blob_oid(&parent.tree().map_err(|error| error.to_string())?, relative) + }; + if current != previous { + return Ok(Some(commit)); + } + } + Ok(None) +} + +fn tree_blob_oid(tree: &git2::Tree<'_>, relative: &Path) -> Option { + tree.get_path(relative) + .ok() + .filter(|entry| entry.kind() == Some(git2::ObjectType::Blob)) + .map(|entry| entry.id()) +} + +fn commit_message_with_agent_trailers(message: &str, agents: &[AgentProvenance]) -> String { + if agents.is_empty() { + return message.to_string(); + } + let mut result = message.trim_end().to_string(); + result.push_str("\n\n"); + for agent in agents { + result.push_str(&format!( + "CoWiki-Agent: {}\nCoWiki-Agent-Change: {}\nCoWiki-Agent-Task: {}\n", + trailer_value(&agent.name), + trailer_value(&agent.change_id), + trailer_value(&agent.task), + )); + } + result +} + +fn agent_provenance_from_message(message: &str) -> Vec { + let lines = message.lines().collect::>(); + let mut result = Vec::new(); + let mut index = 0; + while index < lines.len() { + let Some(name) = lines[index].strip_prefix("CoWiki-Agent: ") else { + index += 1; + continue; + }; + let change_id = lines + .get(index + 1) + .and_then(|line| line.strip_prefix("CoWiki-Agent-Change: ")); + let task = lines + .get(index + 2) + .and_then(|line| line.strip_prefix("CoWiki-Agent-Task: ")); + if let (Some(change_id), Some(task)) = (change_id, task) { + result.push(AgentProvenance { + name: name.to_string(), + change_id: change_id.to_string(), + task: task.to_string(), + }); + index += 3; + } else { + index += 1; + } + } + result +} + +fn trailer_value(value: &str) -> String { + value + .lines() + .next() + .unwrap_or_default() + .trim() + .chars() + .take(160) + .collect() +} + /// Reads one flat `key: value` frontmatter field, unquoting it the same /// way `markdown_title` unquotes `title`. Only used to compare a Source /// document's recorded `source_hash` against a re-import's — not a @@ -3669,6 +3828,96 @@ mod tests { assert_eq!(diff.new_content.as_deref(), Some("# Background result\n")); } + #[test] + fn merged_agent_identity_and_commit_are_portable_page_provenance() { + let temp = tempfile::tempdir().unwrap(); + let engine = LocalEngine::open(&temp.path().join("metadata")).unwrap(); + let folder = temp.path().join("notes"); + std::fs::create_dir_all(&folder).unwrap(); + let space = engine.add_space("Notes", "notes", &folder).unwrap(); + let change = engine + .create_agent_change_with_identity(&space.slug, "Organize interview", "Codex") + .unwrap(); + std::fs::write( + change.worktree_path.join("interview.md"), + "---\ntype: Note\nsources:\n - .cowiki/sources/interview.md\n---\n\n# Interview\n", + ) + .unwrap(); + + engine.merge_agent_change(&space.slug, &change.id).unwrap(); + engine.submit(&space.slug, &[]).unwrap(); + + let page = engine.get_page(&space.slug, "interview").unwrap(); + let provenance = page.provenance.expect("page provenance"); + let commit = provenance.commit.expect("last page commit"); + assert_eq!(commit.oid.len(), 40); + assert_eq!(commit.summary, "Update local Space"); + assert_eq!(provenance.agents.len(), 1); + assert_eq!(provenance.agents[0].name, "Codex"); + assert_eq!(provenance.agents[0].change_id, change.id); + assert_eq!(provenance.agents[0].task, "Organize interview"); + + let repo = Repository::open(&folder).unwrap(); + let message = repo + .head() + .unwrap() + .peel_to_commit() + .unwrap() + .message() + .unwrap() + .to_string(); + assert!(message.contains("CoWiki-Agent: Codex")); + assert!(message.contains(&format!("CoWiki-Agent-Change: {}", change.id))); + assert!(message.contains("CoWiki-Agent-Task: Organize interview")); + drop(repo); + + let clone_path = temp.path().join("clone"); + Repository::clone(folder.to_str().unwrap(), &clone_path).unwrap(); + let clone_engine = LocalEngine::open(&temp.path().join("clone-metadata")).unwrap(); + let cloned_space = clone_engine + .add_space("Cloned Notes", "cloned-notes", &clone_path) + .unwrap(); + let cloned = clone_engine + .get_page(&cloned_space.slug, "interview") + .unwrap() + .provenance + .unwrap(); + assert_eq!(cloned.commit.unwrap().oid, commit.oid); + assert_eq!(cloned.agents[0].name, "Codex"); + assert_eq!(cloned.agents[0].task, "Organize interview"); + } + + #[test] + fn keeping_reviewed_agent_change_preserves_agent_provenance() { + let temp = tempfile::tempdir().unwrap(); + let engine = LocalEngine::open(&temp.path().join("metadata")).unwrap(); + let folder = temp.path().join("notes"); + std::fs::create_dir_all(&folder).unwrap(); + let space = engine.add_space("Notes", "notes", &folder).unwrap(); + let change = engine + .create_agent_change_with_identity(&space.slug, "Compile notes", "Claude") + .unwrap(); + std::fs::write( + change.worktree_path.join("compiled.md"), + "---\ntype: Note\n---\n\n# Compiled\n", + ) + .unwrap(); + + engine.merge_agent_change(&space.slug, &change.id).unwrap(); + let reviewed = engine.working_diff(&space.slug).unwrap(); + engine.keep_working_diff(&space.slug, &reviewed).unwrap(); + + let provenance = engine + .get_page(&space.slug, "compiled") + .unwrap() + .provenance + .expect("page provenance"); + assert_eq!(provenance.agents.len(), 1); + assert_eq!(provenance.agents[0].name, "Claude"); + assert_eq!(provenance.agents[0].change_id, change.id); + assert_eq!(provenance.agents[0].task, "Compile notes"); + } + #[test] fn binary_draft_and_agent_deltas_remain_visible_and_merge_raw_bytes() { let temp = tempfile::tempdir().unwrap(); diff --git a/web/src-tauri/src/local_engine/agent_changes.rs b/web/src-tauri/src/local_engine/agent_changes.rs index 3377648c..64d3d6a2 100644 --- a/web/src-tauri/src/local_engine/agent_changes.rs +++ b/web/src-tauri/src/local_engine/agent_changes.rs @@ -1,4 +1,7 @@ -use super::{file_diff_from_bytes, safe_repo_path, FileDiff, LocalEngine}; +use super::{ + agent_provenance_from_message, file_diff_from_bytes, safe_repo_path, AgentProvenance, FileDiff, + LocalEngine, +}; use git2::{Oid, Repository, Signature, StatusOptions}; use serde::Serialize; use std::collections::BTreeSet; @@ -10,6 +13,7 @@ const BRANCH_REF_PREFIX: &str = "refs/heads/agent/"; const CONFLICT_REF_PREFIX: &str = "refs/cowiki/agent-conflicts/"; const MERGED_REF_PREFIX: &str = "refs/cowiki/agent-merged/"; const DISCARDED_REF_PREFIX: &str = "refs/cowiki/agent-discarded/"; +const INTEGRATED_REF_PREFIX: &str = "refs/cowiki/agent-integrated/"; #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -23,10 +27,20 @@ pub struct AgentChange { } impl LocalEngine { + #[cfg(test)] pub fn create_agent_change( &self, space_slug: &str, agent_name: &str, + ) -> Result { + self.create_agent_change_with_identity(space_slug, agent_name, agent_name) + } + + pub fn create_agent_change_with_identity( + &self, + space_slug: &str, + title: &str, + agent_name: &str, ) -> Result { let space = self.find_space(space_slug)?; let repo = Repository::open(&space.local_path).map_err(|error| error.to_string())?; @@ -39,14 +53,18 @@ impl LocalEngine { let signature = Signature::now("CoWiki Agent", "agent@cowiki.app") .map_err(|error| error.to_string())?; let id = uuid::Uuid::new_v4().to_string(); - let title = clean_agent_name(agent_name); + let title = clean_agent_name(title); + let agent_name = clean_agent_name(agent_name); let base_ref = base_ref(&id); + let message = format!( + "CoWiki Agent Change: {title}\n\nCoWiki-Agent: {agent_name}\nCoWiki-Agent-Change: {id}\nCoWiki-Agent-Task: {title}" + ); let base_id = repo .commit( Some(&base_ref), &signature, &signature, - &format!("CoWiki Agent Change: {title}"), + &message, &tree, &[&parent], ) @@ -454,6 +472,95 @@ impl LocalEngine { } } +pub(super) fn pending_merged_agent_contributions( + repo: &Repository, + parent: Option<&git2::Commit<'_>>, + committed_tree: &git2::Tree<'_>, +) -> Result, String> { + let parent_tree = parent + .map(|commit| commit.tree().map_err(|error| error.to_string())) + .transpose()?; + let committed_paths = changed_paths(repo, parent_tree.as_ref(), committed_tree)?; + if committed_paths.is_empty() { + return Ok(Vec::new()); + } + let mut contributions = Vec::new(); + let references = repo + .references_glob(&format!("{MERGED_REF_PREFIX}*")) + .map_err(|error| error.to_string())?; + for reference in references { + let reference = reference.map_err(|error| error.to_string())?; + let Some(name) = reference.name() else { + continue; + }; + let Some(change_id) = name.strip_prefix(MERGED_REF_PREFIX) else { + continue; + }; + if repo.find_reference(&integrated_ref(change_id)).is_ok() { + continue; + } + let base = reference_commit(repo, &base_ref(change_id))?; + let result = reference + .peel_to_commit() + .map_err(|error| error.to_string())?; + let base_tree = base.tree().map_err(|error| error.to_string())?; + let result_tree = result.tree().map_err(|error| error.to_string())?; + let agent_paths = changed_paths(repo, Some(&base_tree), &result_tree)?; + if !agent_paths + .iter() + .any(|path| committed_paths.contains(path)) + { + continue; + } + let mut agents = agent_provenance_from_message(base.message().unwrap_or_default()); + if let Some(agent) = agents.pop() { + contributions.push(agent); + } + } + contributions.sort_by(|left, right| left.change_id.cmp(&right.change_id)); + Ok(contributions) +} + +pub(super) fn mark_agent_contributions_committed( + repo: &Repository, + commit_oid: Oid, + contributions: &[AgentProvenance], +) { + for contribution in contributions { + if let Err(error) = repo.reference( + &integrated_ref(&contribution.change_id), + commit_oid, + true, + "Record portable CoWiki Agent provenance", + ) { + eprintln!( + "CoWiki committed Agent Change '{}' but could not record its local integration marker: {error}", + contribution.change_id + ); + } + } +} + +fn changed_paths( + repo: &Repository, + old_tree: Option<&git2::Tree<'_>>, + new_tree: &git2::Tree<'_>, +) -> Result, String> { + let diff = repo + .diff_tree_to_tree(old_tree, Some(new_tree), None) + .map_err(|error| error.to_string())?; + let mut paths = BTreeSet::new(); + for delta in diff.deltas() { + if let Some(path) = delta.old_file().path() { + paths.insert(path.to_path_buf()); + } + if let Some(path) = delta.new_file().path() { + paths.insert(path.to_path_buf()); + } + } + Ok(paths) +} + fn warn_cleanup_failure(change_id: &str, error: &str) { eprintln!( "CoWiki closed Agent Change '{change_id}' but could not clean its worktree; cleanup will retry when Reviews reload: {error}" @@ -890,6 +997,10 @@ fn discarded_ref(change_id: &str) -> String { format!("{DISCARDED_REF_PREFIX}{change_id}") } +fn integrated_ref(change_id: &str) -> String { + format!("{INTEGRATED_REF_PREFIX}{change_id}") +} + fn worktree_name(change_id: &str) -> String { format!("cowiki-agent-{change_id}") } diff --git a/web/src/api.ts b/web/src/api.ts index 612095b6..552119da 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -1,6 +1,7 @@ import { authHeaders } from './auth'; import { apiBase, isDesktopClient } from './runtime'; import * as localApi from './local-api'; +import type { DocumentProvenance } from './lib/page-lineage'; const BASE = apiBase(); @@ -28,6 +29,7 @@ export interface PageFull extends PageMeta { edited_by?: string | null; /** Unix timestamp (seconds) of that last edit. */ edited_at?: number | null; + provenance?: DocumentProvenance | null; } export interface Submission { @@ -488,10 +490,11 @@ export async function createLocalCheckpoint(workspaceSlug: string, name?: string export async function createLocalAgentChange( workspaceSlug: string, - agentName: string, + title: string, + agentName = title, ): Promise { if (!isDesktopClient()) throw new Error('Local Agent Changes are available only in the desktop app'); - return localApi.createAgentChange(workspaceSlug, agentName); + return localApi.createAgentChange(workspaceSlug, title, agentName); } export async function listLocalAgentChanges(workspaceSlug: string): Promise { diff --git a/web/src/cloud/CloudWikiView.tsx b/web/src/cloud/CloudWikiView.tsx index fc984866..7de32612 100644 --- a/web/src/cloud/CloudWikiView.tsx +++ b/web/src/cloud/CloudWikiView.tsx @@ -7,6 +7,8 @@ import { resolveInitialCloudPage } from './cloud-shell-model'; import { CloudNotice } from './CloudHome'; import { CloudQuickSetup } from './CloudQuickSetup'; import { cloudSpaceRoute } from './routes'; +import { pageLineage } from '../lib/page-lineage'; +import { SourcePreviewDialog } from '../components/SourcePreviewDialog'; export function CloudWikiView({ client, @@ -26,6 +28,9 @@ export function CloudWikiView({ const navigate = useNavigate(); const [content, setContent] = useState(null); const [contentError, setContentError] = useState<{ path: string; message: string } | null>(null); + const [sourcePath, setSourcePath] = useState(''); + const [sourceContent, setSourceContent] = useState(null); + const [sourceError, setSourceError] = useState(''); const pages = useMemo(() => tree?.entries.filter((entry) => entry.kind === 'page') ?? [], [tree]); const currentContent = content?.path === documentPath ? content : null; const currentError = contentError?.path === documentPath ? contentError?.message ?? '' : ''; @@ -56,6 +61,19 @@ export function CloudWikiView({ return () => { active = false; }; }, [client, documentPath, space.id]); + useEffect(() => { + if (!sourcePath) return; + let active = true; + setSourceContent(null); + setSourceError(''); + void client.getSourceContent(space.id, sourcePath) + .then((next) => { if (active) setSourceContent(next); }) + .catch((cause) => { + if (active) setSourceError(cause instanceof Error ? cause.message : 'Could not load this Source.'); + }); + return () => { active = false; }; + }, [client, sourcePath, space.id]); + return (
{treeError ? ( @@ -65,10 +83,22 @@ export function CloudWikiView({ ) : documentPath && !currentContent ? (
Loading page…
) : currentContent ? ( - + navigate(cloudSpaceRoute(space.id, 'reviews', id))} + /> ) : tree && pages.length === 0 ? ( ) : null} + { if (!open) setSourcePath(''); }} + />
); } diff --git a/web/src/cloud/PublicCloudSpacePage.tsx b/web/src/cloud/PublicCloudSpacePage.tsx index d54311cd..fc26940b 100644 --- a/web/src/cloud/PublicCloudSpacePage.tsx +++ b/web/src/cloud/PublicCloudSpacePage.tsx @@ -3,6 +3,7 @@ import { Globe2 } from 'lucide-react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { PageReader } from '../components/PageReader'; import { splitSystemFrontmatter } from '../lib/page-frontmatter'; +import { pageLineage } from '../lib/page-lineage'; import { apiOrigin } from '../runtime'; import { createPublicCloudClient, @@ -126,7 +127,10 @@ export function PublicCloudSpacePage() {
{documentPath && content?.path === documentPath ? ( - + ) : pages.length === 0 ? (
No published Markdown pages yet.
) : ( diff --git a/web/src/cloud/client.ts b/web/src/cloud/client.ts index 908e400a..dca01a19 100644 --- a/web/src/cloud/client.ts +++ b/web/src/cloud/client.ts @@ -5,6 +5,7 @@ import { type CloudSession, type CloudVisibility, } from './session.ts'; +import type { DocumentProvenance } from '../lib/page-lineage'; export type CloudFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; @@ -63,6 +64,7 @@ export interface CloudContent { oid: string; path: string; content: string; + provenance?: DocumentProvenance | null; } export interface CloudMember { @@ -153,6 +155,7 @@ export interface CloudClient { getSpace(spaceId: string): Promise; getTree(spaceId: string): Promise; getContent(spaceId: string, path: string): Promise; + getSourceContent(spaceId: string, path: string): Promise; listMembers(spaceId: string): Promise; setMember(spaceId: string, handle: string, role: CloudRole): Promise; removeMember(spaceId: string, memberId: string): Promise; @@ -230,6 +233,10 @@ export function createCloudClient( const query = new URLSearchParams({ ref: 'main', path }); return request(`${spacePath(spaceId, '/content')}?${query}`); }, + getSourceContent: (spaceId, path) => { + const query = new URLSearchParams({ ref: 'main', path }); + return request(`${spacePath(spaceId, '/sources/content')}?${query}`); + }, listMembers: (spaceId) => request(spacePath(spaceId, '/members')), setMember: (spaceId, handle, role) => request(spacePath(spaceId, '/members'), { method: 'POST', diff --git a/web/src/components/PageLineage.tsx b/web/src/components/PageLineage.tsx new file mode 100644 index 00000000..f5538f8f --- /dev/null +++ b/web/src/components/PageLineage.tsx @@ -0,0 +1,96 @@ +import { Bot, Database, GitCommitHorizontal, GitPullRequest, MoveRight } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { C, fonts } from '@/lib/design'; +import type { PageLineage as PageLineageModel } from '@/lib/page-lineage'; + +interface LineageStep { + key: string; + icon: ReactNode; + eyebrow: string; + label: string; + title?: string; + onClick?: () => void; +} + +export function PageLineage({ + lineage, + onOpenSource, + onOpenReview, +}: { + lineage: PageLineageModel; + onOpenSource?: (path: string) => void; + onOpenReview?: (id: string) => void; +}) { + const steps: LineageStep[] = [ + ...lineage.sources.map((path) => ({ + key: `source:${path}`, + icon: , + eyebrow: 'Source', + label: path.replace(/^\.cowiki\/sources\//, ''), + onClick: onOpenSource ? () => onOpenSource(path) : undefined, + })), + ...lineage.agents.map((agent) => ({ + key: `agent:${agent.changeId}`, + icon: , + eyebrow: 'Agent', + label: agent.name, + title: agent.task, + })), + ...(lineage.commit ? [{ + key: `commit:${lineage.commit.oid}`, + icon: , + eyebrow: 'Commit', + label: lineage.commit.oid.slice(0, 7), + title: `${lineage.commit.summary} · ${lineage.commit.author}`, + }] : []), + ...(lineage.review ? [{ + key: `review:${lineage.review.id}`, + icon: , + eyebrow: 'Review', + label: `#${lineage.review.number}`, + title: lineage.review.title, + onClick: onOpenReview ? () => onOpenReview(lineage.review!.id) : undefined, + }] : []), + ]; + if (!steps.length) return null; + + return ( +
+
+ Knowledge lineage +
+
+ {steps.map((step, index) => ( +
+ {index > 0 && } + +
+ ))} +
+
+ ); +} diff --git a/web/src/components/PageReader.tsx b/web/src/components/PageReader.tsx index 2e6a2819..82b30a78 100644 --- a/web/src/components/PageReader.tsx +++ b/web/src/components/PageReader.tsx @@ -3,6 +3,8 @@ import ReactMarkdown, { type Components } from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { C } from '@/lib/design'; import { PageByline } from './PageByline'; +import { PageLineage } from './PageLineage'; +import type { PageLineage as PageLineageModel } from '@/lib/page-lineage'; interface PageReaderProps { body: string; @@ -16,6 +18,9 @@ interface PageReaderProps { readOnlyDotColor?: string; missingMessage?: string; aside?: ReactNode; + lineage?: PageLineageModel; + onOpenSource?: (path: string) => void; + onOpenReview?: (id: string) => void; } export function PageReader({ @@ -27,6 +32,9 @@ export function PageReader({ readOnlyDotColor = C.blue, missingMessage, aside, + lineage, + onOpenSource, + onOpenReview, }: PageReaderProps) { return (
@@ -55,6 +63,13 @@ export function PageReader({ ) : ( <> {byline && } + {lineage && ( + + )} {body} diff --git a/web/src/components/SourcePreviewDialog.tsx b/web/src/components/SourcePreviewDialog.tsx new file mode 100644 index 00000000..3fcbc1c7 --- /dev/null +++ b/web/src/components/SourcePreviewDialog.tsx @@ -0,0 +1,55 @@ +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { ExternalLink } from 'lucide-react'; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { splitSystemFrontmatter } from '@/lib/page-frontmatter'; +import { sourceOriginalUrl } from '@/lib/page-lineage'; + +export function SourcePreviewDialog({ + open, + path, + content, + error, + onOpenChange, +}: { + open: boolean; + path: string; + content: string | null; + error: string; + onOpenChange: (open: boolean) => void; +}) { + const originalUrl = content ? sourceOriginalUrl(content) : null; + return ( + + + + {path.replace(/^\.cowiki\/sources\//, '')} + Captured Source content and original URL + + {originalUrl && ( + + View original + + )} +
+ {error ? ( +

{error}

+ ) : content ? ( +
+ + {splitSystemFrontmatter(content).body} + +
+ ) : ( +

Loading Source…

+ )} +
+
+
+ ); +} diff --git a/web/src/components/terminal/AgentTerminalPanel.tsx b/web/src/components/terminal/AgentTerminalPanel.tsx index 8c4884da..9c987b93 100644 --- a/web/src/components/terminal/AgentTerminalPanel.tsx +++ b/web/src/components/terminal/AgentTerminalPanel.tsx @@ -145,7 +145,11 @@ export function AgentTerminalPanel({ setTabState((state) => addAgentTab(state, agent, nextTerminalTabId(agent), mode)); return true; } - const change = await createLocalAgentChange(spaceSlug, title || agentDisplayName(agent)); + const change = await createLocalAgentChange( + spaceSlug, + title || agentDisplayName(agent), + agentDisplayName(agent), + ); setTabState((state) => addAgentTab( state, agent, diff --git a/web/src/lib/page-lineage.ts b/web/src/lib/page-lineage.ts new file mode 100644 index 00000000..fa1c1a27 --- /dev/null +++ b/web/src/lib/page-lineage.ts @@ -0,0 +1,100 @@ +export interface CommitProvenance { + oid: string; + summary: string; + author: string; + committedAt: number; +} + +export interface AgentProvenance { + name: string; + changeId: string; + task: string; +} + +export interface ReviewProvenance { + id: string; + number: number; + title: string; +} + +export interface DocumentProvenance { + commit?: CommitProvenance | null; + agents?: AgentProvenance[] | null; + review?: ReviewProvenance | null; +} + +export interface PageLineage { + sources: string[]; + commit: CommitProvenance | null; + agents: AgentProvenance[]; + review: ReviewProvenance | null; +} + +export function pageLineage(document: string, provenance?: DocumentProvenance | null): PageLineage { + return { + sources: frontmatterSources(document), + commit: provenance?.commit ?? null, + agents: provenance?.agents ?? [], + review: provenance?.review ?? null, + }; +} + +export function sourceFilename(path: string): string { + return path.replace(/^\.cowiki\/sources\//, ''); +} + +export function sourceOriginalUrl(document: string): string | null { + const match = document.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); + const value = match?.[1] + .split(/\r?\n/) + .find((line) => /^source_url\s*:/.test(line)) + ?.replace(/^source_url\s*:\s*/, '') + .trim() + .replace(/^['"]|['"]$/g, ''); + if (!value) return null; + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : null; + } catch { + return null; + } +} + +function frontmatterSources(document: string): string[] { + const match = document.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); + if (!match) return []; + const lines = match[1].split(/\r?\n/); + const values: string[] = []; + for (let index = 0; index < lines.length; index += 1) { + const field = lines[index].match(/^sources\s*:\s*(.*?)\s*$/); + if (!field) continue; + if (field[1]) { + const inline = field[1].trim(); + const entries = inline.startsWith('[') && inline.endsWith(']') + ? inline.slice(1, -1).split(',') + : [inline]; + values.push(...entries); + } else { + for (let item = index + 1; item < lines.length; item += 1) { + const entry = lines[item].match(/^\s+-\s+(.+?)\s*$/); + if (!entry) break; + values.push(entry[1]); + } + } + break; + } + return [...new Set(values.map(normalizeSourceRef).filter((value): value is string => !!value))]; +} + +function normalizeSourceRef(raw: string): string | null { + const value = raw.trim().replace(/^['"]|['"]$/g, ''); + const relative = value.startsWith('.cowiki/sources/') + ? value.slice('.cowiki/sources/'.length) + : value.startsWith('sources/') + ? value.slice('sources/'.length) + : value; + if (!relative.toLowerCase().endsWith('.md')) return null; + const parts = relative.split('/'); + if (parts.some((part) => !part || part === '.' || part === '..' || part.startsWith('.'))) return null; + return `.cowiki/sources/${parts.join('/')}`; +} diff --git a/web/src/lib/source-ingest.ts b/web/src/lib/source-ingest.ts index 4689a9e2..42c2f27a 100644 --- a/web/src/lib/source-ingest.ts +++ b/web/src/lib/source-ingest.ts @@ -36,14 +36,18 @@ export function mergeImportedSources( export function sourceOrganizationTask(sources: SourceItem[]): string { const paths = sources - .map((source) => source.filename.startsWith('sources/') + .map((source) => source.filename.startsWith('.cowiki/sources/') ? source.filename - : `sources/${source.filename}`) + : `.cowiki/sources/${source.filename.replace(/^sources\//, '')}`) .map((path) => `- ${path}`) .join('\n'); return [ 'Organize the newly imported OKF Source files below into durable knowledge.', - 'Read each Source, update the appropriate Concepts and indexes, preserve provenance, and do not modify the Source files.', + 'Read each Source, update the appropriate Concepts and indexes, and do not modify the Source files.', + 'Preserve provenance in every knowledge page you create or update with portable frontmatter in this exact form:', + 'sources:', + ' - .cowiki/sources/.md', + 'Use only the Source paths that support that page.', paths, ].join('\n'); } diff --git a/web/src/local-api.ts b/web/src/local-api.ts index 4bccf7e8..6b06f139 100644 --- a/web/src/local-api.ts +++ b/web/src/local-api.ts @@ -91,8 +91,8 @@ export function createCheckpoint(spaceSlug: string, name?: string): Promise { - return invoke('local_create_agent_change', { spaceSlug, agentName }); +export function createAgentChange(spaceSlug: string, title: string, agentName = title): Promise { + return invoke('local_create_agent_change', { spaceSlug, title, agentName }); } export function probeAgent(agent: AgentKind): Promise { diff --git a/web/src/pages/MainLayout.tsx b/web/src/pages/MainLayout.tsx index a90da3c3..beced1d8 100644 --- a/web/src/pages/MainLayout.tsx +++ b/web/src/pages/MainLayout.tsx @@ -81,6 +81,7 @@ import { type DefaultAgent, } from '@/lib/client-settings'; import { sourceUrlFromDocument, splitSystemFrontmatter } from '@/lib/page-frontmatter'; +import { pageLineage, sourceFilename } from '@/lib/page-lineage'; import { sourceOrganizationTask } from '@/lib/source-ingest'; import { resolveWorkspaceSwitchTarget } from '@/lib/workspace-navigation'; import { openExternalUrl } from '@/external-links'; @@ -1352,6 +1353,13 @@ export function MainLayout() { { + if (activeWorkspace) void selectSource(activeWorkspace, sourceFilename(path)); + }} markdownComponents={commentMarkdownComponents} byline={versionSelection.kind === 'working' ? { name: activeView.content.edited_by, diff --git a/web/tests/cloud-client.test.ts b/web/tests/cloud-client.test.ts index b3278dc1..9c28b485 100644 --- a/web/tests/cloud-client.test.ts +++ b/web/tests/cloud-client.test.ts @@ -93,6 +93,24 @@ test('Cloud logout revokes the server credential', async () => { assert.equal(new Headers(calls[0].init?.headers).get('authorization'), 'Bearer cw_key_test'); }); +test('Cloud Source lineage reads only the authenticated Source endpoint', async () => { + const calls: string[] = []; + const fakeFetch: CloudFetch = async (input) => { + calls.push(String(input)); + return Response.json({ ref: 'main', oid: 'a'.repeat(40), path: '.cowiki/sources/interview.md', content: '# Interview' }); + }; + const client = createCloudClient( + { baseUrl: 'https://cloud.cowiki.test', apiKey: 'key', userId, userName: 'User' }, + fakeFetch, + ); + + await client.getSourceContent(spaceId, '.cowiki/sources/interview.md'); + + assert.deepEqual(calls, [ + `https://cloud.cowiki.test/api/spaces/${spaceId}/sources/content?ref=main&path=.cowiki%2Fsources%2Finterview.md`, + ]); +}); + test('Cloud mutations serialize the current contract and surface typed failures', async () => { const calls: Array<{ url: string; init?: RequestInit }> = []; const fakeFetch: CloudFetch = async (input, init) => { diff --git a/web/tests/page-lineage.test.ts b/web/tests/page-lineage.test.ts new file mode 100644 index 00000000..9de1483d --- /dev/null +++ b/web/tests/page-lineage.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { pageLineage, sourceFilename, sourceOriginalUrl } from '../src/lib/page-lineage.ts'; + +test('page lineage reads portable Source references from OKF frontmatter', () => { + const lineage = pageLineage(`--- +type: Note +title: Durable knowledge +sources: + - .cowiki/sources/interview.md + - "sources/research/paper.md" + - ../outside.md +--- + +# Durable knowledge +`); + + assert.deepEqual(lineage.sources, [ + '.cowiki/sources/interview.md', + '.cowiki/sources/research/paper.md', + ]); + assert.equal(sourceFilename(lineage.sources[1]), 'research/paper.md'); +}); + +test('page lineage accepts a single Source and combines Git provenance', () => { + const lineage = pageLineage( + `--- +type: Note +sources: source.md +--- +`, + { + commit: { + oid: '0123456789abcdef', + summary: 'Compile the interview', + author: 'Qinghao', + committedAt: 1_725_000_000, + }, + agents: [{ name: 'Codex', changeId: 'change-1', task: 'Organize interview' }], + review: { id: 'review-id', number: 12, title: 'Compile interview' }, + }, + ); + + assert.deepEqual(lineage.sources, ['.cowiki/sources/source.md']); + assert.equal(lineage.commit?.oid, '0123456789abcdef'); + assert.equal(lineage.agents[0]?.name, 'Codex'); + assert.equal(lineage.review?.number, 12); +}); + +test('page lineage ignores unsafe paths and malformed frontmatter', () => { + assert.deepEqual(pageLineage('---\nsources: [/wiki/page.md, https://example.com]\n---\n').sources, []); + assert.deepEqual(pageLineage('# Plain Markdown').sources, []); +}); + +test('Source provenance exposes only safe original web URLs', () => { + assert.equal( + sourceOriginalUrl('---\nsource_url: "https://example.com/article"\n---\n'), + 'https://example.com/article', + ); + assert.equal(sourceOriginalUrl('---\nsource_url: javascript:alert(1)\n---\n'), null); +}); diff --git a/web/tests/source-ingest.test.ts b/web/tests/source-ingest.test.ts index 804df920..a282fa11 100644 --- a/web/tests/source-ingest.test.ts +++ b/web/tests/source-ingest.test.ts @@ -54,7 +54,8 @@ test('Agent organization task names exact Source paths, not display titles', () { filename: 'sources/_encoded/second.md', title: 'Another title' }, ]); - assert.match(task, /sources\/_encoded\/first\.md/); - assert.match(task, /sources\/_encoded\/second\.md/); + assert.match(task, /\.cowiki\/sources\/_encoded\/first\.md/); + assert.match(task, /\.cowiki\/sources\/_encoded\/second\.md/); + assert.match(task, /sources:\n {2}- \.cowiki\/sources\//); assert.doesNotMatch(task, /Ignore previous instructions/); }); From 8f09454f134ca088dc713f4c69df259e6b9aba9f Mon Sep 17 00:00:00 2001 From: Qinghao Huang Date: Fri, 4 Sep 2026 01:27:09 +0800 Subject: [PATCH 4/8] feat(ui): make Local and Cloud context explicit --- web/package.json | 3 +- .../layout/WorkspaceContextBadge.tsx | 42 +++++++++ web/src/lib/workspace-context.ts | 91 +++++++++++++++++++ web/src/pages/MainLayout.tsx | 56 +++++++++++- web/tests/workspace-context.test.ts | 41 +++++++++ 5 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 web/src/components/layout/WorkspaceContextBadge.tsx create mode 100644 web/src/lib/workspace-context.ts create mode 100644 web/tests/workspace-context.test.ts diff --git a/web/package.json b/web/package.json index 47ceedc5..bdda4a51 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,7 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "test": "npm run test:auth-flow && npm run test:versioned-document && npm run test:local-space && npm run test:sidebar-layout && npm run test:client-settings && npm run test:design-system && npm run test:page-frontmatter && npm run test:page-lineage && npm run test:agent-terminal && npm run test:agent-contract && npm run test:okf-pages && npm run test:workspace-navigation && npm run test:window-chrome && npm run test:page-header && npm run test:source-ingest && npm run test:desktop-workflow && npm run test:history && npm run test:link-diagnostics && npm run test:cloud-sync && npm run test:cloud-client && npm run test:cloud-shell && npm run test:cloud-review-model && npm run test:review-preview && npm run test:skill-hosting && npm run test:cloud-space-dialog", + "test": "npm run test:auth-flow && npm run test:versioned-document && npm run test:local-space && npm run test:sidebar-layout && npm run test:client-settings && npm run test:design-system && npm run test:page-frontmatter && npm run test:page-lineage && npm run test:agent-terminal && npm run test:agent-contract && npm run test:okf-pages && npm run test:workspace-navigation && npm run test:workspace-context && npm run test:window-chrome && npm run test:page-header && npm run test:source-ingest && npm run test:desktop-workflow && npm run test:history && npm run test:link-diagnostics && npm run test:cloud-sync && npm run test:cloud-client && npm run test:cloud-shell && npm run test:cloud-review-model && npm run test:review-preview && npm run test:skill-hosting && npm run test:cloud-space-dialog", "test:auth-flow": "node --experimental-strip-types --test tests/auth-flow.test.ts", "test:versioned-document": "node --experimental-strip-types --test tests/versioned-document.test.ts", "test:local-space": "node --experimental-strip-types --test tests/local-space.test.ts", @@ -21,6 +21,7 @@ "test:agent-contract": "node --experimental-strip-types --test tests/agent-contract.test.ts", "test:okf-pages": "node --experimental-strip-types --test tests/okf-pages.test.ts", "test:workspace-navigation": "node --experimental-strip-types --test tests/workspace-navigation.test.ts", + "test:workspace-context": "node --experimental-strip-types --test tests/workspace-context.test.ts", "test:window-chrome": "node --experimental-strip-types --test tests/window-chrome.test.ts", "test:page-header": "node --experimental-strip-types --test tests/page-header.test.ts", "test:source-ingest": "node --experimental-strip-types --test tests/source-ingest.test.ts", diff --git a/web/src/components/layout/WorkspaceContextBadge.tsx b/web/src/components/layout/WorkspaceContextBadge.tsx new file mode 100644 index 00000000..0edc5aab --- /dev/null +++ b/web/src/components/layout/WorkspaceContextBadge.tsx @@ -0,0 +1,42 @@ +import { Cloud, HardDrive } from 'lucide-react'; +import { C } from '@/lib/design'; +import type { WorkspaceContextStatus } from '@/lib/workspace-context'; + +export function WorkspaceContextBadge({ + context, + onClick, +}: { + context: WorkspaceContextStatus; + onClick?: () => void; +}) { + const content = ( + <> + {context.kind === 'cloud' ? : } + {context.label} + + ); + const style: React.CSSProperties = { + display: 'inline-flex', + alignItems: 'center', + gap: 5, + flexShrink: 0, + padding: '3px 7px', + border: `1px solid ${context.attention ? C.accentTintBorder : C.line}`, + borderRadius: 999, + background: context.attention ? C.accentSoft : C.sidebar, + color: context.attention ? C.accent : C.muted, + fontSize: 10.5, + fontWeight: 650, + lineHeight: 1, + whiteSpace: 'nowrap', + }; + + if (onClick) { + return ( + + ); + } + return {content}; +} diff --git a/web/src/lib/workspace-context.ts b/web/src/lib/workspace-context.ts new file mode 100644 index 00000000..a639ca9b --- /dev/null +++ b/web/src/lib/workspace-context.ts @@ -0,0 +1,91 @@ +import type { CloudSyncState } from '../local-api.ts'; + +export interface WorkspaceContextStatus { + kind: 'local' | 'linked' | 'cloud'; + label: string; + detail: string; + attention: boolean; +} + +export function workspaceContextStatus({ + desktop, + state, + hasLocalChanges = false, + statusKnown = true, +}: { + desktop: boolean; + state?: CloudSyncState | 'unavailable' | null; + hasLocalChanges?: boolean; + statusKnown?: boolean; +}): WorkspaceContextStatus { + if (!desktop) { + return { + kind: 'cloud', + label: 'Cloud', + detail: 'Shared Cloud Space', + attention: false, + }; + } + if (!statusKnown) { + return { + kind: 'local', + label: 'Local · Checking link', + detail: 'This Space is stored on this device', + attention: false, + }; + } + if (state === 'unavailable') { + return { + kind: 'local', + label: 'Local · Link unavailable', + detail: 'CoWiki could not read this Space’s Cloud link status', + attention: true, + }; + } + if (!state || state === 'unlinked') { + return { + kind: 'local', + label: 'Local only', + detail: 'Changes stay on this device until you publish', + attention: false, + }; + } + if (hasLocalChanges || state === 'dirty') { + return { + kind: 'linked', + label: 'Local + Cloud · Not uploaded', + detail: 'Local changes have not been submitted to Cloud', + attention: true, + }; + } + if (state === 'submitted') { + return { + kind: 'linked', + label: 'Local + Cloud · In review', + detail: 'Your Cloud submission is waiting for Review', + attention: false, + }; + } + if (state === 'needsSync') { + return { + kind: 'linked', + label: 'Local + Cloud · Cloud update', + detail: 'Cloud has changes that are not in this local copy', + attention: true, + }; + } + if (state === 'conflicted' || state === 'leaseRejected') { + return { + kind: 'linked', + label: 'Local + Cloud · Attention', + detail: 'Cloud sync needs your attention', + attention: true, + }; + } + return { + kind: 'linked', + label: 'Local + Cloud · Synced', + detail: 'This local copy is linked to Cloud', + attention: false, + }; +} diff --git a/web/src/pages/MainLayout.tsx b/web/src/pages/MainLayout.tsx index beced1d8..d9badbb4 100644 --- a/web/src/pages/MainLayout.tsx +++ b/web/src/pages/MainLayout.tsx @@ -57,6 +57,8 @@ import { C } from '@/lib/design'; import { apiOrigin, isDesktopClient } from '@/runtime'; import { normalizeCloudSession } from '@/cloud/session'; import { CloudSpaceDialog } from '@/components/cloud/CloudSpaceDialog'; +import { WorkspaceContextBadge } from '@/components/layout/WorkspaceContextBadge'; +import { getCloudStatus, type CloudSyncState } from '@/local-api'; import { chooseLocalSpaceDirectory, localSpaceIdentityFromPath } from '@/local-space'; import { AgentTerminalPanel, @@ -85,6 +87,7 @@ import { pageLineage, sourceFilename } from '@/lib/page-lineage'; import { sourceOrganizationTask } from '@/lib/source-ingest'; import { resolveWorkspaceSwitchTarget } from '@/lib/workspace-navigation'; import { openExternalUrl } from '@/external-links'; +import { workspaceContextStatus } from '@/lib/workspace-context'; type ActiveView = | { kind: 'page'; slug: string; path?: string; content: PageFull | null } @@ -149,6 +152,11 @@ export function MainLayout() { const [versionSelection, setVersionSelection] = useState({ kind: 'working' }); const [openAgentChanges, setOpenAgentChanges] = useState([]); const [workingDiffs, setWorkingDiffs] = useState([]); + const [workingDiffsSpaceSlug, setWorkingDiffsSpaceSlug] = useState(null); + const [desktopCloudStatus, setDesktopCloudStatus] = useState<{ + spaceSlug: string; + state: CloudSyncState | 'unavailable'; + } | null>(null); const [sidebarLayout, setSidebarLayout] = useState(() => loadSidebarLayout(window.localStorage)); const [resizingSidebar, setResizingSidebar] = useState(false); const sidebarResizeStart = useRef({ pointerX: 0, width: sidebarLayout.width }); @@ -434,6 +442,7 @@ export function MainLayout() { const task = window.setTimeout(() => { setOpenAgentChanges([]); setWorkingDiffs([]); + setWorkingDiffsSpaceSlug(null); setVersionSelection({ kind: 'working' }); }, 0); return () => window.clearTimeout(task); @@ -447,6 +456,7 @@ export function MainLayout() { const openChanges = changes.filter((change) => change.status === 'open'); setOpenAgentChanges(openChanges); setWorkingDiffs(diffs); + setWorkingDiffsSpaceSlug(activeWorkspace.slug); setVersionSelection((current) => ( current.kind === 'agent' && !openChanges.some((change) => change.id === current.changeId) ? { kind: 'working' } @@ -456,11 +466,29 @@ export function MainLayout() { if (cancelled) return; setOpenAgentChanges([]); setWorkingDiffs([]); + setWorkingDiffsSpaceSlug(activeWorkspace.slug); setVersionSelection({ kind: 'working' }); }); return () => { cancelled = true; }; }, [activeWorkspace?.localPath, activeWorkspace?.slug, desktop, reviewRefreshKey]); + useEffect(() => { + if (!desktop || !activeWorkspace?.localPath) { + const task = window.setTimeout(() => setDesktopCloudStatus(null), 0); + return () => window.clearTimeout(task); + } + let cancelled = false; + const spaceSlug = activeWorkspace.slug; + getCloudStatus(spaceSlug) + .then((status) => { + if (!cancelled) setDesktopCloudStatus({ spaceSlug, state: status.state }); + }) + .catch(() => { + if (!cancelled) setDesktopCloudStatus({ spaceSlug, state: 'unavailable' }); + }); + return () => { cancelled = true; }; + }, [activeWorkspace?.localPath, activeWorkspace?.slug, desktop, reviewRefreshKey]); + // Select a source file const selectSource = async (ws: Workspace, filename: string) => { setActiveWorkspace(ws); @@ -866,6 +894,16 @@ export function MainLayout() { const personal = activeWorkspace ? isPersonalSpace(activeWorkspace) : false; const isOwner = activeWorkspace?.role === 'owner'; + const activeWorkingDiffs = workingDiffsSpaceSlug === activeWorkspace?.slug ? workingDiffs : []; + const activeCloudState = desktopCloudStatus && desktopCloudStatus.spaceSlug === activeWorkspace?.slug + ? desktopCloudStatus.state + : null; + const workspaceContext = workspaceContextStatus({ + desktop, + state: activeCloudState, + hasLocalChanges: activeWorkingDiffs.length > 0, + statusKnown: !desktop || !!activeCloudState, + }); // Determine active page/source for panel highlight const currentActivePage = activeView?.kind === 'page' ? activeView.slug : null; @@ -879,7 +917,7 @@ export function MainLayout() { const activePagePath = activeView?.kind === 'page' ? (activeView.path || conceptPath(activeView.slug)) : null; - const workingPageDiff = activePagePath ? findDiffForPath(workingDiffs, activePagePath) : undefined; + const workingPageDiff = activePagePath ? findDiffForPath(activeWorkingDiffs, activePagePath) : undefined; const agentPageDiff = activePagePath && selectedAgentChange ? findDiffForPath(selectedAgentChange.diffs, activePagePath) : undefined; @@ -1053,7 +1091,19 @@ export function MainLayout() { )} {activeWorkspace && ( - {activeWorkspace.name} + <> + {activeWorkspace.name} + { + if (!cloudSession) { + navigate('/login'); + return; + } + setCloudDialogOpen(true); + } : undefined} + /> + )} {activeView?.kind === 'page' && activeView.content && ( <> @@ -1606,7 +1656,7 @@ export function MainLayout() { onOpenChange={setCloudDialogOpen} space={activeWorkspace ? { name: activeWorkspace.name, slug: activeWorkspace.slug } : null} session={cloudSession} - hasLocalChanges={workingDiffs.length > 0} + hasLocalChanges={activeWorkingDiffs.length > 0} onChanged={() => setReviewRefreshKey((key) => key + 1)} /> )} diff --git a/web/tests/workspace-context.test.ts b/web/tests/workspace-context.test.ts new file mode 100644 index 00000000..d4dcecf1 --- /dev/null +++ b/web/tests/workspace-context.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +import { workspaceContextStatus } from '../src/lib/workspace-context.ts'; + +const mainLayout = readFileSync(new URL('../src/pages/MainLayout.tsx', import.meta.url), 'utf8'); + +test('hosted Spaces are always identified as Cloud', () => { + assert.deepEqual(workspaceContextStatus({ desktop: false }), { + kind: 'cloud', + label: 'Cloud', + detail: 'Shared Cloud Space', + attention: false, + }); +}); + +test('local Spaces distinguish unlinked, unsent, synced, and review states', () => { + assert.equal(workspaceContextStatus({ desktop: true, state: 'unlinked' }).label, 'Local only'); + assert.equal( + workspaceContextStatus({ desktop: true, state: 'upToDate', hasLocalChanges: true }).label, + 'Local + Cloud · Not uploaded', + ); + assert.equal(workspaceContextStatus({ desktop: true, state: 'synced' }).label, 'Local + Cloud · Synced'); + assert.equal(workspaceContextStatus({ desktop: true, state: 'submitted' }).label, 'Local + Cloud · In review'); +}); + +test('sync problems are visible and never described as uploaded', () => { + const needsSync = workspaceContextStatus({ desktop: true, state: 'needsSync' }); + const conflict = workspaceContextStatus({ desktop: true, state: 'conflicted' }); + assert.equal(needsSync.label, 'Local + Cloud · Cloud update'); + assert.equal(conflict.label, 'Local + Cloud · Attention'); + assert.equal(conflict.attention, true); + assert.doesNotMatch(`${needsSync.label} ${conflict.label}`, /uploaded/i); +}); + +test('active-Space context is keyed so a previous Space cannot leak into the header', () => { + assert.match(mainLayout, /desktopCloudStatus\.spaceSlug === activeWorkspace\?\.slug/); + assert.match(mainLayout, /workingDiffsSpaceSlug === activeWorkspace\?\.slug/); + assert.match(mainLayout, /WorkspaceContextBadge/); +}); From 45c8df0df328e6d6304a05b141f3c0e6c7593d32 Mon Sep 17 00:00:00 2001 From: Qinghao Huang Date: Fri, 4 Sep 2026 01:51:12 +0800 Subject: [PATCH 5/8] feat(comments): unify local and Cloud review threads --- web/src-tauri/src/lib.rs | 74 +++- web/src-tauri/src/local_engine.rs | 6 +- web/src-tauri/src/local_engine/comments.rs | 471 +++++++++++++++++++++ web/src/cloud/CloudApp.tsx | 4 + web/src/cloud/CloudHome.tsx | 11 +- web/src/cloud/CloudNotificationsPage.tsx | 133 ++++++ web/src/cloud/CloudSpaceView.tsx | 13 +- web/src/cloud/CloudWikiView.tsx | 45 +- web/src/cloud/client.ts | 79 ++++ web/src/components/PageCommentsLayer.tsx | 67 +-- web/src/lib/page-comment-store.ts | 119 ++++++ web/src/local-api.ts | 35 ++ web/src/pages/MainLayout.tsx | 56 ++- web/tests/cloud-client.test.ts | 35 ++ 14 files changed, 1097 insertions(+), 51 deletions(-) create mode 100644 web/src-tauri/src/local_engine/comments.rs create mode 100644 web/src/cloud/CloudNotificationsPage.tsx create mode 100644 web/src/lib/page-comment-store.ts diff --git a/web/src-tauri/src/lib.rs b/web/src-tauri/src/lib.rs index 8a33ca10..a8f15678 100644 --- a/web/src-tauri/src/lib.rs +++ b/web/src-tauri/src/lib.rs @@ -8,8 +8,9 @@ mod terminal; mod web_source; use local_engine::{ - AgentChange, BrokenLink, Checkpoint, FileDiff, IngestFileOutcome, LocalEngine, PageFull, - PageMeta, SearchResponse, SourceContent, SourceItem, Space, SpaceHistory, SubmitResult, + AgentChange, BrokenLink, Checkpoint, CommentMember, FileDiff, IngestFileOutcome, LocalEngine, + PageComment, PageCommentsResponse, PageFull, PageMeta, SearchResponse, SourceContent, + SourceItem, Space, SpaceHistory, SubmitResult, }; use std::io::{Read, Write}; use std::net::TcpListener; @@ -98,6 +99,70 @@ fn local_get_page( engine.get_page(&space_slug, &page_slug) } +#[tauri::command] +fn local_list_page_comments( + engine: State<'_, LocalEngine>, + space_slug: String, + page_slug: String, +) -> Result { + engine.list_page_comments(&space_slug, &page_slug) +} + +#[tauri::command] +#[allow(clippy::too_many_arguments)] +fn local_create_page_comment( + engine: State<'_, LocalEngine>, + space_slug: String, + page_slug: String, + user_id: String, + user_name: String, + body: String, + source: Option, + start_line: Option, + end_line: Option, + parent_id: Option, +) -> Result { + engine.create_page_comment( + &space_slug, + &page_slug, + &user_id, + &user_name, + &body, + source.as_deref(), + start_line, + end_line, + parent_id.as_deref(), + ) +} + +#[tauri::command] +fn local_list_comment_members( + engine: State<'_, LocalEngine>, + space_slug: String, +) -> Result, String> { + engine.list_comment_members(&space_slug) +} + +#[tauri::command] +fn local_set_page_comment_resolved( + engine: State<'_, LocalEngine>, + space_slug: String, + comment_id: String, + resolved: bool, +) -> Result { + engine.set_page_comment_resolved(&space_slug, &comment_id, resolved) +} + +#[tauri::command] +fn local_delete_page_comment( + engine: State<'_, LocalEngine>, + space_slug: String, + comment_id: String, + user_id: String, +) -> Result<(), String> { + engine.delete_page_comment(&space_slug, &comment_id, &user_id) +} + #[tauri::command] fn local_write_page( engine: State<'_, LocalEngine>, @@ -452,6 +517,11 @@ pub fn run() { local_add_space, local_list_pages, local_get_page, + local_list_page_comments, + local_create_page_comment, + local_list_comment_members, + local_set_page_comment_resolved, + local_delete_page_comment, local_write_page, local_create_folder, local_list_sources, diff --git a/web/src-tauri/src/local_engine.rs b/web/src-tauri/src/local_engine.rs index d62908fc..c6b57560 100644 --- a/web/src-tauri/src/local_engine.rs +++ b/web/src-tauri/src/local_engine.rs @@ -18,8 +18,10 @@ use crate::okf::{self, DocumentKind}; use crate::web_source::{self, WebSourceSnapshot}; mod agent_changes; +mod comments; pub use crate::knowledge_index::BrokenLink; pub use agent_changes::AgentChange; +pub use comments::{CommentMember, PageComment, PageCommentsResponse}; #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -290,7 +292,8 @@ impl LocalEngine { Connection::open(metadata_dir.join("local.db")).map_err(|e| e.to_string())?; connection .execute_batch( - "CREATE TABLE IF NOT EXISTS spaces ( + "PRAGMA foreign_keys = ON; + CREATE TABLE IF NOT EXISTS spaces ( id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, @@ -308,6 +311,7 @@ impl LocalEngine { );", ) .map_err(|e| e.to_string())?; + comments::initialize(&connection)?; knowledge_index::initialize(&mut connection)?; let engine = Self { db: Arc::new(Mutex::new(connection)), diff --git a/web/src-tauri/src/local_engine/comments.rs b/web/src-tauri/src/local_engine/comments.rs new file mode 100644 index 00000000..530b1245 --- /dev/null +++ b/web/src-tauri/src/local_engine/comments.rs @@ -0,0 +1,471 @@ +use super::LocalEngine; +use rusqlite::{params, OptionalExtension}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +const MAX_COMMENT_CHARS: usize = 10_000; +const MAX_SOURCE_BYTES: usize = 512 * 1024; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct PageComment { + pub id: String, + pub workspace_slug: String, + pub page_slug: String, + pub user_id: String, + pub content_hash: Option, + pub start_line: Option, + pub end_line: Option, + pub body: String, + pub parent_id: Option, + pub resolved: bool, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct CommentSnapshot { + pub content_hash: String, + pub source: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct PageCommentsResponse { + pub comments: Vec, + pub snapshots: Vec, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct CommentMember { + pub id: String, + pub name: String, +} + +pub(super) fn initialize(connection: &rusqlite::Connection) -> Result<(), String> { + connection + .execute_batch( + "CREATE TABLE IF NOT EXISTS local_comment_snapshots ( + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + page_slug TEXT NOT NULL, + content_hash TEXT NOT NULL, + source TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (space_id, page_slug, content_hash) + ); + CREATE TABLE IF NOT EXISTS local_page_comments ( + id TEXT PRIMARY KEY, + space_id TEXT NOT NULL REFERENCES spaces(id) ON DELETE CASCADE, + page_slug TEXT NOT NULL, + user_id TEXT NOT NULL, + user_name TEXT NOT NULL, + content_hash TEXT, + start_line INTEGER, + end_line INTEGER, + body TEXT NOT NULL, + parent_id TEXT REFERENCES local_page_comments(id) ON DELETE CASCADE, + resolved INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + CHECK (length(body) BETWEEN 1 AND 10000), + CHECK ( + (parent_id IS NULL AND content_hash IS NOT NULL AND start_line IS NOT NULL AND end_line IS NOT NULL) + OR + (parent_id IS NOT NULL AND content_hash IS NULL AND start_line IS NULL AND end_line IS NULL) + ) + ); + CREATE INDEX IF NOT EXISTS local_page_comments_page_idx + ON local_page_comments(space_id, page_slug, created_at, id);", + ) + .map_err(|error| error.to_string()) +} + +impl LocalEngine { + pub fn list_page_comments( + &self, + space_slug: &str, + page_slug: &str, + ) -> Result { + validate_page_slug(page_slug)?; + let db = self + .db + .lock() + .map_err(|_| "local database lock poisoned".to_string())?; + let space_id = space_id(&db, space_slug)?; + let mut comments_query = db + .prepare( + "SELECT comments.id, spaces.slug, comments.page_slug, comments.user_id, + comments.content_hash, comments.start_line, comments.end_line, + comments.body, comments.parent_id, comments.resolved, + comments.created_at, comments.updated_at + FROM local_page_comments AS comments + JOIN spaces ON spaces.id = comments.space_id + WHERE comments.space_id = ?1 AND comments.page_slug = ?2 + ORDER BY comments.created_at, comments.id", + ) + .map_err(|error| error.to_string())?; + let comments = comments_query + .query_map(params![space_id, page_slug], |row| { + Ok(PageComment { + id: row.get(0)?, + workspace_slug: row.get(1)?, + page_slug: row.get(2)?, + user_id: row.get(3)?, + content_hash: row.get(4)?, + start_line: row.get(5)?, + end_line: row.get(6)?, + body: row.get(7)?, + parent_id: row.get(8)?, + resolved: row.get::<_, i64>(9)? != 0, + created_at: row.get(10)?, + updated_at: row.get(11)?, + }) + }) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + let mut snapshots_query = db + .prepare( + "SELECT content_hash, source FROM local_comment_snapshots + WHERE space_id = ?1 AND page_slug = ?2 ORDER BY created_at", + ) + .map_err(|error| error.to_string())?; + let snapshots = snapshots_query + .query_map(params![space_id, page_slug], |row| { + Ok(CommentSnapshot { + content_hash: row.get(0)?, + source: row.get(1)?, + }) + }) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + Ok(PageCommentsResponse { + comments, + snapshots, + }) + } + + #[allow(clippy::too_many_arguments)] + pub fn create_page_comment( + &self, + space_slug: &str, + page_slug: &str, + user_id: &str, + user_name: &str, + body: &str, + source: Option<&str>, + start_line: Option, + end_line: Option, + parent_id: Option<&str>, + ) -> Result { + validate_page_slug(page_slug)?; + let body = validate_body(body)?; + let user_name = if user_name.trim().is_empty() { + "You" + } else { + user_name.trim() + }; + let mut db = self + .db + .lock() + .map_err(|_| "local database lock poisoned".to_string())?; + let space_id = space_id(&db, space_slug)?; + let transaction = db.transaction().map_err(|error| error.to_string())?; + let (content_hash, start_line, end_line) = if let Some(parent_id) = parent_id { + if source.is_some() || start_line.is_some() || end_line.is_some() { + return Err("replies cannot include a source anchor".into()); + } + let exists = transaction + .query_row( + "SELECT EXISTS( + SELECT 1 FROM local_page_comments + WHERE id = ?1 AND space_id = ?2 AND page_slug = ?3 + )", + params![parent_id, space_id, page_slug], + |row| row.get::<_, bool>(0), + ) + .map_err(|error| error.to_string())?; + if !exists { + return Err("reply parent does not belong to this page".into()); + } + (None, None, None) + } else { + let source = source.ok_or_else(|| "a source snapshot is required".to_string())?; + let (start_line, end_line) = validate_anchor(source, start_line, end_line)?; + let hash = format!("{:x}", Sha256::digest(source.as_bytes())); + transaction + .execute( + "INSERT OR IGNORE INTO local_comment_snapshots + (space_id, page_slug, content_hash, source) VALUES (?1, ?2, ?3, ?4)", + params![space_id, page_slug, hash, source], + ) + .map_err(|error| error.to_string())?; + (Some(hash), Some(start_line), Some(end_line)) + }; + let id = Uuid::new_v4().to_string(); + transaction + .execute( + "INSERT INTO local_page_comments + (id, space_id, page_slug, user_id, user_name, content_hash, + start_line, end_line, body, parent_id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + id, + space_id, + page_slug, + user_id, + user_name, + content_hash, + start_line, + end_line, + body, + parent_id + ], + ) + .map_err(|error| error.to_string())?; + transaction.commit().map_err(|error| error.to_string())?; + drop(db); + self.get_page_comment(space_slug, &id) + } + + pub fn list_comment_members(&self, space_slug: &str) -> Result, String> { + let db = self + .db + .lock() + .map_err(|_| "local database lock poisoned".to_string())?; + let space_id = space_id(&db, space_slug)?; + let mut query = db + .prepare( + "SELECT user_id, max(user_name) FROM local_page_comments + WHERE space_id = ?1 GROUP BY user_id ORDER BY lower(max(user_name)), user_id", + ) + .map_err(|error| error.to_string())?; + let members = query + .query_map([space_id], |row| { + Ok(CommentMember { + id: row.get(0)?, + name: row.get(1)?, + }) + }) + .map_err(|error| error.to_string())? + .collect::, _>>() + .map_err(|error| error.to_string())?; + Ok(members) + } + + pub fn set_page_comment_resolved( + &self, + space_slug: &str, + comment_id: &str, + resolved: bool, + ) -> Result { + let db = self + .db + .lock() + .map_err(|_| "local database lock poisoned".to_string())?; + let space_id = space_id(&db, space_slug)?; + let changed = db + .execute( + "UPDATE local_page_comments + SET resolved = ?3, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE id = ?1 AND space_id = ?2", + params![comment_id, space_id, resolved], + ) + .map_err(|error| error.to_string())?; + drop(db); + if changed == 0 { + return Err("comment not found".into()); + } + self.get_page_comment(space_slug, comment_id) + } + + pub fn delete_page_comment( + &self, + space_slug: &str, + comment_id: &str, + user_id: &str, + ) -> Result<(), String> { + let db = self + .db + .lock() + .map_err(|_| "local database lock poisoned".to_string())?; + let space_id = space_id(&db, space_slug)?; + let owner = db + .query_row( + "SELECT user_id FROM local_page_comments WHERE id = ?1 AND space_id = ?2", + params![comment_id, space_id], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|error| error.to_string())? + .ok_or_else(|| "comment not found".to_string())?; + if owner != user_id { + return Err("only the comment author can delete it".into()); + } + db.execute( + "DELETE FROM local_page_comments WHERE id = ?1 AND space_id = ?2", + params![comment_id, space_id], + ) + .map_err(|error| error.to_string())?; + Ok(()) + } + + fn get_page_comment(&self, space_slug: &str, comment_id: &str) -> Result { + let db = self + .db + .lock() + .map_err(|_| "local database lock poisoned".to_string())?; + let space_id = space_id(&db, space_slug)?; + db.query_row( + "SELECT comments.id, spaces.slug, comments.page_slug, comments.user_id, + comments.content_hash, comments.start_line, comments.end_line, + comments.body, comments.parent_id, comments.resolved, + comments.created_at, comments.updated_at + FROM local_page_comments AS comments JOIN spaces ON spaces.id = comments.space_id + WHERE comments.id = ?1 AND comments.space_id = ?2", + params![comment_id, space_id], + |row| { + Ok(PageComment { + id: row.get(0)?, + workspace_slug: row.get(1)?, + page_slug: row.get(2)?, + user_id: row.get(3)?, + content_hash: row.get(4)?, + start_line: row.get(5)?, + end_line: row.get(6)?, + body: row.get(7)?, + parent_id: row.get(8)?, + resolved: row.get::<_, i64>(9)? != 0, + created_at: row.get(10)?, + updated_at: row.get(11)?, + }) + }, + ) + .optional() + .map_err(|error| error.to_string())? + .ok_or_else(|| "comment not found".to_string()) + } +} + +fn space_id(connection: &rusqlite::Connection, space_slug: &str) -> Result { + connection + .query_row( + "SELECT id FROM spaces WHERE slug = ?1", + [space_slug], + |row| row.get(0), + ) + .optional() + .map_err(|error| error.to_string())? + .ok_or_else(|| "Space not found".to_string()) +} + +fn validate_page_slug(page_slug: &str) -> Result<(), String> { + let valid = !page_slug.is_empty() + && page_slug.len() <= 1024 + && page_slug.ends_with(".md") + && !page_slug.starts_with('/') + && !page_slug.contains('\\') + && page_slug + .split('/') + .all(|part| !part.is_empty() && part != "." && part != ".." && !part.starts_with('.')); + if valid { + Ok(()) + } else { + Err("comment path must be a visible Markdown page".into()) + } +} + +fn validate_body(body: &str) -> Result<&str, String> { + let body = body.trim(); + if (1..=MAX_COMMENT_CHARS).contains(&body.chars().count()) { + Ok(body) + } else { + Err("comment body must be between 1 and 10000 characters".into()) + } +} + +fn validate_anchor( + source: &str, + start: Option, + end: Option, +) -> Result<(i32, i32), String> { + if source.len() > MAX_SOURCE_BYTES { + return Err("source snapshot is too large".into()); + } + let start = start.ok_or_else(|| "startLine is required".to_string())?; + let end = end.ok_or_else(|| "endLine is required".to_string())?; + let line_count = source.lines().count().max(1) as i32; + if start < 1 || end < start || end > line_count { + return Err("comment anchor is outside the source snapshot".into()); + } + Ok((start, end)) +} + +#[cfg(test)] +mod tests { + use super::super::LocalEngine; + + #[test] + fn local_comments_persist_and_support_threads() { + let temp = tempfile::tempdir().unwrap(); + let metadata = temp.path().join("metadata"); + let folder = temp.path().join("space"); + std::fs::create_dir_all(&folder).unwrap(); + let engine = LocalEngine::open(&metadata).unwrap(); + engine.add_space("Local", "local", &folder).unwrap(); + + let root = engine + .create_page_comment( + "local", + "wiki/page.md", + "local-user", + "You", + "Review this", + Some("# Page\n\nText\n"), + Some(3), + Some(3), + None, + ) + .unwrap(); + let reply = engine + .create_page_comment( + "local", + "wiki/page.md", + "local-user", + "You", + "Done", + None, + None, + None, + Some(&root.id), + ) + .unwrap(); + assert_eq!(reply.parent_id.as_deref(), Some(root.id.as_str())); + engine + .set_page_comment_resolved("local", &root.id, true) + .unwrap(); + drop(engine); + + let reopened = LocalEngine::open(&metadata).unwrap(); + let page = reopened + .list_page_comments("local", "wiki/page.md") + .unwrap(); + assert_eq!(page.comments.len(), 2); + assert_eq!(page.snapshots.len(), 1); + assert!( + page.comments + .iter() + .find(|item| item.id == root.id) + .unwrap() + .resolved + ); + reopened + .delete_page_comment("local", &root.id, "local-user") + .unwrap(); + assert!(reopened + .list_page_comments("local", "wiki/page.md") + .unwrap() + .comments + .is_empty()); + } +} diff --git a/web/src/cloud/CloudApp.tsx b/web/src/cloud/CloudApp.tsx index 78758069..13c03b28 100644 --- a/web/src/cloud/CloudApp.tsx +++ b/web/src/cloud/CloudApp.tsx @@ -5,6 +5,7 @@ import { apiOrigin } from '../runtime'; import { createCloudClient } from './client'; import { CloudHome } from './CloudHome'; import { CloudSpaceView } from './CloudSpaceView'; +import { CloudNotificationsPage } from './CloudNotificationsPage'; import { parseCloudRoute } from './routes'; import { normalizeCloudSession, type CloudSession } from './session'; @@ -38,6 +39,9 @@ export function CloudApp({ session: injectedSession }: CloudAppProps) { if (location.pathname === '/cloud' || location.pathname === '/cloud/') { return void signOut()} />; } + if (location.pathname === '/cloud/notifications') { + return void signOut()} />; + } if (route) { return ( ('private'); + const [unread, setUnread] = useState(0); const panel = panelFromSearch(location.search); useEffect(() => { @@ -63,6 +64,10 @@ export function CloudHome({ client, session, onSignOut }: CloudHomeProps) { return () => { active = false; }; }, [client]); + useEffect(() => { + void client.notificationUnreadCount().then((value) => setUnread(value.count)).catch(() => undefined); + }, [client]); + useEffect(() => { if (panel !== 'create') return undefined; let active = true; @@ -143,9 +148,9 @@ export function CloudHome({ client, session, onSignOut }: CloudHomeProps) { onDiscover={() => undefined} onLogout={onSignOut} onConnectCloud={() => undefined} - notifUnread={0} - onShowNotifications={() => undefined} - showBell={false} + notifUnread={unread} + onShowNotifications={() => navigate('/cloud/notifications')} + showBell showCloudActions showSettings={false} showDiscover={false} diff --git a/web/src/cloud/CloudNotificationsPage.tsx b/web/src/cloud/CloudNotificationsPage.tsx new file mode 100644 index 00000000..f5bd312c --- /dev/null +++ b/web/src/cloud/CloudNotificationsPage.tsx @@ -0,0 +1,133 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Bell, CheckCheck, MessageSquare } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { SpaceRail } from '../components/layout/SpaceRail'; +import { TooltipProvider } from '../components/ui/tooltip'; +import { timeAgo } from '../lib/time'; +import type { CloudClient, CloudNotification, CloudSpace } from './client'; +import { cloudSpaceRoute } from './routes'; +import type { CloudSession } from './session'; + +export function CloudNotificationsPage({ + client, + session, + onSignOut, +}: { + client: CloudClient; + session: CloudSession; + onSignOut: () => void; +}) { + const navigate = useNavigate(); + const [spaces, setSpaces] = useState([]); + const [notifications, setNotifications] = useState(null); + const [error, setError] = useState(''); + const unread = useMemo( + () => notifications?.filter((notification) => !notification.read).length ?? 0, + [notifications], + ); + + const reload = () => { + void client.listNotifications() + .then(setNotifications) + .catch((cause) => setError(cause instanceof Error ? cause.message : 'Could not load notifications.')); + }; + + useEffect(() => { + reload(); + void client.listSpaces().then(setSpaces).catch(() => undefined); + // client identity is stable for the signed-in session. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [client]); + + const open = async (notification: CloudNotification) => { + if (!notification.read) { + setNotifications((current) => current?.map((item) => ( + item.id === notification.id ? { ...item, read: true } : item + )) ?? null); + await client.setNotificationRead(notification.id, true).catch(reload); + } + navigate(cloudSpaceRoute(notification.spaceId, 'wiki', notification.pagePath)); + }; + + const markAll = async () => { + setNotifications((current) => current?.map((item) => ({ ...item, read: true })) ?? null); + await client.markAllNotificationsRead().catch(reload); + }; + + return ( + +
+ navigate(cloudSpaceRoute(space.id))} + onCreateWorkspace={() => navigate('/cloud?action=create')} + onSettings={() => undefined} + onDiscover={() => undefined} + onLogout={onSignOut} + onConnectCloud={() => undefined} + notifUnread={unread} + onShowNotifications={() => undefined} + showBell + showCloudActions + showSettings={false} + showDiscover={false} + titlebarInset={false} + createLabel="New shared Space" + /> +
+
+
+
+

Notifications

+

Mentions shared through Cloud Spaces.

+
+ +
+ {error ? ( +

{error}

+ ) : notifications == null ? ( +

Loading…

+ ) : notifications.length === 0 ? ( +
+ +

No mentions yet.

+
+ ) : ( +
+ {notifications.map((notification) => ( + + ))} +
+ )} +
+
+
+
+ ); +} diff --git a/web/src/cloud/CloudSpaceView.tsx b/web/src/cloud/CloudSpaceView.tsx index 0001741c..833c229d 100644 --- a/web/src/cloud/CloudSpaceView.tsx +++ b/web/src/cloud/CloudSpaceView.tsx @@ -42,6 +42,7 @@ export function CloudSpaceView({ client, session, route, onSignOut }: CloudSpace const [treeErrorState, setTreeErrorState] = useState | null>(null); const [unpublishedState, setUnpublishedState] = useState | null>(null); const [panelCollapsed, setPanelCollapsed] = useState(false); + const [unread, setUnread] = useState(0); const space = routeScopedValue(route.spaceId, loadedSpace); const tree = routeScopedValue(route.spaceId, loadedTree); const spaceError = routeScopedValue(route.spaceId, spaceErrorState) ?? ''; @@ -103,6 +104,10 @@ export function CloudSpaceView({ client, session, route, onSignOut }: CloudSpace return () => { active = false; }; }, [client]); + useEffect(() => { + void client.notificationUnreadCount().then((value) => setUnread(value.count)).catch(() => undefined); + }, [client]); + const changeTab = (tab: NavTab) => { if (tab === 'wiki' || tab === 'reviews' || tab === 'members') { navigate(cloudSpaceRoute(route.spaceId, tab)); @@ -122,9 +127,9 @@ export function CloudSpaceView({ client, session, route, onSignOut }: CloudSpace onDiscover={() => undefined} onLogout={onSignOut} onConnectCloud={() => undefined} - notifUnread={0} - onShowNotifications={() => undefined} - showBell={false} + notifUnread={unread} + onShowNotifications={() => navigate('/cloud/notifications')} + showBell showCloudActions showSettings={false} showDiscover={false} @@ -203,6 +208,8 @@ export function CloudSpaceView({ client, session, route, onSignOut }: CloudSpace treeError={treeError} unpublished={unpublished} documentPath={route.documentPath} + currentUserId={session.userId} + currentUserName={session.userName} /> ) : route.view === 'reviews' ? ( diff --git a/web/src/cloud/CloudWikiView.tsx b/web/src/cloud/CloudWikiView.tsx index 7de32612..aaaa17d4 100644 --- a/web/src/cloud/CloudWikiView.tsx +++ b/web/src/cloud/CloudWikiView.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { PageReader } from '../components/PageReader'; import { splitSystemFrontmatter } from '../lib/page-frontmatter'; @@ -9,6 +9,13 @@ import { CloudQuickSetup } from './CloudQuickSetup'; import { cloudSpaceRoute } from './routes'; import { pageLineage } from '../lib/page-lineage'; import { SourcePreviewDialog } from '../components/SourcePreviewDialog'; +import { + CommentsHeaderToggle, + CommentsPanel, + CommentsProvider, + commentMarkdownComponents, +} from '../components/PageCommentsLayer'; +import { cloudPageCommentStore } from '../lib/page-comment-store'; export function CloudWikiView({ client, @@ -17,6 +24,8 @@ export function CloudWikiView({ treeError, unpublished, documentPath, + currentUserId, + currentUserName, }: { client: CloudClient; space: CloudSpace; @@ -24,6 +33,8 @@ export function CloudWikiView({ treeError: string; unpublished: boolean; documentPath?: string; + currentUserId: string; + currentUserName: string; }) { const navigate = useNavigate(); const [content, setContent] = useState(null); @@ -34,6 +45,11 @@ export function CloudWikiView({ const pages = useMemo(() => tree?.entries.filter((entry) => entry.kind === 'page') ?? [], [tree]); const currentContent = content?.path === documentPath ? content : null; const currentError = contentError?.path === documentPath ? contentError?.message ?? '' : ''; + const articleRef = useRef(null); + const commentStore = useMemo( + () => cloudPageCommentStore(client, space.id, currentUserId, currentUserName), + [client, currentUserId, currentUserName, space.id], + ); useEffect(() => { if (!tree || documentPath || unpublished) return; @@ -83,12 +99,27 @@ export function CloudWikiView({ ) : documentPath && !currentContent ? (
Loading page…
) : currentContent ? ( - navigate(cloudSpaceRoute(space.id, 'reviews', id))} - /> + + + navigate(cloudSpaceRoute(space.id, 'reviews', id))} + aside={} + /> + ) : tree && pages.length === 0 ? ( ) : null} diff --git a/web/src/cloud/client.ts b/web/src/cloud/client.ts index dca01a19..a9148a4c 100644 --- a/web/src/cloud/client.ts +++ b/web/src/cloud/client.ts @@ -75,6 +75,54 @@ export interface CloudMember { role: CloudRole; } +export interface CloudComment { + id: string; + pagePath: string; + userId: string; + userHandle: string; + userName: string; + userAvatarUrl: string | null; + contentHash: string | null; + startLine: number | null; + endLine: number | null; + body: string; + parentId: string | null; + resolved: boolean; + createdAt: string; + updatedAt: string; +} + +export interface CloudCommentsResponse { + comments: CloudComment[]; + snapshots: Array<{ contentHash: string; source: string }>; +} + +export interface CreateCloudComment { + path: string; + body: string; + source?: string; + startLine?: number; + endLine?: number; + parentId?: string; +} + +export interface CloudNotification { + id: string; + kind: 'mention'; + spaceId: string; + spaceName: string; + spaceSlug: string; + commentId: string; + pagePath: string; + commentBody: string; + actorId: string; + actorHandle: string; + actorName: string; + actorAvatarUrl: string | null; + read: boolean; + createdAt: string; +} + export type CloudInvitableRole = 'editor' | 'viewer'; export interface CloudInvitationPreview { @@ -159,6 +207,14 @@ export interface CloudClient { listMembers(spaceId: string): Promise; setMember(spaceId: string, handle: string, role: CloudRole): Promise; removeMember(spaceId: string, memberId: string): Promise; + listComments(spaceId: string, path: string): Promise; + createComment(spaceId: string, input: CreateCloudComment): Promise; + setCommentResolved(spaceId: string, commentId: string, resolved: boolean): Promise; + deleteComment(spaceId: string, commentId: string): Promise; + listNotifications(): Promise; + notificationUnreadCount(): Promise<{ count: number }>; + setNotificationRead(notificationId: string, read: boolean): Promise; + markAllNotificationsRead(): Promise; acceptInvitation(token: string): Promise; listInvitations(spaceId: string): Promise; createInvitation( @@ -246,6 +302,29 @@ export function createCloudClient( `${spacePath(spaceId, '/members')}/${encodeURIComponent(memberId)}`, { method: 'DELETE' }, ), + listComments: (spaceId, path) => { + const query = new URLSearchParams({ path }); + return request(`${spacePath(spaceId, '/comments')}?${query}`); + }, + createComment: (spaceId, input) => request(spacePath(spaceId, '/comments'), { + method: 'POST', + body: JSON.stringify(input), + }), + setCommentResolved: (spaceId, commentId, resolved) => request( + `${spacePath(spaceId, '/comments')}/${encodeURIComponent(commentId)}`, + { method: 'PATCH', body: JSON.stringify({ resolved }) }, + ), + deleteComment: (spaceId, commentId) => request( + `${spacePath(spaceId, '/comments')}/${encodeURIComponent(commentId)}`, + { method: 'DELETE' }, + ), + listNotifications: () => request('/api/notifications'), + notificationUnreadCount: () => request('/api/notifications/unread-count'), + setNotificationRead: (notificationId, read) => request( + `/api/notifications/${encodeURIComponent(notificationId)}`, + { method: 'PATCH', body: JSON.stringify({ read }) }, + ), + markAllNotificationsRead: () => request('/api/notifications/read-all', { method: 'POST' }), acceptInvitation: (token) => request( `/api/invitations/${encodeURIComponent(token)}/accept`, { method: 'POST' }, diff --git a/web/src/components/PageCommentsLayer.tsx b/web/src/components/PageCommentsLayer.tsx index 3dc17ab9..a19a3f1e 100644 --- a/web/src/components/PageCommentsLayer.tsx +++ b/web/src/components/PageCommentsLayer.tsx @@ -7,13 +7,11 @@ import { MessageSquare, Reply, Check, RotateCcw, AtSign, ChevronsRight, ChevronRight, ChevronDown, CheckCircle2, Trash2, MoreHorizontal, } from 'lucide-react'; -import { - listPageComments, createPageComment, setPageCommentResolved, deletePageComment, - listMembers, type PageComment, type MemberInfo, -} from '@/api'; +import type { PageComment } from '@/api'; import { AvatarBadge } from '@/components/ui/avatar-badge'; import { buildSnapshotMaps, mapWithSurviving, type MappedAnchor } from '@/lib/comment-anchor'; import { C, fonts, shadows } from '@/lib/design'; +import type { CommentMember, PageCommentStore } from '@/lib/page-comment-store'; // ── identity helpers ─────────────────────────────────────────────────────── function relTime(iso: string): string { @@ -23,6 +21,10 @@ function relTime(iso: string): string { if (s < 86400) return `${Math.floor(s / 3600)}h`; return `${Math.floor(s / 86400)}d`; } +function relTimeAgo(iso: string): string { + const value = relTime(iso); + return value === 'now' ? value : `${value} ago`; +} function quoteOf(source: string, start: number, end: number): string { const text = source .split('\n') @@ -102,8 +104,9 @@ interface CommentsCtx { panelOpen: boolean; setPanelOpen: (b: boolean) => void; composing: { start: number; end: number; quote: string } | null; cancelCompose: () => void; submitNew: (body: string) => Promise; - members: MemberInfo[]; nameOf: (id: string) => string; + members: CommentMember[]; nameOf: (id: string) => string; currentUserId?: string; myId: string; myName: string; + scopeLabel: string; onResolve: (id: string, r: boolean) => Promise; onDelete: (id: string) => Promise; submitReply: (parentId: string, body: string) => Promise; /** Highlight info for a doc source line, or null if not commented. */ @@ -113,36 +116,35 @@ interface CommentsCtx { const Ctx = createContext(null); export function CommentsProvider({ - workspaceSlug, pageSlug, source, articleRef, currentUserId, children, + store, pageSlug, source, articleRef, children, }: { - workspaceSlug: string; + store: PageCommentStore | null; pageSlug: string; source: string; articleRef: React.RefObject; - currentUserId: string | undefined; children: React.ReactNode; }) { const [comments, setComments] = useState([]); const [snapshots, setSnapshots] = useState<{ content_hash: string; source: string }[]>([]); - const [members, setMembers] = useState([]); + const [members, setMembers] = useState([]); const [activeId, setActiveId] = useState(null); const [panelOpen, setPanelOpen] = useState(true); const [pending, setPending] = useState<{ start: number; end: number; x: number; y: number } | null>(null); const [composing, setComposing] = useState<{ start: number; end: number; quote: string } | null>(null); - const enabled = !!workspaceSlug && !!pageSlug; + const enabled = !!store && !!pageSlug; const reload = useCallback(async () => { - if (!workspaceSlug || !pageSlug) { setComments([]); setSnapshots([]); return; } - const res = await listPageComments(workspaceSlug, pageSlug); + if (!store || !pageSlug) { setComments([]); setSnapshots([]); return; } + const res = await store.list(pageSlug); setComments(res.comments); setSnapshots(res.snapshots); - }, [workspaceSlug, pageSlug]); + }, [store, pageSlug]); useEffect(() => { void reload(); }, [reload]); useEffect(() => { - if (!workspaceSlug) return; - listMembers(workspaceSlug).then(setMembers).catch(() => setMembers([])); - }, [workspaceSlug]); + if (!store) return; + store.listMembers().then(setMembers).catch(() => setMembers([])); + }, [store]); // Reset the focused thread when navigating to a different page. useEffect(() => { setActiveId(null); }, [pageSlug]); @@ -214,14 +216,16 @@ export function CommentsProvider({ const submitNew = async (body: string) => { if (!composing || !body.trim()) return; - await createPageComment(workspaceSlug, { slug: pageSlug, body: body.trim(), source, startLine: composing.start, endLine: composing.end }); + if (!store) return; + await store.create({ pagePath: pageSlug, body: body.trim(), source, startLine: composing.start, endLine: composing.end }); setComposing(null); setPending(null); await reload(); }; const submitReply = async (parentId: string, body: string) => { if (!body.trim()) return; - await createPageComment(workspaceSlug, { slug: pageSlug, body: body.trim(), parentId }); + if (!store) return; + await store.create({ pagePath: pageSlug, body: body.trim(), parentId }); await reload(); }; @@ -231,9 +235,11 @@ export function CommentsProvider({ activeId, setActive: setActiveId, panelOpen, setPanelOpen, composing, cancelCompose: () => { setComposing(null); setPending(null); }, submitNew, - members, nameOf, currentUserId, myId: currentUserId ?? 'me', myName: currentUserId ? nameOf(currentUserId) : 'You', - onResolve: async (id, r) => { await setPageCommentResolved(workspaceSlug, id, r); await reload(); }, - onDelete: async (id) => { await deletePageComment(workspaceSlug, id); await reload(); }, + members, nameOf, currentUserId: store?.currentUserId, + myId: store?.currentUserId ?? 'me', myName: store?.currentUserName ?? 'You', + scopeLabel: store?.scopeLabel ?? '', + onResolve: async (id, r) => { if (store) await store.setResolved(id, r); await reload(); }, + onDelete: async (id) => { if (store) await store.delete(id); await reload(); }, submitReply, lineHighlight: (line) => (lineToThread.has(line) ? { active: lineToThread.get(line) === activeId } : null), focusLine: (line) => { const id = lineToThread.get(line); if (id) { setActiveId(id); setPanelOpen(true); } }, @@ -334,7 +340,7 @@ export function CommentsPanel() { if (!ctx) return null; const { anchored, outdated, resolved, openCount, activeId, setActive, panelOpen, setPanelOpen, composing, cancelCompose, submitNew, members, nameOf, currentUserId, myId, myName, - onResolve, onDelete, submitReply } = ctx; + onResolve, onDelete, submitReply, scopeLabel } = ctx; // Collapsed: nothing here — the header toggle reopens the panel. if (!panelOpen || (openCount === 0 && resolved.length === 0 && !composing)) return null; @@ -348,6 +354,7 @@ export function CommentsPanel() {
Comments ({openCount}) + {scopeLabel}
@@ -499,7 +506,7 @@ function CommentCard({ function Composer({ quote, meId, meName, members, placeholder, autoFocus, compact, onCancel, onSubmit, primaryLabel, }: { - quote?: string; meId: string; meName: string; members: MemberInfo[]; + quote?: string; meId: string; meName: string; members: CommentMember[]; placeholder?: string; autoFocus?: boolean; compact?: boolean; onCancel: () => void; onSubmit: (body: string) => void | Promise; primaryLabel: string; }) { @@ -512,13 +519,13 @@ function Composer({ const m = /@([A-Za-z0-9_-]*)$/.exec(next.slice(0, caret)); setMenu(m ? { query: m[1], at: caret - m[1].length - 1 } : null); }; - const pick = (name: string) => { + const pick = (mention: string) => { if (!menu) return; - setValue(value.slice(0, menu.at) + '@' + name + ' ' + value.slice(menu.at + 1 + menu.query.length)); + setValue(value.slice(0, menu.at) + '@' + mention + ' ' + value.slice(menu.at + 1 + menu.query.length)); setMenu(null); ref.current?.focus(); }; - const matches = menu ? members.filter((m) => m.name.toLowerCase().startsWith(menu.query.toLowerCase())).slice(0, 5) : []; + const matches = menu ? members.filter((m) => m.mention.toLowerCase().startsWith(menu.query.toLowerCase())).slice(0, 5) : []; return (
@@ -545,11 +552,11 @@ function Composer({ {matches.length > 0 && (
{matches.map((m) => ( - ))}
diff --git a/web/src/lib/page-comment-store.ts b/web/src/lib/page-comment-store.ts new file mode 100644 index 00000000..e29f716d --- /dev/null +++ b/web/src/lib/page-comment-store.ts @@ -0,0 +1,119 @@ +import type { MemberInfo, PageComment, PageCommentsResponse } from '../api'; +import * as localApi from '../local-api'; +import type { CloudClient, CloudComment } from '../cloud/client'; + +export interface CommentMember extends Pick { + mention: string; +} + +export interface PageCommentStore { + readonly scope: 'local' | 'cloud'; + readonly scopeLabel: string; + readonly currentUserId: string; + readonly currentUserName: string; + list(pagePath: string): Promise; + listMembers(): Promise; + create(input: { + pagePath: string; + body: string; + source?: string; + startLine?: number; + endLine?: number; + parentId?: string; + }): Promise; + setResolved(commentId: string, resolved: boolean): Promise; + delete(commentId: string): Promise; +} + +export function localPageCommentStore(spaceSlug: string): PageCommentStore { + const currentUserId = 'local'; + const currentUserName = 'You'; + return { + scope: 'local', + scopeLabel: 'Local only', + currentUserId, + currentUserName, + list: (pagePath) => localApi.listPageComments(spaceSlug, pagePath), + async listMembers() { + const members = await localApi.listCommentMembers(spaceSlug); + const normalized = members.map((member) => ({ ...member, mention: member.name })); + return normalized.some((member) => member.id === currentUserId) + ? normalized + : [{ id: currentUserId, name: currentUserName, mention: currentUserName }, ...normalized]; + }, + create: (input) => localApi.createPageComment(spaceSlug, { + pageSlug: input.pagePath, + userId: currentUserId, + userName: currentUserName, + body: input.body, + source: input.source, + startLine: input.startLine, + endLine: input.endLine, + parentId: input.parentId, + }), + setResolved: (commentId, resolved) => localApi.setPageCommentResolved(spaceSlug, commentId, resolved), + delete: (commentId) => localApi.deletePageComment(spaceSlug, commentId, currentUserId), + }; +} + +export function cloudPageCommentStore( + client: CloudClient, + spaceId: string, + currentUserId: string, + currentUserName: string, +): PageCommentStore { + return { + scope: 'cloud', + scopeLabel: 'Cloud shared', + currentUserId, + currentUserName, + async list(pagePath) { + const response = await client.listComments(spaceId, pagePath); + return { + comments: response.comments.map((comment) => cloudComment(comment, spaceId)), + snapshots: response.snapshots.map((snapshot) => ({ + content_hash: snapshot.contentHash, + source: snapshot.source, + })), + }; + }, + async listMembers() { + return (await client.listMembers(spaceId)).map((member) => ({ + id: member.userId, + name: member.displayName, + mention: member.handle, + })); + }, + async create(input) { + return cloudComment(await client.createComment(spaceId, { + path: input.pagePath, + body: input.body, + source: input.source, + startLine: input.startLine, + endLine: input.endLine, + parentId: input.parentId, + }), spaceId); + }, + async setResolved(commentId, resolved) { + return cloudComment(await client.setCommentResolved(spaceId, commentId, resolved), spaceId); + }, + delete: (commentId) => client.deleteComment(spaceId, commentId), + }; +} + +function cloudComment(comment: CloudComment, workspaceSlug: string): PageComment { + return { + id: comment.id, + workspace_slug: workspaceSlug, + page_slug: comment.pagePath, + user_id: comment.userId, + content_hash: comment.contentHash, + start_line: comment.startLine, + end_line: comment.endLine, + body: comment.body, + parent_id: comment.parentId, + resolved: comment.resolved, + created_at: comment.createdAt, + updated_at: comment.updatedAt, + }; +} diff --git a/web/src/local-api.ts b/web/src/local-api.ts index 6b06f139..0d035a46 100644 --- a/web/src/local-api.ts +++ b/web/src/local-api.ts @@ -3,6 +3,8 @@ import type { AgentChange, BrokenLink, PageFull, + PageComment, + PageCommentsResponse, PageMeta, FileDiff, IngestFileOutcome, @@ -31,6 +33,39 @@ export function getPage(spaceSlug: string, conceptId: string): Promise return invoke('local_get_page', { spaceSlug, pageSlug: conceptId }); } +export function listPageComments(spaceSlug: string, pageSlug: string): Promise { + return invoke('local_list_page_comments', { spaceSlug, pageSlug }); +} + +export function createPageComment(spaceSlug: string, input: { + pageSlug: string; + userId: string; + userName: string; + body: string; + source?: string; + startLine?: number; + endLine?: number; + parentId?: string; +}): Promise { + return invoke('local_create_page_comment', { spaceSlug, ...input }); +} + +export function listCommentMembers(spaceSlug: string): Promise> { + return invoke('local_list_comment_members', { spaceSlug }); +} + +export function setPageCommentResolved( + spaceSlug: string, + commentId: string, + resolved: boolean, +): Promise { + return invoke('local_set_page_comment_resolved', { spaceSlug, commentId, resolved }); +} + +export function deletePageComment(spaceSlug: string, commentId: string, userId: string): Promise { + return invoke('local_delete_page_comment', { spaceSlug, commentId, userId }); +} + export function writePage( spaceSlug: string, conceptId: string, diff --git a/web/src/pages/MainLayout.tsx b/web/src/pages/MainLayout.tsx index d9badbb4..21b76f7a 100644 --- a/web/src/pages/MainLayout.tsx +++ b/web/src/pages/MainLayout.tsx @@ -88,6 +88,8 @@ import { sourceOrganizationTask } from '@/lib/source-ingest'; import { resolveWorkspaceSwitchTarget } from '@/lib/workspace-navigation'; import { openExternalUrl } from '@/external-links'; import { workspaceContextStatus } from '@/lib/workspace-context'; +import { createCloudClient } from '@/cloud/client'; +import { cloudPageCommentStore, localPageCommentStore } from '@/lib/page-comment-store'; type ActiveView = | { kind: 'page'; slug: string; path?: string; content: PageFull | null } @@ -291,6 +293,28 @@ export function MainLayout() { return null; } }, [auth]); + const cloudClient = useMemo( + () => cloudSession ? createCloudClient(cloudSession) : null, + [cloudSession], + ); + const [commentCloudSpace, setCommentCloudSpace] = useState<{ slug: string; id: string } | null>(null); + + useEffect(() => { + if (!desktop || !activeWorkspace || !cloudSession) { + setCommentCloudSpace(null); + return; + } + let active = true; + void getCloudStatus(activeWorkspace.slug) + .then((status) => { + if (!active) return; + setCommentCloudSpace(status.cloudSpaceId + ? { slug: activeWorkspace.slug, id: status.cloudSpaceId } + : null); + }) + .catch(() => { if (active) setCommentCloudSpace(null); }); + return () => { active = false; }; + }, [activeWorkspace, cloudSession, desktop]); // Load pages for a space. const loadSpacePages = useCallback(async (ws: Workspace) => { @@ -779,8 +803,33 @@ export function MainLayout() { // Page-view comment context: active only when reading (not editing) a page. const pageView = activeView?.kind === 'page' ? activeView : null; const commentsActive = !!pageView?.content && !editingPage; - const commentPageSlug = commentsActive && pageView ? pageView.slug : ''; + const commentPageSlug = commentsActive && pageView + ? (pageView.path ?? pageView.content?.path ?? '') + : ''; const commentSource = commentsActive && pageView?.content ? renderBody(pageView.content.body) : ''; + const commentStore = useMemo(() => { + if (!activeWorkspace) return null; + if (desktop) { + if (cloudClient && cloudSession && commentCloudSpace?.slug === activeWorkspace.slug) { + return cloudPageCommentStore( + cloudClient, + commentCloudSpace.id, + cloudSession.userId, + cloudSession.userName, + ); + } + return localPageCommentStore(activeWorkspace.slug); + } + if (cloudClient && cloudSession) { + return cloudPageCommentStore( + cloudClient, + activeWorkspace.id, + cloudSession.userId, + cloudSession.userName, + ); + } + return null; + }, [activeWorkspace, cloudClient, cloudSession, commentCloudSpace, desktop]); // Execute a pending rename/delete from the tree menus. const handlePathOp = async () => { @@ -1060,13 +1109,10 @@ export function MainLayout() { {/* Main Content Area */}
{/* Top bar: breadcrumb + actions */} diff --git a/web/tests/cloud-client.test.ts b/web/tests/cloud-client.test.ts index 9c28b485..184e97db 100644 --- a/web/tests/cloud-client.test.ts +++ b/web/tests/cloud-client.test.ts @@ -111,6 +111,41 @@ test('Cloud Source lineage reads only the authenticated Source endpoint', async ]); }); +test('Cloud comment and notification methods use the shared authenticated contract', async () => { + const calls: Array<{ url: string; init?: RequestInit }> = []; + const fakeFetch: CloudFetch = async (input, init) => { + calls.push({ url: String(input), init }); + return init?.method === 'DELETE' || String(input).endsWith('/read-all') + ? new Response(null, { status: 204 }) + : Response.json({ comments: [], snapshots: [] }); + }; + const client = createCloudClient( + { baseUrl: 'https://cloud.cowiki.test', apiKey: 'key', userId, userName: 'User' }, + fakeFetch, + ); + + await client.listComments(spaceId, 'wiki/roadmap.md'); + await client.createComment(spaceId, { + path: 'wiki/roadmap.md', body: '@reviewer check this', source: '# Roadmap', startLine: 1, endLine: 1, + }); + await client.setCommentResolved(spaceId, 'comment-id', true); + await client.deleteComment(spaceId, 'comment-id'); + await client.listNotifications(); + await client.notificationUnreadCount(); + await client.markAllNotificationsRead(); + + assert.equal(calls[0].url, `https://cloud.cowiki.test/api/spaces/${spaceId}/comments?path=wiki%2Froadmap.md`); + assert.deepEqual(JSON.parse(String(calls[1].init?.body)), { + path: 'wiki/roadmap.md', body: '@reviewer check this', source: '# Roadmap', startLine: 1, endLine: 1, + }); + assert.equal(calls[2].init?.method, 'PATCH'); + assert.deepEqual(JSON.parse(String(calls[2].init?.body)), { resolved: true }); + assert.equal(calls[3].init?.method, 'DELETE'); + assert.equal(calls[4].url, 'https://cloud.cowiki.test/api/notifications'); + assert.equal(calls[5].url, 'https://cloud.cowiki.test/api/notifications/unread-count'); + assert.equal(calls[6].url, 'https://cloud.cowiki.test/api/notifications/read-all'); +}); + test('Cloud mutations serialize the current contract and surface typed failures', async () => { const calls: Array<{ url: string; init?: RequestInit }> = []; const fakeFetch: CloudFetch = async (input, init) => { From c73bad96a55249224400d253afd425989ed73f3e Mon Sep 17 00:00:00 2001 From: Qinghao Huang Date: Fri, 4 Sep 2026 01:55:26 +0800 Subject: [PATCH 6/8] feat(wiki): render interactive HTML in a sandbox --- web/package.json | 3 +- web/src/components/HtmlView.tsx | 97 +++++++++++++++++++++++++++++++ web/src/components/PageReader.tsx | 6 +- web/src/lib/html-view.ts | 44 ++++++++++++++ web/tests/html-view.test.ts | 27 +++++++++ 5 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 web/src/components/HtmlView.tsx create mode 100644 web/src/lib/html-view.ts create mode 100644 web/tests/html-view.test.ts diff --git a/web/package.json b/web/package.json index bdda4a51..7ebe7419 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,7 @@ "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview", - "test": "npm run test:auth-flow && npm run test:versioned-document && npm run test:local-space && npm run test:sidebar-layout && npm run test:client-settings && npm run test:design-system && npm run test:page-frontmatter && npm run test:page-lineage && npm run test:agent-terminal && npm run test:agent-contract && npm run test:okf-pages && npm run test:workspace-navigation && npm run test:workspace-context && npm run test:window-chrome && npm run test:page-header && npm run test:source-ingest && npm run test:desktop-workflow && npm run test:history && npm run test:link-diagnostics && npm run test:cloud-sync && npm run test:cloud-client && npm run test:cloud-shell && npm run test:cloud-review-model && npm run test:review-preview && npm run test:skill-hosting && npm run test:cloud-space-dialog", + "test": "npm run test:auth-flow && npm run test:versioned-document && npm run test:local-space && npm run test:sidebar-layout && npm run test:client-settings && npm run test:design-system && npm run test:page-frontmatter && npm run test:page-lineage && npm run test:agent-terminal && npm run test:agent-contract && npm run test:okf-pages && npm run test:workspace-navigation && npm run test:workspace-context && npm run test:window-chrome && npm run test:page-header && npm run test:source-ingest && npm run test:html-view && npm run test:desktop-workflow && npm run test:history && npm run test:link-diagnostics && npm run test:cloud-sync && npm run test:cloud-client && npm run test:cloud-shell && npm run test:cloud-review-model && npm run test:review-preview && npm run test:skill-hosting && npm run test:cloud-space-dialog", "test:auth-flow": "node --experimental-strip-types --test tests/auth-flow.test.ts", "test:versioned-document": "node --experimental-strip-types --test tests/versioned-document.test.ts", "test:local-space": "node --experimental-strip-types --test tests/local-space.test.ts", @@ -25,6 +25,7 @@ "test:window-chrome": "node --experimental-strip-types --test tests/window-chrome.test.ts", "test:page-header": "node --experimental-strip-types --test tests/page-header.test.ts", "test:source-ingest": "node --experimental-strip-types --test tests/source-ingest.test.ts", + "test:html-view": "node --experimental-strip-types --test tests/html-view.test.ts", "test:history": "node --experimental-strip-types --test tests/history.test.ts", "test:link-diagnostics": "node --experimental-strip-types --test tests/link-diagnostics.test.ts", "test:desktop-workflow": "node --experimental-strip-types --test tests/macos-desktop-workflow.test.ts", diff --git a/web/src/components/HtmlView.tsx b/web/src/components/HtmlView.tsx new file mode 100644 index 00000000..e6a30ef9 --- /dev/null +++ b/web/src/components/HtmlView.tsx @@ -0,0 +1,97 @@ +import { useMemo, useState, type ComponentPropsWithoutRef } from 'react'; +import { Code2, Eye } from 'lucide-react'; +import type { Components } from 'react-markdown'; +import { C, fonts } from '../lib/design'; +import { isHtmlCodeLanguage, sandboxedHtmlDocument } from '../lib/html-view'; + +type CodeProps = ComponentPropsWithoutRef<'code'> & { + node?: unknown; +}; + +type PreProps = ComponentPropsWithoutRef<'pre'> & { + node?: unknown; +}; + +export function HtmlView({ source }: { source: string }) { + const [mode, setMode] = useState<'preview' | 'source'>('preview'); + const document = useMemo(() => sandboxedHtmlDocument(source), [source]); + return ( +
+
+ + HTML View + + setMode('preview')}> + Preview + + setMode('source')}> + Source + +
+ {mode === 'preview' ? ( +