diff --git a/web/package.json b/web/package.json index 7b9dbfe..3ef0ebf 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,7 @@ "lint": "eslint .", "test:browser": "playwright test", "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-comments-ui && 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": "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:page-comments-ui && 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", @@ -17,6 +17,8 @@ "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 && npm run test:page-lineage-ui", + "test:page-lineage-ui": "node --experimental-strip-types --test tests/page-lineage-ui.test.ts tests/page-lineage-panel.test.ts", "test:page-comments-ui": "node --experimental-strip-types --test tests/page-comments-ui.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", diff --git a/web/src-tauri/src/lib.rs b/web/src-tauri/src/lib.rs index fb966c7..a8f1567 100644 --- a/web/src-tauri/src/lib.rs +++ b/web/src-tauri/src/lib.rs @@ -334,9 +334,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 1c12cf3..2082f78 100644 --- a/web/src-tauri/src/local_engine.rs +++ b/web/src-tauri/src/local_engine.rs @@ -189,6 +189,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)] @@ -877,6 +902,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(), @@ -890,6 +919,7 @@ impl LocalEngine { body, edited_by: None, edited_at: None, + provenance, }) } @@ -1097,6 +1127,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, @@ -1110,6 +1144,7 @@ impl LocalEngine { body, edited_by: None, edited_at: None, + provenance, }) } @@ -1291,17 +1326,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 }) } @@ -1488,15 +1530,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 }) } @@ -2263,6 +2310,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 @@ -3673,6 +3832,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 3377648..64d3d6a 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 612095b..552119d 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 dde7daf..4e0f746 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'; import { CommentsHeaderToggle, CommentsPanel, @@ -37,6 +39,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 ?? '' : ''; @@ -72,6 +77,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 ? ( @@ -87,13 +105,17 @@ export function CloudWikiView({ source={splitSystemFrontmatter(currentContent.content).body} articleRef={articleRef} > - } body={splitSystemFrontmatter(currentContent.content).body} + lineage={pageLineage(currentContent.content, currentContent.provenance)} + onOpenSource={setSourcePath} + loadSource={(path) => client.getSourceContent(space.id, path)} + onOpenReview={(id) => navigate(cloudSpaceRoute(space.id, 'reviews', id))} articleRef={articleRef} markdownComponents={commentMarkdownComponents} aside={} @@ -102,6 +124,13 @@ export function CloudWikiView({ ) : tree && pages.length === 0 ? ( ) : null} + { if (!open) setSourcePath(''); }} + />
); } diff --git a/web/src/cloud/PublicCloudSpacePage.tsx b/web/src/cloud/PublicCloudSpacePage.tsx index d54311c..74acd1d 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,11 @@ 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 86607cb..313cfe6 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 { @@ -208,6 +210,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; @@ -297,6 +300,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/PageCommentsLayer.tsx b/web/src/components/PageCommentsLayer.tsx index a1c7f4d..d621349 100644 --- a/web/src/components/PageCommentsLayer.tsx +++ b/web/src/components/PageCommentsLayer.tsx @@ -104,6 +104,7 @@ interface CommentsCtx { anchored: Thread[]; outdated: Thread[]; resolved: Thread[]; openCount: number; activeId: string | null; setActive: (id: string) => void; panelOpen: boolean; setPanelOpen: (b: boolean) => void; + lineageOpen: boolean; setLineageOpen: (b: boolean) => void; composing: { start: number; end: number; quote: string } | null; cancelCompose: () => void; submitNew: (body: string) => Promise; members: CommentMember[]; nameOf: (id: string) => string; @@ -118,6 +119,13 @@ interface CommentsCtx { } const Ctx = createContext(null); +/** Share the right-hand slot with comments; standalone/public readers need no provider. */ +export function useReaderLineagePanel(): [boolean, (open: boolean) => void] { + const ctx = useContext(Ctx); + const local = useState(false); + return ctx ? [ctx.lineageOpen, ctx.setLineageOpen] : local; +} + interface CommentsProviderProps { store: PageCommentStore | null; pageSlug: string; @@ -135,7 +143,10 @@ function CommentsSession({ store, pageSlug, source, articleRef, children }: Comm const [snapshots, setSnapshots] = useState<{ content_hash: string; source: string }[]>([]); const [members, setMembers] = useState([]); const [activeId, setActiveId] = useState(null); - const [panelOpen, setPanelOpen] = useState(false); + const [panelOpen, setPanelOpenState] = useState(false); + const [lineageOpen, setLineageOpenState] = useState(false); + const setPanelOpen = (open: boolean) => { setPanelOpenState(open); if (open) setLineageOpenState(false); }; + const setLineageOpen = (open: boolean) => { setLineageOpenState(open); if (open) setPanelOpenState(false); }; 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 [loading, setLoading] = useState(true); @@ -267,6 +278,7 @@ function CommentsSession({ store, pageSlug, source, articleRef, children }: Comm anchored, outdated, resolved, openCount, activeId, setActive: setActiveId, panelOpen, setPanelOpen, + lineageOpen, setLineageOpen, composing, cancelCompose: () => { setComposing(null); setPending(null); }, submitNew, members, nameOf, currentUserId: store?.currentUserId, myId: store?.currentUserId ?? 'me', myName: store?.currentUserName ?? 'You', diff --git a/web/src/components/PageLineage.css b/web/src/components/PageLineage.css new file mode 100644 index 0000000..02105db --- /dev/null +++ b/web/src/components/PageLineage.css @@ -0,0 +1,23 @@ +.page-lineage-entry{margin-top:36px;padding-top:14px;border-top:1px solid var(--color-border-soft)} +.page-lineage-entry>button{display:flex;align-items:center;gap:10px;width:100%;padding:9px;border:0;background:transparent;text-align:left;color:var(--color-text-secondary);border-radius:6px;cursor:pointer} +.page-lineage-entry>button:hover{background:var(--color-bg-hover)} +.page-lineage-entry>button>span{flex:1;min-width:0}.page-lineage-entry strong{display:block;font-size:13px;font-weight:600}.page-lineage-entry small{display:block;margin-top:3px;color:var(--color-text-tertiary);font-size:12px;overflow-wrap:anywhere} +.page-lineage-panel{width:344px;max-width:100%;flex-shrink:0;display:flex;flex-direction:column;min-height:0;border-left:1px solid var(--color-border);background:var(--color-panel);color:var(--color-text-secondary);font-family:var(--font-sans);font-size:13px} +.page-lineage-panel button,.page-lineage-panel summary{cursor:pointer}.page-lineage-panel button{font:inherit} +.page-lineage-panel button:focus-visible,.page-lineage-panel summary:focus-visible,.page-lineage-entry button:focus-visible,.page-lineage-panel a:focus-visible{outline:2px solid var(--color-accent);outline-offset:3px} +.page-lineage-panel>header{display:flex;align-items:center;justify-content:space-between;padding:22px 22px 17px}.page-lineage-panel h2{font:600 15px/1.5 var(--font-sans);margin:0;color:var(--color-text)} +.page-lineage-panel>header button{background:none;border:0;padding:5px;color:var(--color-text-tertiary);border-radius:5px;display:grid;place-items:center}.page-lineage-panel>header button:hover{background:var(--color-bg-hover)} +.page-lineage-tabs{display:flex;gap:24px;padding:0 22px;border-bottom:1px solid var(--color-border)}.page-lineage-tabs button{border:0;border-bottom:2px solid transparent;padding:0 0 12px;background:none;color:var(--color-text-tertiary)}.page-lineage-tabs button[aria-selected=true]{border-bottom-color:var(--color-accent);color:var(--color-text);font-weight:600}.page-lineage-tabs small{font-size:11px;margin-left:4px} +.page-lineage-body{padding:20px 22px;overflow:auto;flex:1;min-height:0}.page-lineage-body[hidden]{display:none}.lineage-caption{font-size:12px;line-height:1.8;color:var(--color-text-tertiary);margin:0 0 20px} +.lineage-source{display:grid;grid-template-columns:17px minmax(0,1fr);gap:12px;margin-bottom:20px;padding-bottom:18px;border-bottom:1px solid var(--color-border-soft)}.lineage-source>svg{margin-top:3px;color:var(--color-text-tertiary)} +.lineage-source details>summary{font-size:13px;display:flex;align-items:flex-start;justify-content:space-between;gap:8px;font-weight:550;list-style:none;overflow-wrap:anywhere}.lineage-source summary::-webkit-details-marker{display:none}.lineage-source summary svg{flex-shrink:0;margin-top:3px}.lineage-source details[open]>summary svg{transform:rotate(90deg)} +.lineage-source-excerpt{margin-top:12px;padding:12px;background:var(--color-bg-secondary);border-radius:5px;white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;line-height:1.8;max-height:230px;overflow:auto} +.lineage-source-meta{color:var(--color-text-tertiary);font-size:11px;line-height:1.8;margin:6px 0;overflow-wrap:anywhere} +.lineage-source-actions{display:flex;gap:14px;flex-wrap:wrap;margin-top:9px}.lineage-source-actions button,.lineage-source-actions a,.lineage-text-action{display:inline-flex;align-items:center;gap:4px;font-size:12px!important;color:var(--color-accent-ink);background:none;border:0;padding:0;text-decoration:none;text-align:left}.lineage-source-actions button:hover,.lineage-source-actions a:hover,.lineage-text-action:hover{text-decoration:underline} +.lineage-source-error{font-size:12px;color:var(--color-red);overflow-wrap:anywhere}.lineage-source-error button{background:none;border:0;text-decoration:underline;color:inherit} +.lineage-technical{margin-top:16px;font-size:11px;color:var(--color-text-tertiary)}.lineage-source .lineage-technical>summary{font-size:11px;font-weight:400;display:list-item;list-style:disclosure-closed}.lineage-source .lineage-technical[open]>summary{list-style:disclosure-open} +.lineage-technical dl{padding:12px;border-radius:5px;background:var(--color-bg-secondary);margin:10px 0 0;font-size:11px}.lineage-technical dt{margin-top:8px}.lineage-technical dt:first-child{margin-top:0}.lineage-technical dd{margin:3px 0 0;overflow-wrap:anywhere;color:var(--color-text-secondary);font-family:var(--font-mono)} +.lineage-records{padding:0;margin:0;list-style:none}.lineage-records li{display:grid;grid-template-columns:17px minmax(0,1fr);gap:13px;padding-bottom:24px}.lineage-records li>svg{color:var(--color-text-tertiary);margin-top:3px}.lineage-records h3{font:600 13px/1.7 var(--font-sans);margin:0;color:var(--color-text)}.lineage-records p{font-size:12px;line-height:1.8;margin:6px 0;overflow-wrap:anywhere}.lineage-records time{font-size:11px;color:var(--color-text-tertiary)} +.page-lineage-panel footer{padding:14px 22px;border-top:1px solid var(--color-border-soft);font-size:11px;color:var(--color-text-tertiary)} +.page-reader{container-type:inline-size} +@container(max-width:760px){.page-lineage-panel{position:absolute;inset:0 0 0 auto;width:min(100%,344px);z-index:30;box-shadow:-10px 0 25px rgb(0 0 0 / 8%)}} diff --git a/web/src/components/PageLineage.tsx b/web/src/components/PageLineage.tsx new file mode 100644 index 0000000..b7c26ea --- /dev/null +++ b/web/src/components/PageLineage.tsx @@ -0,0 +1,122 @@ +import { useEffect, useId, useRef, useState, type Ref } from 'react'; +import { Bot, ChevronRight, ExternalLink, FileText, GitCommitHorizontal, GitPullRequest, Link2, X } from 'lucide-react'; +import type { PageLineage as PageLineageModel } from '@/lib/page-lineage'; +import { sourceFilename } from '@/lib/page-lineage'; +import { sourceUrlFromDocument, splitSystemFrontmatter } from '@/lib/page-frontmatter'; +import { openExternalUrl } from '@/external-links'; +import './PageLineage.css'; + +export interface LineageSource { title?: string; content: string } +export type LoadLineageSource = (path: string) => Promise; + +export function PageLineage({ lineage, open, onToggle, panelId, triggerRef }: { + lineage: PageLineageModel; open: boolean; onToggle: () => void; panelId: string; triggerRef?: Ref; +}) { + const who = lineage.agents.map(a => a.name).join(', ') || lineage.commit?.author; + return
+ +
; +} + +export function PageLineagePanel({ lineage, panelId, onClose, onOpenSource, onOpenReview, loadSource }: { + lineage: PageLineageModel; panelId: string; onClose: () => void; + onOpenSource?: (path: string) => void; onOpenReview?: (id: string) => void; loadSource?: LoadLineageSource; +}) { + const [tab, setTab] = useState<'sources' | 'records'>('sources'); + const [limit, setLimit] = useState(20); + const closeRef = useRef(null); + const sourcesRef = useRef(null); + const recordsRef = useRef(null); + const id = useId(); + useEffect(() => { closeRef.current?.focus(); }, []); + const records = !!lineage.commit || !!lineage.review || lineage.agents.length > 0; + return ; +} + +function formatTime(seconds: number) { + const date = new Date(seconds * 1000); + return Number.isFinite(date.getTime()) ? date.toLocaleString() : ''; +} +function scalar(document: string, field: string): string { + const line = splitSystemFrontmatter(document).systemFrontmatter.split(/\r?\n/).find(line => line.startsWith(field + ':')); + const raw = line?.slice(field.length + 1).trim() ?? ''; + if (raw.startsWith('"')) { try { const value: unknown = JSON.parse(raw); return typeof value === 'string' ? value : ''; } catch { return ''; } } + return raw.replace(/^'|'$/g, ''); +} +function SourceRow({ path, loadSource, onOpenSource }: { path: string; loadSource?: LoadLineageSource; onOpenSource?: (path: string) => void }) { + const [result, setResult] = useState(null); + const [error, setError] = useState(''); + const [attempt, setAttempt] = useState(0); + // Callers may recreate callbacks when comment state changes. Only a mounted + // source identity or an explicit retry starts another request. + const loader = useRef(loadSource); + useEffect(() => { + let active = true; + if (!loader.current) return; + void loader.current(path).then(value => { if (active) { setResult(value); setError(''); } }) + .catch(cause => { if (active) setError(cause instanceof Error ? cause.message : 'Could not load this Source.'); }); + return () => { active = false; }; + }, [path, attempt]); + const document = result?.content ?? ''; + const body = splitSystemFrontmatter(document).body; + const title = result?.title || scalar(document, 'title') || body.match(/^#\s+(.+)$/m)?.[1] || sourceFilename(path).replace(/\.md$/i, '').replace(/[_-]+/g, ' '); + const url = sourceUrlFromDocument(document); + const capturedAt = scalar(document, 'captured_at'); + return
+ +
+
{title} +
{result ? body.slice(0, 600) || 'This Source has no text content.' : !loadSource ? 'Source content is not available in this view.' : error ? 'Source content could not be loaded.' : 'Loading Source…'}
+
+

{url ? new URL(url).hostname : 'Markdown Source'}{capturedAt ? ` · Captured ${capturedAt}` : ''}

+ {error &&
{error}
} + +
Technical details
Source path
{path}
+ {scalar(document, 'content_hash') && <>
Content hash
{scalar(document, 'content_hash')}
} + {scalar(document, 'extractor') && <>
Extractor
{scalar(document, 'extractor')}
} +
+
+
; +} diff --git a/web/src/components/PageReader.tsx b/web/src/components/PageReader.tsx index 6b7ac00..45d7d48 100644 --- a/web/src/components/PageReader.tsx +++ b/web/src/components/PageReader.tsx @@ -1,8 +1,11 @@ -import type { Ref, ReactNode } from 'react'; +import { useId, useRef, type Ref, type ReactNode } from 'react'; import ReactMarkdown, { type Components } from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { C } from '@/lib/design'; import { PageByline } from './PageByline'; +import { PageLineage, PageLineagePanel, type LoadLineageSource } from './PageLineage'; +import { useReaderLineagePanel } from './PageCommentsLayer'; +import type { PageLineage as PageLineageModel } from '@/lib/page-lineage'; import { withHtmlMarkdownComponents } from './HtmlView'; import { useMemo } from 'react'; @@ -18,6 +21,11 @@ interface PageReaderProps { readOnlyDotColor?: string; missingMessage?: string; aside?: ReactNode; + toolbar?: ReactNode; + lineage?: PageLineageModel; + onOpenSource?: (path: string) => void; + onOpenReview?: (id: string) => void; + loadSource?: LoadLineageSource; } export function PageReader({ @@ -29,10 +37,21 @@ export function PageReader({ readOnlyDotColor = C.blue, missingMessage, aside, + toolbar, + lineage, + onOpenSource, + onOpenReview, + loadSource, }: PageReaderProps) { + const [lineageOpen, setLineageOpen] = useReaderLineagePanel(); + const lineageId = useId(); + const lineageTrigger = useRef(null); + const closeLineage = () => { setLineageOpen(false); lineageTrigger.current?.focus(); }; const components = useMemo(() => withHtmlMarkdownComponents(markdownComponents), [markdownComponents]); return ( -
+
+ {toolbar &&
{toolbar}
} +
{body} + {lineage && ( + setLineageOpen(!lineageOpen)} + panelId={lineageId} + triggerRef={lineageTrigger} + /> + )} )}
+ {lineage && lineageOpen && !missingMessage && } {aside} +
); } diff --git a/web/src/components/SourcePreviewDialog.tsx b/web/src/components/SourcePreviewDialog.tsx new file mode 100644 index 0000000..3fcbc1c --- /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 8c4884d..9c987b9 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 0000000..f8992b6 --- /dev/null +++ b/web/src/lib/page-lineage.ts @@ -0,0 +1,85 @@ +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 { sourceUrlFromDocument as sourceOriginalUrl } from './page-frontmatter.ts'; + +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 4689a9e..42c2f27 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 bd20506..0d035a4 100644 --- a/web/src/local-api.ts +++ b/web/src/local-api.ts @@ -126,8 +126,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 841829c..2959b20 100644 --- a/web/src/pages/MainLayout.tsx +++ b/web/src/pages/MainLayout.tsx @@ -84,6 +84,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 { workspaceContextStatus } from '@/lib/workspace-context'; @@ -1431,8 +1432,19 @@ export function MainLayout() { // panel is flush to the right edge and full height with its own // scroll. Opening it shrinks the doc from the right only. { + if (activeWorkspace) void selectSource(activeWorkspace, sourceFilename(path)); + }} + loadSource={versionSelection.kind === 'working' && activeWorkspace + ? (path) => getSource(activeWorkspace.slug, sourceFilename(path)) + : undefined} markdownComponents={commentMarkdownComponents} byline={versionSelection.kind === 'working' ? { name: activeView.content.edited_by, diff --git a/web/tests/browser/page-lineage.spec.ts b/web/tests/browser/page-lineage.spec.ts new file mode 100644 index 0000000..88fc1d6 --- /dev/null +++ b/web/tests/browser/page-lineage.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from '@playwright/test'; +import { createServer, type ViteDevServer } from 'vite'; +import { fileURLToPath } from 'node:url'; +let vite: ViteDevServer; +let origin: string; +test.beforeAll(async () => { + vite = await createServer({ root: fileURLToPath(new URL('../../', import.meta.url)), logLevel: 'silent', server: { host: '127.0.0.1', port: 0 } }); + await vite.listen(); + const address = vite.httpServer!.address(); + if (!address || typeof address === 'string') throw new Error('Missing test port'); + origin = 'http://127.0.0.1:' + address.port; +}); +test.afterAll(async () => { await vite.close(); }); + +test('real reader keeps lineage beside prose, coordinates comments and isolates HTML', async ({ page }) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto(origin + '/tests/fixtures/lineage-reader.html'); + const trigger = page.getByRole('button', { name: /Sources & records/ }); + await expect(page.getByRole('complementary', { name: 'Sources & records' })).toHaveCount(0); + await trigger.click(); + const panel = page.getByRole('complementary', { name: 'Sources & records' }); + await expect(panel.getByText('Team discussion: knowledge ownership')).toBeVisible(); + const articleBox = await page.locator('article.prose').boundingBox(); + const panelBox = await panel.boundingBox(); + expect(panelBox!.x).toBeGreaterThanOrEqual(articleBox!.x + articleBox!.width - 1); + const commentToggleBox = await page.getByTitle('Show comments').boundingBox(); + expect(commentToggleBox!.y + commentToggleBox!.height).toBeLessThanOrEqual(panelBox!.y); + await panel.getByRole('tab', { name: 'Records' }).click(); + await expect(panel.getByText('Organize the team discussion into portable knowledge.')).toBeVisible(); + await panel.getByRole('button', { name: /Review: Knowledge/ }).click(); + await expect(page.locator('output')).toHaveText('review-1'); + await panel.getByRole('tabpanel', { name: 'Records' }).getByText('Technical details').click(); + await expect(panel.getByText('0123456789abcdef')).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(panel).toHaveCount(0); + await expect(trigger).toBeFocused(); + await page.getByTitle('Show comments').click(); + await expect(page.locator('#page-comments-panel')).toBeVisible(); + await trigger.click(); + await expect(page.locator('#page-comments-panel')).toHaveCount(0); + await expect(panel).toBeVisible(); + await page.getByTitle('Show comments').click(); + await expect(panel).toHaveCount(0); + await page.getByTitle('Hide comments').click(); + await expect(panel).toHaveCount(0); + const html = page.frameLocator('iframe[title="Sandboxed HTML preview"]').frameLocator('iframe'); + await html.getByRole('button', { name: 'Try HTML' }).click(); + await expect(html.getByRole('button', { name: 'Interactive success' })).toBeVisible(); + await trigger.click(); + await page.getByRole('button', { name: 'Switch document' }).click(); + await expect(panel).toHaveCount(0); + await page.getByRole('button', { name: /Sources & records/ }).click(); + await expect(page.getByText(/No sources linked/)).toBeVisible(); + await expect(page.getByText('Team discussion: knowledge ownership')).toHaveCount(0); +}); + +test('narrow reader keeps close and tabs reachable without horizontal overflow', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto(origin + '/tests/fixtures/lineage-reader.html'); + await page.getByRole('button', { name: /Sources & records/ }).click(); + const panel = page.getByRole('complementary', { name: 'Sources & records' }); + const box = await panel.boundingBox(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(390); + await expect(panel.getByRole('button', { name: 'Close sources and records' })).toBeVisible(); + await panel.getByRole('tab', { name: 'Records' }).click(); + await expect(panel.getByText('Codex', { exact: true })).toBeVisible(); +}); diff --git a/web/tests/cloud-client.test.ts b/web/tests/cloud-client.test.ts index 0e70c58..79a2022 100644 --- a/web/tests/cloud-client.test.ts +++ b/web/tests/cloud-client.test.ts @@ -389,3 +389,21 @@ test('pull request diff is fetched from the reviewed head endpoint', async () => `https://cloud.cowiki.test/api/spaces/${spaceId}/pull-requests/33333333-3333-4333-8333-333333333333/diff`, ); }); + +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`, + ]); +}); diff --git a/web/tests/fixtures/lineage-reader.html b/web/tests/fixtures/lineage-reader.html new file mode 100644 index 0000000..f891fd7 --- /dev/null +++ b/web/tests/fixtures/lineage-reader.html @@ -0,0 +1 @@ +CoWiki lineage integration fixture
diff --git a/web/tests/fixtures/lineage-reader.tsx b/web/tests/fixtures/lineage-reader.tsx new file mode 100644 index 0000000..c9d5f72 --- /dev/null +++ b/web/tests/fixtures/lineage-reader.tsx @@ -0,0 +1,35 @@ +import React, { useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { PageReader } from '../../src/components/PageReader'; +import { CommentsProvider, CommentsPanel, CommentsHeaderToggle, commentMarkdownComponents } from '../../src/components/PageCommentsLayer'; +import type { PageCommentStore } from '../../src/lib/page-comment-store'; +import '../../src/index.css'; + +const store: PageCommentStore = { + key: 'lineage-fixture', scope: 'local', scopeLabel: 'Local', currentUserId: 'reader', currentUserName: 'Reader', + list: async () => ({ comments: [], snapshots: [] }), listMembers: async () => [], + create: async () => { throw new Error('Read-only fixture'); }, + setResolved: async () => undefined, delete: async () => undefined, +}; +const evidence = { sources: ['.cowiki/sources/interview.md'], agents: [{ name: 'Codex', changeId: 'change-1', task: 'Organize the team discussion into portable knowledge.' }], commit: { oid: '0123456789abcdef', author: 'Qinghao', summary: 'Document local-first collaboration', committedAt: 1789182846 }, review: { id: 'review-1', number: 12, title: 'Knowledge ownership and collaboration' } }; +function Fixture() { + const articleRef = useRef(null); + const [other, setOther] = useState(false); + const [opened, setOpened] = useState(''); + return
+
+ CoWiki · Demo{opened} +
+ +
+ } + body={other ? '# Another document' : '# Local-first collaboration\n\nKnowledge stays with the people who create it. A Space is an ordinary folder of Markdown files, with Git recording its changes.\n\n## Keep the reading experience focused\n\nSources and records stay out of the way until the reader needs to verify a claim.\n\n## Review before sharing\n\nAgents organize material. People choose what belongs in shared knowledge.\n\n```html\n\n```'} + lineage={other ? { sources: [], agents: [], commit: null, review: null } : evidence} + loadSource={async () => ({ title: 'Team discussion: knowledge ownership', content: '---\nsource_url: https://example.com/notes\ncaptured_at: 2026-09-12\ncontent_hash: test-hash\n---\n# Team discussion\n\nRetain local ownership while sharing selected changes.' })} + onOpenSource={path => setOpened(path)} onOpenReview={id => setOpened(id)} aside={} /> +
+
+
; +} +createRoot(document.getElementById('root')!).render(); diff --git a/web/tests/page-lineage-panel.test.ts b/web/tests/page-lineage-panel.test.ts new file mode 100644 index 0000000..8478ce1 --- /dev/null +++ b/web/tests/page-lineage-panel.test.ts @@ -0,0 +1,79 @@ +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import React, { act } from 'react'; +import { createServer, type ViteDevServer } from 'vite'; +import { JSDOM } from 'jsdom'; + +let vite: ViteDevServer; +test.before(async () => { vite = await createServer({ root: fileURLToPath(new URL('../', import.meta.url)), appType: 'custom', logLevel: 'silent', server: { middlewareMode: true } }); }); +test.after(async () => { await vite.close(); }); +const lineage = { + sources: ['.cowiki/sources/interview.md'], + agents: [{ name: 'Codex', changeId: 'change-1', task: 'Organize interview' }], + commit: { oid: '0123456789abcdef', summary: 'Compile interview', author: 'Qinghao', committedAt: 1725000000 }, + review: { id: 'review-id', number: 12, title: 'Compile interview' }, +}; +async function mount(props: Record = {}) { + const dom = new JSDOM('
', { url: 'http://localhost' }); + Object.assign(globalThis, { window: dom.window, document: dom.window.document, HTMLElement: dom.window.HTMLElement, Node: dom.window.Node, IS_REACT_ACT_ENVIRONMENT: true }); + const { createRoot } = await import('react-dom/client'); + const { PageReader } = await vite.ssrLoadModule('/src/components/PageReader.tsx'); + const host = document.getElementById('root')!; + const root = createRoot(host); + await act(async () => root.render(React.createElement(PageReader, { body: '# Durable knowledge', lineage, ...props }))); + const click = async (name: string) => { + const button = [...host.querySelectorAll('button')].find(b => b.textContent?.includes(name) || b.getAttribute('aria-label') === name); + assert.ok(button, 'Missing action: ' + name); + await act(async () => button.click()); + }; + return { host, click, cleanup: async () => { await act(async () => root.unmount()); dom.window.close(); } }; +} +test('lineage opens beside the article and closes back to reading', async () => { + const opened: string[] = []; + const ui = await mount({ onOpenReview: (id: string) => opened.push(id) }); + try { + assert.equal(ui.host.querySelector('aside'), null); + assert.doesNotMatch(ui.host.textContent!, /0123456789abcdef|change-1|review-id/); + await ui.click('Sources & records'); + const panel = ui.host.querySelector('aside[aria-label="Sources & records"]'); + assert.ok(panel, 'side panel appears on demand'); + assert.ok(!ui.host.querySelector('article')!.contains(panel)); + await ui.click('Records'); + assert.match(panel.textContent!, /Codex/); + assert.match(panel.textContent!, /Organize interview/); + await ui.click('Review: Compile interview'); + assert.deepEqual(opened, ['review-id']); + await ui.click('Close sources and records'); + assert.equal(ui.host.querySelector('aside'), null); + assert.equal(document.activeElement?.getAttribute('aria-expanded'), 'false'); + } finally { await ui.cleanup(); } +}); +test('sources load on demand and show readable titles with retryable errors', async () => { + let calls = 0; + const ui = await mount({ loadSource: async (path: string) => { + assert.equal(path, '.cowiki/sources/interview.md'); + calls++; + if (calls === 1) throw new Error('Source unavailable'); + return { title: 'Interview with the team', content: '---\nsource_url: https://example.com/interview\ncaptured_at: 2026-09-12\n---\n# Interview\n\nEvidence from the team.' }; + } }); + try { + assert.equal(calls, 0); + await ui.click('Sources & records'); + assert.match(ui.host.textContent!, /Source unavailable/); + await ui.click('Retry'); + assert.match(ui.host.textContent!, /Interview with the team/); + assert.match(ui.host.textContent!, /example.com/); + assert.equal(ui.host.querySelector('a[target="_blank"]')?.getAttribute('href'), 'https://example.com/interview'); + } finally { await ui.cleanup(); } +}); +test('empty provenance never fabricates history', async () => { + const ui = await mount({ lineage: { sources: [], agents: [], commit: null, review: null } }); + try { + await ui.click('Sources & records'); + assert.match(ui.host.textContent!, /No sources linked/); + await ui.click('Records'); + assert.match(ui.host.textContent!, /No recorded changes/); + assert.doesNotMatch(ui.host.textContent!, /Created|Approved|Qinghao|Codex/); + } finally { await ui.cleanup(); } +}); diff --git a/web/tests/page-lineage-ui.test.ts b/web/tests/page-lineage-ui.test.ts new file mode 100644 index 0000000..b34a43a --- /dev/null +++ b/web/tests/page-lineage-ui.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { createServer, type ViteDevServer } from 'vite'; + +const webRoot = fileURLToPath(new URL('../', import.meta.url)); +let vite: ViteDevServer; + +test.before(async () => { + vite = await createServer({ root: webRoot, appType: 'custom', logLevel: 'silent', server: { middlewareMode: true } }); +}); + +test.after(async () => { + await vite.close(); +}); + +test('page lineage stays collapsed after the document until requested', async () => { + const { PageReader } = await vite.ssrLoadModule('/src/components/PageReader.tsx'); + const html = renderToStaticMarkup(React.createElement(PageReader, { + body: '# Durable knowledge\n\nThe page stays focused on reading.', + lineage: { + sources: ['.cowiki/sources/interview.md'], + agents: [{ name: 'Codex', changeId: 'change-1', task: 'Organize interview' }], + commit: { oid: '0123456789abcdef', summary: 'Compile interview', author: 'Qinghao', committedAt: 1_725_000_000 }, + review: { id: 'review-id', number: 12, title: 'Compile interview' }, + }, + })); + + assert.match(html, /aria-expanded="false"/); + assert.doesNotMatch(html, /