diff --git a/crates/sprout-core/src/kind.rs b/crates/sprout-core/src/kind.rs
index 21cd2c9cd..9a50070df 100644
--- a/crates/sprout-core/src/kind.rs
+++ b/crates/sprout-core/src/kind.rs
@@ -84,6 +84,8 @@ pub const KIND_STREAM_MESSAGE_BOOKMARKED: u32 = 40005;
pub const KIND_STREAM_MESSAGE_SCHEDULED: u32 = 40006;
/// A reminder attached to a stream message or time.
pub const KIND_STREAM_REMINDER: u32 = 40007;
+/// A diff/patch message showing file changes (unified diff format).
+pub const KIND_STREAM_MESSAGE_DIFF: u32 = 40008;
/// Canvas (shared document) for a channel.
pub const KIND_CANVAS: u32 = 40100;
/// System message for channel state changes (join, leave, rename, etc.).
@@ -226,6 +228,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_STREAM_MESSAGE_BOOKMARKED,
KIND_STREAM_MESSAGE_SCHEDULED,
KIND_STREAM_REMINDER,
+ KIND_STREAM_MESSAGE_DIFF,
KIND_CANVAS,
KIND_SYSTEM_MESSAGE,
KIND_DM_CREATED,
diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs
index 98d3c4120..da4b6033b 100644
--- a/crates/sprout-mcp/src/server.rs
+++ b/crates/sprout-mcp/src/server.rs
@@ -128,7 +128,7 @@ pub struct CreateWorkflowParams {
/// UUID of the channel to own this workflow.
pub channel_id: String,
/// Full workflow definition in YAML format. Required fields: name (string), trigger (object with
- /// 'on' field: 'message_posted', 'reaction_added', or 'webhook'), steps (array).
+ /// 'on' field: 'message_posted', 'diff_posted', 'reaction_added', or 'webhook'), steps (array).
/// Each step needs: id (alphanumeric/underscore), action (e.g. 'send_message'), and action-specific
/// fields as direct properties (NOT nested under 'params'). Example:
/// ```yaml
@@ -475,6 +475,130 @@ pub struct GetFeedActionsParams {
pub limit: Option,
}
+/// Parameters for the `send_diff_message` tool.
+#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)]
+pub struct SendDiffMessageParams {
+ /// UUID of the channel to post to.
+ pub channel_id: String,
+ /// Unified diff content (git diff format).
+ pub diff: String,
+ /// URL of the source repository (e.g. "https://github.com/org/repo").
+ pub repo_url: String,
+ /// Full commit SHA this diff applies to.
+ pub commit_sha: String,
+ /// Optional file path within the repo (used for language inference and display).
+ #[serde(default)]
+ pub file_path: Option,
+ /// Optional parent commit SHA (the base of the diff).
+ #[serde(default)]
+ pub parent_commit_sha: Option,
+ /// Optional source branch name (e.g. "feat/my-feature").
+ #[serde(default)]
+ pub source_branch: Option,
+ /// Optional target branch name (e.g. "main").
+ #[serde(default)]
+ pub target_branch: Option,
+ /// Optional pull request number associated with this diff.
+ #[serde(default)]
+ pub pr_number: Option,
+ /// Optional language hint for syntax highlighting (e.g. "rust", "typescript").
+ /// Inferred from file_path extension if omitted.
+ #[serde(default)]
+ pub language: Option,
+ /// Optional human-readable description of the change.
+ #[serde(default)]
+ pub description: Option,
+ /// Optional parent event ID. If provided, sends the diff as a threaded reply.
+ #[serde(default)]
+ pub parent_event_id: Option,
+}
+
+// ── Diff utility functions ────────────────────────────────────────────────────
+
+// Truncation notice appended when a diff is cut. This constant is used to
+// reserve space so the final result never exceeds max_bytes.
+// NOTE: This function is only called with max_bytes = 60 * 1024, so the
+// hardcoded "60KB" in the notice is intentional and always accurate.
+const TRUNCATION_NOTICE: &str =
+ "\n\\ Diff truncated at 60KB. Full diff available at the source repository.";
+
+/// Truncate a diff to at most `max_bytes` bytes, cutting at a hunk boundary
+/// where possible. Returns the (possibly truncated) string and a flag indicating
+/// whether truncation occurred.
+///
+/// The truncation notice is included within the `max_bytes` budget — the
+/// returned string is guaranteed to be `<= max_bytes` in length.
+fn truncate_diff(diff: &str, max_bytes: usize) -> (String, bool) {
+ debug_assert!(
+ max_bytes >= TRUNCATION_NOTICE.len(),
+ "max_bytes ({max_bytes}) must be >= TRUNCATION_NOTICE length ({})",
+ TRUNCATION_NOTICE.len()
+ );
+
+ if diff.len() <= max_bytes {
+ return (diff.to_string(), false);
+ }
+
+ // Reserve space for the truncation notice so the final result stays within max_bytes.
+ let effective_limit = max_bytes.saturating_sub(TRUNCATION_NOTICE.len());
+
+ // Step 1: Find the last UTF-8 char boundary at or before effective_limit
+ let utf8_boundary = diff
+ .char_indices()
+ .map(|(i, _)| i)
+ .take_while(|&i| i <= effective_limit)
+ .last()
+ .unwrap_or(0);
+
+ // Step 2: Within safe prefix, find last complete hunk boundary
+ let safe_prefix = &diff[..utf8_boundary];
+ let last_hunk_start = safe_prefix.rfind("\n@@");
+
+ let cut_point = match last_hunk_start {
+ Some(pos) if pos > 0 => pos,
+ _ => safe_prefix.rfind('\n').unwrap_or(utf8_boundary),
+ };
+
+ let mut result = diff[..cut_point].to_string();
+ result.push_str(TRUNCATION_NOTICE);
+ (result, true)
+}
+
+/// Infer a language name from a file path's extension for syntax highlighting.
+/// Returns `None` if the extension is unknown or absent.
+fn infer_language(file_path: &str) -> Option {
+ // Note: rsplit always yields at least one element (the full string if no '.' found),
+ // so .next() always returns Some. The ? is effectively a no-op here.
+ let ext = file_path.rsplit('.').next()?;
+ let lang = match ext {
+ "rs" => "rust",
+ "ts" | "tsx" => "typescript",
+ "js" | "jsx" => "javascript",
+ "py" => "python",
+ "go" => "go",
+ "java" => "java",
+ "rb" => "ruby",
+ "c" | "h" => "c",
+ "cpp" | "cc" | "cxx" | "hpp" => "cpp",
+ "cs" => "csharp",
+ "swift" => "swift",
+ "kt" | "kts" => "kotlin",
+ "scala" => "scala",
+ "sh" | "bash" | "zsh" => "bash",
+ "sql" => "sql",
+ "html" | "htm" => "html",
+ "css" | "scss" | "sass" => "css",
+ "json" => "json",
+ "yaml" | "yml" => "yaml",
+ "toml" => "toml",
+ "xml" => "xml",
+ "md" | "markdown" => "markdown",
+ "dockerfile" => "dockerfile",
+ _ => return None,
+ };
+ Some(lang.to_string())
+}
+
/// The MCP server that exposes Sprout relay functionality as tools.
#[derive(Clone)]
pub struct SproutMcpServer {
@@ -531,6 +655,103 @@ impl SproutMcpServer {
}
}
+ /// Send a code diff to a Sprout channel as kind:40008.
+ #[tool(
+ name = "send_diff_message",
+ description = "Send a code diff to a Sprout channel with syntax highlighting and structured metadata. The diff is rendered with GitHub-quality visualization in the desktop client."
+ )]
+ pub async fn send_diff_message(
+ &self,
+ Parameters(p): Parameters,
+ ) -> String {
+ let SendDiffMessageParams {
+ channel_id,
+ diff,
+ repo_url,
+ commit_sha,
+ file_path,
+ parent_commit_sha,
+ source_branch,
+ target_branch,
+ pr_number,
+ language,
+ description,
+ parent_event_id,
+ } = p;
+
+ if let Err(e) = validate_uuid(&channel_id) {
+ return format!("Error: {e}");
+ }
+
+ // 1. Truncate diff at 60KB (UTF-8 safe)
+ let (diff_content, truncated) = truncate_diff(&diff, 60 * 1024);
+
+ // 2. Infer language from file extension if not provided
+ let lang = language.or_else(|| file_path.as_deref().and_then(infer_language));
+
+ // 3. Build NIP-31 alt tag
+ let alt_text = match &description {
+ Some(desc) => format!(
+ "Diff: {} — {}",
+ file_path.as_deref().unwrap_or("diff"),
+ desc
+ ),
+ None => format!("Diff: {}", file_path.as_deref().unwrap_or("diff")),
+ };
+
+ // 4. Build JSON body for REST endpoint
+ let mut body = serde_json::json!({
+ "content": diff_content,
+ "kind": 40008_u32,
+ "broadcast_to_channel": false,
+ "diff_repo_url": repo_url,
+ "diff_commit_sha": commit_sha,
+ "diff_alt": alt_text,
+ });
+ if let Some(ref parent) = parent_event_id {
+ body["parent_event_id"] = serde_json::Value::String(parent.clone());
+ }
+ if let Some(ref file) = file_path {
+ body["diff_file_path"] = serde_json::Value::String(file.clone());
+ }
+ if let Some(ref sha) = parent_commit_sha {
+ body["diff_parent_commit_sha"] = serde_json::Value::String(sha.clone());
+ }
+ // Branch metadata — both source and target must be provided together
+ match (&source_branch, &target_branch) {
+ (Some(ref src), Some(ref tgt)) => {
+ body["diff_source_branch"] = serde_json::Value::String(src.clone());
+ body["diff_target_branch"] = serde_json::Value::String(tgt.clone());
+ }
+ (Some(_), None) | (None, Some(_)) => {
+ // Warn caller that partial branch metadata is discarded
+ tracing::warn!("send_diff_message: only one of source_branch/target_branch provided — both required, branch metadata omitted");
+ }
+ (None, None) => {} // Both absent — no branch tag
+ }
+ if let Some(pr) = pr_number {
+ body["diff_pr_number"] = serde_json::json!(pr);
+ }
+ if let Some(ref l) = lang {
+ body["diff_language"] = serde_json::Value::String(l.clone());
+ }
+ if let Some(ref desc) = description {
+ body["diff_description"] = serde_json::Value::String(desc.clone());
+ }
+ if truncated {
+ body["diff_truncated"] = serde_json::json!(true);
+ }
+
+ match self
+ .client
+ .post(&format!("/api/channels/{}/messages", channel_id), &body)
+ .await
+ {
+ Ok(b) => b,
+ Err(e) => format!("Error: {e}"),
+ }
+ }
+
/// Get recent messages from a Sprout channel.
#[tool(
name = "get_channel_history",
@@ -1555,3 +1776,102 @@ mod tests {
assert_eq!(MAX_CONTENT_BYTES, 65_536);
}
}
+
+#[cfg(test)]
+mod diff_tests {
+ use super::*;
+
+ #[test]
+ fn truncate_diff_small_passes_through() {
+ let diff = "--- a/file\n+++ b/file\n@@ -1,3 +1,3 @@\n context\n-old\n+new\n";
+ let (result, truncated) = truncate_diff(diff, 60 * 1024);
+ assert_eq!(result, diff);
+ assert!(!truncated);
+ }
+
+ #[test]
+ fn truncate_diff_cuts_at_hunk_boundary() {
+ // Build a diff large enough that truncation is meaningful.
+ // Repeat the first hunk many times so the total is well above any
+ // reasonable max_bytes, then append a second hunk we want excluded.
+ let hunk_unit = "--- a/file\n+++ b/file\n@@ -1,3 +1,3 @@\n context\n-old\n+new\n";
+ let mut diff = hunk_unit.repeat(20); // ~1140 bytes of first-hunk content
+ diff.push_str("@@ -10,3 +10,3 @@\n more context\n-old2\n+new2\n");
+
+ // max_bytes sits inside the repeated first-hunk region (well below total)
+ // but above TRUNCATION_NOTICE.len() so effective_limit > 0.
+ // effective_limit = max_bytes - TRUNCATION_NOTICE.len() ≈ 500 - 72 = 428,
+ // which lands inside the repeated first-hunk block.
+ let max_bytes = 500;
+ let (result, truncated) = truncate_diff(&diff, max_bytes);
+ assert!(truncated);
+ assert!(
+ result.contains("context"),
+ "should contain first-hunk content"
+ );
+ assert!(result.contains("Diff truncated"));
+ assert!(
+ !result.contains("@@ -10,3"),
+ "second hunk should be excluded"
+ );
+ // Result must not exceed max_bytes.
+ assert!(
+ result.len() <= max_bytes,
+ "truncated result ({}) exceeds max_bytes ({})",
+ result.len(),
+ max_bytes
+ );
+ }
+
+ #[test]
+ fn truncate_diff_utf8_safe() {
+ // Create a diff with multi-byte chars near the boundary
+ let mut diff = String::from("--- a/file\n+++ b/file\n@@ -1,1 +1,1 @@\n-");
+ // Add enough content to exceed a small limit, with multi-byte chars
+ for _ in 0..100 {
+ diff.push('日'); // 3-byte UTF-8 char
+ }
+ diff.push('\n');
+ let (result, truncated) = truncate_diff(&diff, 80);
+ assert!(truncated);
+ // Must not panic and must produce valid UTF-8
+ assert!(result.is_char_boundary(result.len()));
+ }
+
+ #[test]
+ fn truncate_diff_result_within_limit() {
+ let mut diff = String::new();
+ for i in 0..2000 {
+ diff.push_str(&format!(
+ "@@ -{i},1 +{i},1 @@\n-old line {i}\n+new line {i}\n"
+ ));
+ }
+ let max = 1024;
+ let (result, truncated) = truncate_diff(&diff, max);
+ assert!(truncated);
+ assert!(
+ result.len() <= max,
+ "truncated result ({}) exceeds max_bytes ({})",
+ result.len(),
+ max
+ );
+ }
+
+ #[test]
+ fn infer_language_known_extensions() {
+ assert_eq!(infer_language("src/main.rs"), Some("rust".to_string()));
+ assert_eq!(infer_language("app.tsx"), Some("typescript".to_string()));
+ assert_eq!(infer_language("script.py"), Some("python".to_string()));
+ assert_eq!(infer_language("Makefile"), None);
+ }
+
+ #[test]
+ fn infer_language_no_extension() {
+ assert_eq!(infer_language("Dockerfile"), None);
+ // But "foo.dockerfile" should match
+ assert_eq!(
+ infer_language("foo.dockerfile"),
+ Some("dockerfile".to_string())
+ );
+ }
+}
diff --git a/crates/sprout-relay/src/api/messages.rs b/crates/sprout-relay/src/api/messages.rs
index 87192106e..acb6f0c16 100644
--- a/crates/sprout-relay/src/api/messages.rs
+++ b/crates/sprout-relay/src/api/messages.rs
@@ -102,6 +102,137 @@ fn reactions_to_json(reactions: &[sprout_db::reaction::ReactionSummary]) -> serd
// ── POST /api/channels/:channel_id/messages ───────────────────────────────────
+/// Build Nostr tags for a kind:40008 diff message.
+///
+/// Required fields: `diff_repo_url` (must be http/https) and `diff_commit_sha`.
+/// All other diff fields are optional.
+fn build_diff_tags(
+ body: &SendMessageBody,
+) -> Result, (StatusCode, axum::Json)> {
+ // repo-url is required and must be http/https
+ let repo_url = body.diff_repo_url.as_deref().ok_or_else(|| {
+ api_error(
+ StatusCode::BAD_REQUEST,
+ "diff_repo_url is required for kind:40008",
+ )
+ })?;
+ if !repo_url.starts_with("http://") && !repo_url.starts_with("https://") {
+ return Err(api_error(
+ StatusCode::BAD_REQUEST,
+ "diff_repo_url must use http or https scheme",
+ ));
+ }
+
+ // commit-sha is required and must be a valid hex string (min 7 chars for short SHA)
+ let commit_sha = body
+ .diff_commit_sha
+ .as_deref()
+ .filter(|s| !s.is_empty())
+ .ok_or_else(|| {
+ api_error(
+ StatusCode::BAD_REQUEST,
+ "diff_commit_sha is required for kind=40008",
+ )
+ })?;
+ if commit_sha.len() < 7 || !commit_sha.chars().all(|c| c.is_ascii_hexdigit()) {
+ return Err(api_error(
+ StatusCode::BAD_REQUEST,
+ "diff_commit_sha must be a hex string (min 7 chars)",
+ ));
+ }
+
+ let mut tags: Vec = Vec::new();
+
+ tags.push(
+ Tag::parse(&["repo", repo_url])
+ .map_err(|e| internal_error(&format!("tag build error: {e}")))?,
+ );
+ tags.push(
+ Tag::parse(&["commit", commit_sha])
+ .map_err(|e| internal_error(&format!("tag build error: {e}")))?,
+ );
+
+ if let Some(ref file_path) = body.diff_file_path {
+ tags.push(
+ Tag::parse(&["file", file_path])
+ .map_err(|e| internal_error(&format!("tag build error: {e}")))?,
+ );
+ }
+
+ if let Some(parent) = body
+ .diff_parent_commit_sha
+ .as_deref()
+ .filter(|s| !s.is_empty())
+ {
+ if parent.len() < 7 || !parent.chars().all(|c| c.is_ascii_hexdigit()) {
+ return Err(api_error(
+ StatusCode::BAD_REQUEST,
+ "diff_parent_commit_sha must be a hex string (min 7 chars)",
+ ));
+ }
+ tags.push(
+ Tag::parse(&["parent-commit", parent])
+ .map_err(|e| internal_error(&format!("tag build error: {e}")))?,
+ );
+ }
+
+ // Both source and target branch are required together
+ match (&body.diff_source_branch, &body.diff_target_branch) {
+ (Some(src), Some(tgt)) => {
+ tags.push(
+ Tag::parse(&["branch", src, tgt])
+ .map_err(|e| internal_error(&format!("tag build error: {e}")))?,
+ );
+ }
+ (None, None) => {}
+ _ => {
+ return Err(api_error(
+ StatusCode::BAD_REQUEST,
+ "diff_source_branch and diff_target_branch must both be provided or both omitted",
+ ));
+ }
+ }
+
+ if let Some(pr_number) = body.diff_pr_number {
+ let pr_str = pr_number.to_string();
+ tags.push(
+ Tag::parse(&["pr", &pr_str])
+ .map_err(|e| internal_error(&format!("tag build error: {e}")))?,
+ );
+ }
+
+ if let Some(ref lang) = body.diff_language {
+ tags.push(
+ Tag::parse(&["l", lang])
+ .map_err(|e| internal_error(&format!("tag build error: {e}")))?,
+ );
+ }
+
+ if let Some(ref description) = body.diff_description {
+ tags.push(
+ Tag::parse(&["description", description])
+ .map_err(|e| internal_error(&format!("tag build error: {e}")))?,
+ );
+ }
+
+ if let Some(truncated) = body.diff_truncated {
+ let val = if truncated { "true" } else { "false" };
+ tags.push(
+ Tag::parse(&["truncated", val])
+ .map_err(|e| internal_error(&format!("tag build error: {e}")))?,
+ );
+ }
+
+ if let Some(ref alt) = body.diff_alt {
+ tags.push(
+ Tag::parse(&["alt", alt])
+ .map_err(|e| internal_error(&format!("tag build error: {e}")))?,
+ );
+ }
+
+ Ok(tags)
+}
+
/// Request body for sending a channel message or thread reply.
#[derive(Debug, Deserialize)]
pub struct SendMessageBody {
@@ -114,6 +245,29 @@ pub struct SendMessageBody {
pub broadcast_to_channel: bool,
/// Nostr kind for this message. Defaults to `KIND_STREAM_MESSAGE` (40001).
pub kind: Option,
+ // Diff metadata fields (only used when kind == KIND_STREAM_MESSAGE_DIFF)
+ /// Repository URL for the diff (required for kind:40008; must be http/https).
+ pub diff_repo_url: Option,
+ /// File path within the repository that this diff applies to.
+ pub diff_file_path: Option,
+ /// Commit SHA that produced this diff (required for kind:40008).
+ pub diff_commit_sha: Option,
+ /// Parent commit SHA (the base of the diff).
+ pub diff_parent_commit_sha: Option,
+ /// Source branch for the diff (e.g. feature branch). Must be paired with `diff_target_branch`.
+ pub diff_source_branch: Option,
+ /// Target branch for the diff (e.g. main). Must be paired with `diff_source_branch`.
+ pub diff_target_branch: Option,
+ /// Pull request number associated with this diff.
+ pub diff_pr_number: Option,
+ /// Programming language of the diffed file (e.g. `"rust"`, `"typescript"`).
+ pub diff_language: Option,
+ /// Human-readable description of what the diff changes.
+ pub diff_description: Option,
+ /// When `true`, the diff content was truncated to fit the 60KB limit.
+ pub diff_truncated: Option,
+ /// Plain-text alternative summary for clients that cannot render diffs.
+ pub diff_alt: Option,
}
/// Send a new channel message or reply to an existing thread.
@@ -149,6 +303,13 @@ pub async fn send_message(
// Resolve kind — default to KIND_STREAM_MESSAGE (40001).
let kind_u32 = body.kind.unwrap_or(sprout_core::kind::KIND_STREAM_MESSAGE);
+
+ if kind_u32 == sprout_core::kind::KIND_STREAM_MESSAGE_DIFF && body.content.len() > 60 * 1024 {
+ return Err(api_error(
+ StatusCode::BAD_REQUEST,
+ "diff content exceeds 60KB limit; truncate before sending",
+ ));
+ }
let kind = Kind::from(kind_u32 as u16);
// ── Resolve thread ancestry ───────────────────────────────────────────────
@@ -274,6 +435,11 @@ pub async fn send_message(
);
}
+ if body.kind == Some(sprout_core::kind::KIND_STREAM_MESSAGE_DIFF) {
+ let diff_tags = build_diff_tags(&body)?;
+ tags.extend(diff_tags);
+ }
+
let event = EventBuilder::new(kind, &body.content, tags)
.sign_with_keys(&state.relay_keypair)
.map_err(|e| internal_error(&format!("event signing error: {e}")))?;
@@ -486,6 +652,7 @@ pub async fn list_messages(
"kind": m.kind,
"created_at": m.created_at.timestamp(),
"channel_id": m.channel_id.to_string(),
+ "tags": m.tags,
});
if let Some(ref ts) = m.thread_summary {
@@ -618,11 +785,14 @@ pub async fn get_thread(
let relay_pk = state.relay_keypair.public_key();
let relay_pk_bytes = relay_pk.serialize().to_vec();
let root_author = effective_author(&root_event.event, &relay_pk);
+ let root_tags =
+ serde_json::to_value(&root_event.event.tags).unwrap_or(serde_json::Value::Array(vec![]));
let mut root_obj = serde_json::json!({
"event_id": root_event.event.id.to_hex(),
"pubkey": nostr_hex::encode(&root_author),
"content": root_event.event.content,
"kind": root_event.event.kind.as_u16(),
+ "tags": root_tags,
"created_at": root_created_at,
"channel_id": channel_id.to_string(),
"thread_summary": summary.as_ref().map(|s| serde_json::json!({
@@ -677,6 +847,7 @@ pub async fn get_thread(
"depth": r.depth,
"created_at": r.created_at.timestamp(),
"broadcast": r.broadcast,
+ "tags": r.tags,
});
if let Some(reactions) = thread_reaction_map.get(&r.event_id) {
diff --git a/crates/sprout-relay/src/handlers/event.rs b/crates/sprout-relay/src/handlers/event.rs
index 7f2dc0608..0d0b78241 100644
--- a/crates/sprout-relay/src/handlers/event.rs
+++ b/crates/sprout-relay/src/handlers/event.rs
@@ -11,9 +11,9 @@ use sprout_core::event::StoredEvent;
use sprout_core::kind::{
event_kind_u32, is_ephemeral, is_workflow_execution_kind, KIND_AUTH, KIND_CANVAS,
KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE,
- KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_EDIT,
- KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2,
- KIND_STREAM_REMINDER,
+ KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF,
+ KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED,
+ KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER,
};
use sprout_core::verification::verify_event;
@@ -581,6 +581,10 @@ fn extract_channel_id(event: &Event) -> Option {
None
}
+// NOTE: This function only validates that channel-scoped kinds include an `h` tag.
+// Kind-specific metadata validation (e.g., diff_repo_url for kind:40008) is NOT
+// enforced on the WebSocket path — it is handled by the REST API layer (api/messages.rs).
+// This follows the Nostr protocol model where the relay is kind-agnostic for content events.
fn requires_h_channel_scope(kind: u32) -> bool {
matches!(
kind,
@@ -591,6 +595,7 @@ fn requires_h_channel_scope(kind: u32) -> bool {
| KIND_STREAM_MESSAGE_BOOKMARKED
| KIND_STREAM_MESSAGE_SCHEDULED
| KIND_STREAM_REMINDER
+ | KIND_STREAM_MESSAGE_DIFF
| KIND_CANVAS
| KIND_FORUM_POST
| KIND_FORUM_VOTE
@@ -603,13 +608,14 @@ mod tests {
use super::requires_h_channel_scope;
use sprout_core::kind::{
KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE,
- KIND_STREAM_MESSAGE,
+ KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF,
};
#[test]
fn channel_scoped_content_kinds_require_h_tags() {
for kind in [
KIND_STREAM_MESSAGE,
+ KIND_STREAM_MESSAGE_DIFF,
KIND_CANVAS,
KIND_FORUM_POST,
KIND_FORUM_VOTE,
diff --git a/crates/sprout-test-client/tests/e2e_mcp.rs b/crates/sprout-test-client/tests/e2e_mcp.rs
index 48a4e22a7..965a2faf7 100644
--- a/crates/sprout-test-client/tests/e2e_mcp.rs
+++ b/crates/sprout-test-client/tests/e2e_mcp.rs
@@ -295,8 +295,8 @@ async fn test_mcp_initialize_and_list_tools() {
assert_eq!(
tools.len(),
- 41,
- "expected exactly 41 tools, got {}. Tools: {:?}",
+ 42,
+ "expected exactly 42 tools, got {}. Tools: {:?}",
tools.len(),
tools
.iter()
diff --git a/crates/sprout-workflow/src/lib.rs b/crates/sprout-workflow/src/lib.rs
index ff212548d..609282211 100644
--- a/crates/sprout-workflow/src/lib.rs
+++ b/crates/sprout-workflow/src/lib.rs
@@ -351,6 +351,29 @@ async fn should_fire_workflow(
}
}
+ if let TriggerDef::DiffPosted {
+ filter: Some(ref expr),
+ } = def.trigger
+ {
+ match executor::evaluate_condition(expr, trigger_ctx, &HashMap::new()).await {
+ Ok(true) => {}
+ Ok(false) => {
+ tracing::debug!(
+ workflow_id = %workflow_id,
+ "Trigger filter evaluated false — skipping workflow"
+ );
+ return false;
+ }
+ Err(e) => {
+ tracing::warn!(
+ workflow_id = %workflow_id,
+ "Trigger filter error: {e} — skipping workflow"
+ );
+ return false;
+ }
+ }
+ }
+
true
}
@@ -426,10 +449,11 @@ pub fn build_trigger_context(event: &sprout_core::StoredEvent) -> executor::Trig
/// Returns `true` if the trigger type matches the given event kind.
fn trigger_matches_event(trigger: &TriggerDef, kind_u32: u32) -> bool {
- use sprout_core::kind::{KIND_REACTION, KIND_STREAM_MESSAGE};
+ use sprout_core::kind::{KIND_REACTION, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF};
match trigger {
TriggerDef::MessagePosted { .. } => kind_u32 == KIND_STREAM_MESSAGE,
TriggerDef::ReactionAdded { .. } => kind_u32 == KIND_REACTION,
+ TriggerDef::DiffPosted { .. } => kind_u32 == KIND_STREAM_MESSAGE_DIFF,
// Schedule and Webhook triggers are not fired by channel events.
TriggerDef::Schedule { .. } | TriggerDef::Webhook => false,
}
@@ -611,6 +635,21 @@ steps:
assert!(!trigger_matches_event(&webhook_trigger, 0));
}
+ #[test]
+ fn diff_posted_matches_kind_40008_only() {
+ let trigger = TriggerDef::DiffPosted { filter: None };
+ assert!(trigger_matches_event(&trigger, 40008));
+ assert!(!trigger_matches_event(&trigger, 40001));
+ assert!(!trigger_matches_event(&trigger, 7));
+ }
+
+ #[test]
+ fn message_posted_does_not_match_kind_40008() {
+ let trigger = TriggerDef::MessagePosted { filter: None };
+ assert!(!trigger_matches_event(&trigger, 40008));
+ assert!(trigger_matches_event(&trigger, 40001));
+ }
+
#[test]
fn workflow_config_custom_values() {
let cfg = WorkflowConfig {
diff --git a/crates/sprout-workflow/src/schema.rs b/crates/sprout-workflow/src/schema.rs
index ccf453165..54203861e 100644
--- a/crates/sprout-workflow/src/schema.rs
+++ b/crates/sprout-workflow/src/schema.rs
@@ -52,6 +52,12 @@ pub enum TriggerDef {
#[serde(default)]
emoji: Option,
},
+ /// Fires when a diff message (kind:40008) is posted in the workflow's channel.
+ DiffPosted {
+ /// Optional evalexpr filter expression (same variables as MessagePosted).
+ #[serde(default)]
+ filter: Option,
+ },
/// Fires on a cron schedule.
Schedule {
/// Cron expression (UTC). Mutually exclusive with `interval`.
@@ -832,4 +838,25 @@ mod tests {
let result = normalize_cron("* * * * *");
assert_eq!(result, "0 * * * * * *");
}
+
+ // ── DiffPosted trigger ────────────────────────────────────────────────────
+
+ #[test]
+ fn diff_posted_trigger_roundtrips_yaml() {
+ let yaml = "on: diff_posted\n";
+ let trigger: TriggerDef = serde_yaml::from_str(yaml).unwrap();
+ assert!(matches!(trigger, TriggerDef::DiffPosted { filter: None }));
+ let back = serde_yaml::to_string(&trigger).unwrap();
+ assert!(back.contains("diff_posted"));
+ }
+
+ #[test]
+ fn diff_posted_trigger_with_filter_roundtrips_yaml() {
+ let yaml = "on: diff_posted\nfilter: 'str_contains(trigger_text, \"src/\")'\n";
+ let trigger: TriggerDef = serde_yaml::from_str(yaml).unwrap();
+ assert!(matches!(
+ trigger,
+ TriggerDef::DiffPosted { filter: Some(_) }
+ ));
+ }
}
diff --git a/desktop/package.json b/desktop/package.json
index 394b56dc7..11171b272 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -19,6 +19,7 @@
"test:e2e:report": "playwright show-report"
},
"dependencies": {
+ "@monaco-editor/react": "^4.7.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-separator": "^1.1.8",
@@ -29,6 +30,8 @@
"@tauri-apps/plugin-opener": "^2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "diff2html": "^3.4.56",
+ "dompurify": "^3.3.3",
"lucide-react": "^0.577.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml
index 0c10c10f5..7be916dc5 100644
--- a/desktop/pnpm-lock.yaml
+++ b/desktop/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ '@monaco-editor/react':
+ specifier: ^4.7.0
+ version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@radix-ui/react-dialog':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -38,6 +41,12 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
+ diff2html:
+ specifier: ^3.4.56
+ version: 3.4.56
+ dompurify:
+ specifier: ^3.3.3
+ version: 3.3.3
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.4)
@@ -432,6 +441,16 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@monaco-editor/loader@1.7.0':
+ resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
+
+ '@monaco-editor/react@4.7.0':
+ resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==}
+ peerDependencies:
+ monaco-editor: '>= 0.25.0 < 1'
+ react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
'@noble/ciphers@2.1.1':
resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==}
engines: {node: '>= 20.19.0'}
@@ -461,6 +480,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
+ '@profoundlogic/hogan@3.0.4':
+ resolution: {integrity: sha512-pmNVGuooS30Mm7YbZd5T7E5zYVO6D5Ct91sn4T39mUvMUc3sCGridcnhAufL1/Bz2QzAtzEn0agNrdk3+5yWzw==}
+ hasBin: true
+
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
@@ -838,7 +861,6 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
@@ -849,7 +871,6 @@ packages:
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
@@ -867,7 +888,6 @@ packages:
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
@@ -884,18 +904,17 @@ packages:
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
@@ -907,6 +926,7 @@ packages:
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64]
os: [linux]
+ libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
@@ -1070,6 +1090,9 @@ packages:
'@types/react@19.2.14':
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
+ '@types/trusted-types@2.0.7':
+ resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
+
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@@ -1085,6 +1108,9 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+ abbrev@1.1.1:
+ resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==}
+
any-promise@1.3.0:
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
@@ -1203,9 +1229,23 @@ packages:
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
+ diff2html@3.4.56:
+ resolution: {integrity: sha512-u9gfn+BlbHcyO7vItCIC4z49LJDUt31tODzOfAuJ5R1E7IdlRL6KjugcB9zOpejD+XiR+dDZbsnHSQ3g6A/u8A==}
+ engines: {node: '>=12'}
+
+ diff@8.0.3:
+ resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
+ engines: {node: '>=0.3.1'}
+
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
+ dompurify@3.2.7:
+ resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
+
+ dompurify@3.3.3:
+ resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==}
+
electron-to-chromium@1.5.307:
resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==}
@@ -1290,6 +1330,10 @@ packages:
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
+ highlight.js@11.11.1:
+ resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
+ engines: {node: '>=12.0.0'}
+
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
@@ -1370,6 +1414,11 @@ packages:
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
+ marked@14.0.0:
+ resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
+ engines: {node: '>= 18'}
+ hasBin: true
+
mdast-util-find-and-replace@3.0.2:
resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==}
@@ -1510,6 +1559,9 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
+ monaco-editor@0.55.1:
+ resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1524,6 +1576,10 @@ packages:
node-releases@2.0.36:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
+ nopt@1.0.10:
+ resolution: {integrity: sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==}
+ hasBin: true
+
normalize-path@3.0.0:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
@@ -1731,6 +1787,9 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
+ state-local@1.0.7:
+ resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
+
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@@ -2163,6 +2222,17 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@monaco-editor/loader@1.7.0':
+ dependencies:
+ state-local: 1.0.7
+
+ '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+ dependencies:
+ '@monaco-editor/loader': 1.7.0
+ monaco-editor: 0.55.1
+ react: 19.2.4
+ react-dom: 19.2.4(react@19.2.4)
+
'@noble/ciphers@2.1.1': {}
'@noble/curves@2.0.1':
@@ -2187,6 +2257,10 @@ snapshots:
dependencies:
playwright: 1.58.2
+ '@profoundlogic/hogan@3.0.4':
+ dependencies:
+ nopt: 1.0.10
+
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
@@ -2702,6 +2776,9 @@ snapshots:
dependencies:
csstype: 3.2.3
+ '@types/trusted-types@2.0.7':
+ optional: true
+
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
@@ -2720,6 +2797,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ abbrev@1.1.1: {}
+
any-promise@1.3.0: {}
anymatch@3.1.3:
@@ -2820,8 +2899,25 @@ snapshots:
didyoumean@1.2.2: {}
+ diff2html@3.4.56:
+ dependencies:
+ '@profoundlogic/hogan': 3.0.4
+ diff: 8.0.3
+ optionalDependencies:
+ highlight.js: 11.11.1
+
+ diff@8.0.3: {}
+
dlv@1.1.3: {}
+ dompurify@3.2.7:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+
+ dompurify@3.3.3:
+ optionalDependencies:
+ '@types/trusted-types': 2.0.7
+
electron-to-chromium@1.5.307: {}
esbuild@0.27.3:
@@ -2931,6 +3027,9 @@ snapshots:
dependencies:
'@types/hast': 3.0.4
+ highlight.js@11.11.1:
+ optional: true
+
html-url-attributes@3.0.1: {}
inline-style-parser@0.2.7: {}
@@ -2988,6 +3087,8 @@ snapshots:
markdown-table@3.0.4: {}
+ marked@14.0.0: {}
+
mdast-util-find-and-replace@3.0.2:
dependencies:
'@types/mdast': 4.0.4
@@ -3344,6 +3445,11 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.1
+ monaco-editor@0.55.1:
+ dependencies:
+ dompurify: 3.2.7
+ marked: 14.0.0
+
ms@2.1.3: {}
mz@2.7.0:
@@ -3356,6 +3462,10 @@ snapshots:
node-releases@2.0.36: {}
+ nopt@1.0.10:
+ dependencies:
+ abbrev: 1.1.1
+
normalize-path@3.0.0: {}
nostr-tools@2.23.3(typescript@5.8.3):
@@ -3600,6 +3710,8 @@ snapshots:
space-separated-tokens@2.0.2: {}
+ state-local@1.0.7: {}
+
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts
index 8f67a70a9..e470adc2f 100644
--- a/desktop/src/features/messages/lib/formatTimelineMessages.ts
+++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts
@@ -54,6 +54,8 @@ export function formatTimelineMessages(
body: event.content,
accent: currentPubkey === event.pubkey,
pending: event.pending,
+ kind: event.kind,
+ tags: event.tags,
}));
}
diff --git a/desktop/src/features/messages/lib/parseDiff.ts b/desktop/src/features/messages/lib/parseDiff.ts
new file mode 100644
index 000000000..e19de19bd
--- /dev/null
+++ b/desktop/src/features/messages/lib/parseDiff.ts
@@ -0,0 +1,52 @@
+import { parse } from "diff2html";
+
+export function parseDiffToOldNew(unifiedDiff: string): {
+ original: string;
+ modified: string;
+} {
+ try {
+ const files = parse(unifiedDiff);
+ if (!files.length) {
+ // diff2html couldn't parse any files — show raw diff as fallback
+ return { original: "", modified: unifiedDiff };
+ }
+
+ const originalLines: string[] = [];
+ const modifiedLines: string[] = [];
+
+ for (const file of files) {
+ // Add file header separator for multi-file diffs
+ if (files.length > 1) {
+ const header = `// ── ${file.newName || file.oldName || "unknown"} ──`;
+ originalLines.push(header);
+ modifiedLines.push(header);
+ }
+
+ for (const block of file.blocks) {
+ for (const line of block.lines) {
+ if (line.content.startsWith("\\ ")) continue;
+ if (line.type === "context" || line.type === "delete") {
+ originalLines.push(line.content.slice(1));
+ }
+ if (line.type === "context" || line.type === "insert") {
+ modifiedLines.push(line.content.slice(1));
+ }
+ }
+ }
+
+ // Add blank line between files
+ if (files.length > 1) {
+ originalLines.push("");
+ modifiedLines.push("");
+ }
+ }
+
+ return {
+ original: originalLines.join("\n"),
+ modified: modifiedLines.join("\n"),
+ };
+ } catch {
+ // Malformed diff — return raw content as fallback
+ return { original: "", modified: unifiedDiff };
+ }
+}
diff --git a/desktop/src/features/messages/types.ts b/desktop/src/features/messages/types.ts
index 304f551ce..d1c18e121 100644
--- a/desktop/src/features/messages/types.ts
+++ b/desktop/src/features/messages/types.ts
@@ -8,4 +8,6 @@ export type TimelineMessage = {
accent?: boolean;
pending?: boolean;
highlighted?: boolean;
+ kind?: number;
+ tags?: string[][];
};
diff --git a/desktop/src/features/messages/ui/DiffMessage.tsx b/desktop/src/features/messages/ui/DiffMessage.tsx
new file mode 100644
index 000000000..078bec71d
--- /dev/null
+++ b/desktop/src/features/messages/ui/DiffMessage.tsx
@@ -0,0 +1,236 @@
+import { html } from "diff2html";
+import "diff2html/bundles/css/diff2html.min.css";
+import DOMPurify from "dompurify";
+import { FileDiff, Maximize2 } from "lucide-react";
+import { useMemo } from "react";
+
+import { Button } from "@/shared/ui/button";
+
+/**
+ * Override diff2html styles that break in constrained containers.
+ * The default CSS uses position:absolute for line numbers which causes
+ * overflow and misalignment in narrow message bubbles.
+ */
+const diffStyleOverrides = `
+.d2h-wrapper { text-align: left; }
+.d2h-file-wrapper { border: none; margin: 0; border-radius: 0; }
+.d2h-file-header { display: none; }
+.d2h-diff-table { font-size: 12px; width: 100%; table-layout: fixed; }
+.d2h-code-linenumber {
+ position: static;
+ display: table-cell;
+ width: 3em;
+ min-width: 3em;
+ max-width: 3em;
+ padding: 0 0.4em;
+ text-align: right;
+ vertical-align: top;
+ user-select: none;
+ border-right: 1px solid var(--d2h-line-border-color, #eee);
+ font-size: 11px;
+}
+.d2h-code-line {
+ display: table-cell;
+ padding: 0 0.5em;
+ white-space: pre-wrap;
+ word-break: break-all;
+ width: auto;
+}
+.d2h-code-line-ctn {
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+.d2h-info {
+ padding: 0.2em 0.5em;
+}
+`;
+
+type DiffMessageProps = {
+ content: string;
+ repoUrl?: string;
+ filePath?: string;
+ commitSha?: string;
+ description?: string;
+ truncated?: boolean;
+ onExpand?: () => void;
+};
+
+function isSafeUrl(url: string | undefined): url is string {
+ if (!url) return false;
+ try {
+ const parsed = new URL(url);
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
+ } catch {
+ return false;
+ }
+}
+
+function getHostname(url: string): string {
+ try {
+ return new URL(url).hostname;
+ } catch {
+ return url;
+ }
+}
+
+const ALLOWED_TAGS = [
+ "div",
+ "span",
+ "table",
+ "thead",
+ "tbody",
+ "tr",
+ "th",
+ "td",
+ "pre",
+ "code",
+ "ins",
+ "del",
+ "a",
+ "i",
+ "em",
+ "strong",
+ "small",
+];
+
+const ALLOWED_ATTR = [
+ "class",
+ "id",
+ "data-line-number",
+ "href",
+ "title",
+ "aria-label",
+];
+
+const ALLOWED_URI_REGEXP = /^https?:\/\//i;
+
+export function DiffMessage({
+ content,
+ repoUrl,
+ filePath,
+ commitSha,
+ description,
+ truncated,
+ onExpand,
+}: DiffMessageProps) {
+ const { diffHtml, renderError } = useMemo(() => {
+ try {
+ const rawHtml = html(content, {
+ drawFileList: false,
+ matching: "lines",
+ outputFormat: "line-by-line",
+ });
+ const sanitized = DOMPurify.sanitize(rawHtml, {
+ ALLOWED_TAGS,
+ ALLOWED_ATTR,
+ ALLOWED_URI_REGEXP,
+ });
+ return { diffHtml: sanitized, renderError: false };
+ } catch {
+ return { diffHtml: "", renderError: true };
+ }
+ }, [content]);
+
+ const safeRepoUrl = isSafeUrl(repoUrl) ? repoUrl : undefined;
+
+ const commitUrl =
+ safeRepoUrl && commitSha ? `${safeRepoUrl}/commit/${commitSha}` : undefined;
+
+ const shortSha = commitSha ? commitSha.slice(0, 7) : undefined;
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Description */}
+ {description && (
+
+ {description}
+
+ )}
+
+ {/* Diff content — max 400px height, scrollable */}
+
+ {/* biome-ignore lint/security/noDangerouslySetInnerHtml: static CSS overrides */}
+
+ {renderError ? (
+
+ {content}
+
+ ) : diffHtml ? (
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized by DOMPurify
+
+ ) : (
+
+ No diff content
+
+ )}
+
+
+ {/* Truncation warning */}
+ {truncated && (
+
+ )}
+
+ );
+}
diff --git a/desktop/src/features/messages/ui/DiffMessageExpanded.tsx b/desktop/src/features/messages/ui/DiffMessageExpanded.tsx
new file mode 100644
index 000000000..e3896792b
--- /dev/null
+++ b/desktop/src/features/messages/ui/DiffMessageExpanded.tsx
@@ -0,0 +1,65 @@
+import { DiffEditor } from "@monaco-editor/react";
+import { useMemo } from "react";
+
+import { parseDiffToOldNew } from "@/features/messages/lib/parseDiff";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@/shared/ui/dialog";
+
+type DiffMessageExpandedProps = {
+ content: string;
+ language?: string;
+ filePath?: string;
+ onClose: () => void;
+};
+
+export default function DiffMessageExpanded({
+ content,
+ language,
+ filePath,
+ onClose,
+}: DiffMessageExpandedProps) {
+ const { original, modified } = useMemo(
+ () => parseDiffToOldNew(content),
+ [content],
+ );
+
+ return (
+
+ );
+}
diff --git a/desktop/src/features/messages/ui/MessageTimeline.tsx b/desktop/src/features/messages/ui/MessageTimeline.tsx
index 42f318c45..35f67b165 100644
--- a/desktop/src/features/messages/ui/MessageTimeline.tsx
+++ b/desktop/src/features/messages/ui/MessageTimeline.tsx
@@ -2,11 +2,15 @@ import { ArrowDown } from "lucide-react";
import * as React from "react";
import type { TimelineMessage } from "@/features/messages/types";
+import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import { Separator } from "@/shared/ui/separator";
import { Skeleton } from "@/shared/ui/skeleton";
+import { DiffMessage } from "./DiffMessage";
+
+const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
type MessageTimelineProps = {
messages: TimelineMessage[];
@@ -28,6 +32,9 @@ function isNearBottom(container: HTMLDivElement) {
function MessageRow({ message }: { message: TimelineMessage }) {
const [hasAvatarError, setHasAvatarError] = React.useState(false);
+ const [expandedDiffId, setExpandedDiffId] = React.useState(
+ null,
+ );
const initials = message.author
.split(" ")
.map((part) => part[0])
@@ -35,6 +42,30 @@ function MessageRow({ message }: { message: TimelineMessage }) {
.slice(0, 2)
.toUpperCase();
+ const getTag = (name: string) =>
+ message.tags?.find((t) => t[0] === name)?.[1];
+
+ const renderBody = () => {
+ switch (message.kind) {
+ case KIND_STREAM_MESSAGE_DIFF:
+ return (
+ {
+ setExpandedDiffId(message.id);
+ }}
+ repoUrl={getTag("repo")}
+ truncated={getTag("truncated") === "true"}
+ />
+ );
+ default:
+ return ;
+ }
+ };
+
return (
{message.time}
-
+ {renderBody()}
+ {expandedDiffId === message.id && (
+
+ Loading diff viewer…
+
+ }
+ >
+ {
+ setExpandedDiffId(null);
+ }}
+ />
+
+ )}
);
diff --git a/desktop/src/shared/api/relayClient.ts b/desktop/src/shared/api/relayClient.ts
index 3c487b714..a62419697 100644
--- a/desktop/src/shared/api/relayClient.ts
+++ b/desktop/src/shared/api/relayClient.ts
@@ -6,6 +6,7 @@ import {
signRelayEvent,
} from "@/shared/api/tauri";
import type { PresenceStatus, RelayEvent } from "@/shared/api/types";
+import { STREAM_MESSAGE_KINDS } from "@/shared/constants/kinds";
type RelaySubscriptionFilter = {
kinds: number[];
@@ -230,7 +231,7 @@ class RelayClient {
limit: number,
): RelaySubscriptionFilter {
return {
- kinds: [40001],
+ kinds: [...STREAM_MESSAGE_KINDS],
"#h": [channelId],
limit,
};
diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts
new file mode 100644
index 000000000..c81ecf31a
--- /dev/null
+++ b/desktop/src/shared/constants/kinds.ts
@@ -0,0 +1,9 @@
+export const KIND_STREAM_MESSAGE = 40001;
+export const KIND_STREAM_MESSAGE_V2 = 40002;
+export const KIND_STREAM_MESSAGE_EDIT = 40003;
+export const KIND_STREAM_MESSAGE_DIFF = 40008;
+
+export const STREAM_MESSAGE_KINDS = [
+ KIND_STREAM_MESSAGE, // 40001 — was in original filter
+ KIND_STREAM_MESSAGE_DIFF, // 40008 — new in this PR
+] as const;