diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 66b7708f1d..c9af425141 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -30,6 +30,8 @@ pub mod network; pub mod observer; /// NIP-AB device pairing — crypto primitives, message types, and errors. pub mod pairing; +/// Signed, channel-scoped extension-panel manifest types. +pub mod panel; /// Presence status types shared across crates. pub mod presence; /// Canonical relay runtime identities. @@ -42,6 +44,7 @@ pub mod verification; pub use error::VerificationError; pub use event::StoredEvent; pub use nostr::{Event, EventId, Filter, Keys, Kind, PublicKey}; +pub use panel::{PanelManifest, PanelManifestError}; pub use presence::PresenceStatus; pub use tenant::{normalize_host, CommunityId, TenantContext}; pub use verification::verify_event; diff --git a/crates/buzz-core/src/panel.rs b/crates/buzz-core/src/panel.rs new file mode 100644 index 0000000000..82e783cdbd --- /dev/null +++ b/crates/buzz-core/src/panel.rs @@ -0,0 +1,476 @@ +//! Signed, channel-scoped extension-panel manifest types. +//! +//! The manifest is a generic projection contract. It does not assign a Nostr +//! event kind or decide which integration owns a field; transport and domain +//! semantics remain outside `buzz-core`. + +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use url::Url; +use uuid::Uuid; + +/// Current signed panel-manifest schema version. +pub const PANEL_MANIFEST_SCHEMA_VERSION: u16 = 1; +/// Maximum serialized UTF-8 manifest size, leaving room for an event envelope. +pub const MAX_PANEL_MANIFEST_BYTES: usize = 32 * 1024; +/// Maximum number of sections in a manifest. +pub const MAX_PANEL_SECTIONS: usize = 32; +/// Maximum number of fields in one section. +pub const MAX_PANEL_FIELDS_PER_SECTION: usize = 64; +/// Maximum number of links in one section. +pub const MAX_PANEL_LINKS_PER_SECTION: usize = 32; +/// Maximum number of source-event references in a manifest. +pub const MAX_PANEL_SOURCE_EVENTS: usize = 64; + +const MAX_PANEL_ID_BYTES: usize = 128; +const MAX_PANEL_LABEL_BYTES: usize = 256; +const MAX_PANEL_VALUE_BYTES: usize = 4_096; + +/// Validation failures for a [`PanelManifest`]. +#[derive(Debug, Error)] +pub enum PanelManifestError { + /// The JSON was not a manifest object or contained an unknown field. + #[error("invalid panel manifest JSON: {0}")] + Json(#[from] serde_json::Error), + /// A decoded manifest violated the bounded contract. + #[error("invalid panel manifest: {0}")] + Invalid(String), +} + +/// A generic, read-only projection of signed channel state. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PanelManifest { + /// Exact schema version understood by this client. + pub schema_version: u16, + /// Stable identifier for this panel within its channel. + pub panel_id: String, + /// Canonical UUID of the channel that owns this projection. + pub channel_id: String, + /// Short human-readable panel title. + pub title: String, + /// Optional plain-text context for the panel. + pub description: Option, + /// Overall panel status. + pub status: PanelStatus, + /// Unix timestamp in seconds for the latest source update. + pub updated_at: u64, + /// Structured sections rendered by the client. + pub sections: Vec, + /// Signed source-event references supporting the projection. + pub source_events: Vec, +} + +/// Bounded status vocabulary shared by a panel and its sections. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PanelStatus { + /// Work has not started. + Pending, + /// Work is in progress. + Active, + /// Work completed successfully. + Complete, + /// Work needs an external decision or intervention. + Blocked, + /// Work failed. + Failed, + /// The projection may no longer reflect current source state. + Stale, + /// The source exists but is not currently available to the reader. + Unavailable, +} + +/// One named section of a panel. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PanelSection { + /// Stable section identifier within the manifest. + pub id: String, + /// Short section heading. + pub title: String, + /// Current section status. + pub status: PanelStatus, + /// Human-readable fields in display order. + pub fields: Vec, + /// Typed links back to source events or an external HTTPS destination. + pub links: Vec, +} + +/// One label/value pair in a section. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PanelField { + /// Short field label. + pub label: String, + /// Plain-text field value. + pub value: String, + /// Allowlisted rendering hint. + pub presentation: PanelPresentation, +} + +/// Safe presentation hints understood by the renderer. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PanelPresentation { + /// Normal proportional text. + Text, + /// Text that benefits from fixed-width glyphs. + Monospace, + /// Unix seconds rendered as a localized timestamp. + Timestamp, + /// A value that should receive semantic status treatment. + Status, +} + +/// A typed link from a panel to a source event or external destination. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PanelLink { + /// Short action label. + pub label: String, + /// Kind of destination the client should resolve. + pub target: PanelLinkTarget, + /// Source event for a Buzz-native destination. + pub source_event_id: Option, + /// External destination. Only `https:` is permitted for this target. + pub uri: Option, +} + +/// Allowlisted link destinations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PanelLinkTarget { + /// A channel Canvas revision. + Canvas, + /// A workflow definition or run. + Workflow, + /// A structured job handoff/result. + Handoff, + /// A channel thread. + Thread, + /// A raw signed event. + Event, + /// A user-activated external HTTPS destination. + External, +} + +/// A signed event reference supporting the panel projection. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct PanelSourceEvent { + /// Lowercase 64-character event id. + pub event_id: String, + /// Nostr event kind. + pub kind: u32, + /// Channel containing the source event. + pub channel_id: String, + /// Short provenance label shown to the reader. + pub label: String, +} + +impl PanelManifest { + /// Decode and validate a JSON manifest, including size limits. + pub fn from_json(input: &str) -> Result { + if input.len() > MAX_PANEL_MANIFEST_BYTES { + return Err(PanelManifestError::Invalid(format!( + "serialized content exceeds {MAX_PANEL_MANIFEST_BYTES} bytes" + ))); + } + + let manifest: Self = serde_json::from_str(input)?; + manifest.validate()?; + Ok(manifest) + } + + /// Validate the manifest's structural, size, and identifier constraints. + pub fn validate(&self) -> Result<(), PanelManifestError> { + if self.schema_version != PANEL_MANIFEST_SCHEMA_VERSION { + return invalid(format!( + "unsupported schema version {}", + self.schema_version + )); + } + + validate_identifier("panelId", &self.panel_id, MAX_PANEL_ID_BYTES)?; + validate_channel_id("channelId", &self.channel_id)?; + validate_text("title", &self.title, MAX_PANEL_LABEL_BYTES, true)?; + if let Some(description) = &self.description { + validate_text("description", description, MAX_PANEL_VALUE_BYTES, false)?; + } + + if self.updated_at == 0 { + return invalid("updatedAt must be a positive Unix timestamp"); + } + if self.sections.len() > MAX_PANEL_SECTIONS { + return invalid(format!("sections exceeds maximum of {MAX_PANEL_SECTIONS}")); + } + if self.source_events.len() > MAX_PANEL_SOURCE_EVENTS { + return invalid(format!( + "sourceEvents exceeds maximum of {MAX_PANEL_SOURCE_EVENTS}" + )); + } + if self.source_events.is_empty() { + return invalid("sourceEvents must contain at least one signed source"); + } + + let mut section_ids = HashSet::with_capacity(self.sections.len()); + for section in &self.sections { + validate_identifier("section.id", §ion.id, MAX_PANEL_ID_BYTES)?; + validate_text("section.title", §ion.title, MAX_PANEL_LABEL_BYTES, true)?; + if !section_ids.insert(§ion.id) { + return invalid(format!("duplicate section id `{}`", section.id)); + } + if section.fields.len() > MAX_PANEL_FIELDS_PER_SECTION { + return invalid(format!( + "section `{}` fields exceeds maximum of {MAX_PANEL_FIELDS_PER_SECTION}", + section.id + )); + } + if section.links.len() > MAX_PANEL_LINKS_PER_SECTION { + return invalid(format!( + "section `{}` links exceeds maximum of {MAX_PANEL_LINKS_PER_SECTION}", + section.id + )); + } + + let mut field_labels = HashSet::with_capacity(section.fields.len()); + for field in §ion.fields { + validate_text("field.label", &field.label, MAX_PANEL_LABEL_BYTES, true)?; + validate_text("field.value", &field.value, MAX_PANEL_VALUE_BYTES, false)?; + if !field_labels.insert(&field.label) { + return invalid(format!( + "duplicate field label `{}` in section `{}`", + field.label, section.id + )); + } + } + + for link in §ion.links { + validate_text("link.label", &link.label, MAX_PANEL_LABEL_BYTES, true)?; + validate_link(link)?; + } + } + + let mut source_event_ids = HashSet::with_capacity(self.source_events.len()); + for source in &self.source_events { + validate_event_id("sourceEvents.eventId", &source.event_id)?; + validate_channel_id("sourceEvents.channelId", &source.channel_id)?; + if source.channel_id != self.channel_id { + return invalid(format!( + "source event `{}` belongs to another channel", + source.event_id + )); + } + validate_text( + "sourceEvents.label", + &source.label, + MAX_PANEL_LABEL_BYTES, + true, + )?; + if !source_event_ids.insert(&source.event_id) { + return invalid(format!("duplicate source event id `{}`", source.event_id)); + } + } + + let serialized = serde_json::to_vec(self)?; + if serialized.len() > MAX_PANEL_MANIFEST_BYTES { + return invalid(format!( + "serialized content exceeds {MAX_PANEL_MANIFEST_BYTES} bytes" + )); + } + Ok(()) + } + + /// Validate that the manifest is scoped to the reader's current channel. + pub fn validate_for_channel(&self, channel_id: Uuid) -> Result<(), PanelManifestError> { + self.validate()?; + if self.channel_id != channel_id.to_string() { + return invalid("manifest channel does not match the current channel"); + } + Ok(()) + } +} + +fn validate_link(link: &PanelLink) -> Result<(), PanelManifestError> { + match link.target { + PanelLinkTarget::External => { + if link.source_event_id.is_some() { + return invalid("external links cannot carry a source event id"); + } + let Some(uri) = link.uri.as_deref() else { + return invalid("external links require a URI"); + }; + let parsed = Url::parse(uri) + .map_err(|_| PanelManifestError::Invalid("external URI is invalid".into()))?; + if parsed.scheme() != "https" || parsed.host_str().is_none() { + return invalid("external links require an HTTPS URI with a host"); + } + } + _ => { + let Some(source_event_id) = link.source_event_id.as_deref() else { + return invalid("Buzz-native links require a source event id"); + }; + validate_event_id("link.sourceEventId", source_event_id)?; + if link.uri.is_some() { + return invalid("Buzz-native links cannot carry an arbitrary URI"); + } + } + } + Ok(()) +} + +fn validate_channel_id(field: &str, value: &str) -> Result<(), PanelManifestError> { + let parsed = Uuid::parse_str(value) + .map_err(|_| PanelManifestError::Invalid(format!("{field} is not a UUID")))?; + if parsed.to_string() != value { + return invalid(format!("{field} must use canonical lowercase UUID form")); + } + Ok(()) +} + +fn validate_event_id(field: &str, value: &str) -> Result<(), PanelManifestError> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return invalid(format!("{field} must be lowercase 64-character hex")); + } + Ok(()) +} + +fn validate_identifier( + field: &str, + value: &str, + max_bytes: usize, +) -> Result<(), PanelManifestError> { + validate_text(field, value, max_bytes, true)?; + if !value.is_ascii() { + return invalid(format!("{field} must contain ASCII characters only")); + } + Ok(()) +} + +fn validate_text( + field: &str, + value: &str, + max_bytes: usize, + require_non_empty: bool, +) -> Result<(), PanelManifestError> { + if require_non_empty && value.is_empty() { + return invalid(format!("{field} must not be empty")); + } + if value.len() > max_bytes { + return invalid(format!("{field} exceeds maximum of {max_bytes} bytes")); + } + if value.chars().any(char::is_control) { + return invalid(format!("{field} must not contain control characters")); + } + Ok(()) +} + +fn invalid(message: impl Into) -> Result { + Err(PanelManifestError::Invalid(message.into())) +} + +#[cfg(test)] +mod tests { + use super::*; + + const CHANNEL_ID: &str = "00000000-0000-4000-8000-000000000001"; + + fn valid_manifest() -> PanelManifest { + PanelManifest { + schema_version: PANEL_MANIFEST_SCHEMA_VERSION, + panel_id: "panel".into(), + channel_id: CHANNEL_ID.into(), + title: "Panel".into(), + description: Some("A projection".into()), + status: PanelStatus::Active, + updated_at: 1_760_000_000, + sections: vec![PanelSection { + id: "section".into(), + title: "Section".into(), + status: PanelStatus::Active, + fields: vec![PanelField { + label: "State".into(), + value: "Active".into(), + presentation: PanelPresentation::Status, + }], + links: vec![PanelLink { + label: "Source".into(), + target: PanelLinkTarget::Event, + source_event_id: Some("1".repeat(64)), + uri: None, + }], + }], + source_events: vec![PanelSourceEvent { + event_id: "1".repeat(64), + kind: 43004, + channel_id: CHANNEL_ID.into(), + label: "Result".into(), + }], + } + } + + #[test] + fn fixture_round_trips_and_validates() { + let input = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../docs/fixtures/signed-channel-panel.json" + )); + let manifest = PanelManifest::from_json(input).expect("fixture must validate"); + assert_eq!(manifest.schema_version, 1); + assert_eq!(manifest.source_events.len(), 2); + } + + #[test] + fn valid_manifest_validates_for_current_channel() { + let manifest = valid_manifest(); + assert!(manifest + .validate_for_channel(Uuid::parse_str(CHANNEL_ID).expect("channel id")) + .is_ok()); + } + + #[test] + fn unknown_schema_version_is_rejected() { + let mut manifest = valid_manifest(); + manifest.schema_version = 2; + assert!(manifest.validate().is_err()); + } + + #[test] + fn cross_channel_manifest_is_rejected() { + let manifest = valid_manifest(); + assert!(manifest.validate_for_channel(Uuid::new_v4()).is_err()); + } + + #[test] + fn unsafe_external_link_is_rejected() { + let mut manifest = valid_manifest(); + manifest.sections[0].links = vec![PanelLink { + label: "Remote".into(), + target: PanelLinkTarget::External, + source_event_id: None, + uri: Some("javascript:alert(1)".into()), + }]; + assert!(manifest.validate().is_err()); + } + + #[test] + fn duplicate_section_ids_are_rejected() { + let mut manifest = valid_manifest(); + manifest.sections.push(manifest.sections[0].clone()); + assert!(manifest.validate().is_err()); + } + + #[test] + fn unknown_json_fields_are_rejected() { + let mut value = serde_json::to_value(valid_manifest()).expect("serialize"); + value["unexpected"] = serde_json::Value::Bool(true); + assert!(PanelManifest::from_json(&value.to_string()).is_err()); + } +} diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index bba9218a1a..206382bd07 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -131,6 +131,7 @@ export default defineConfig({ "**/harness-management.spec.ts", "**/harness-catalog-screenshots.spec.ts", "**/inline-custom-harness.spec.ts", + "**/signed-channel-panel.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index c87617ce9b..3eea2bbdab 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -1,4 +1,4 @@ -import { EllipsisVertical, Settings2, Users } from "lucide-react"; +import { EllipsisVertical, PanelRight, Settings2, Users } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; import { useQueryClient } from "@tanstack/react-query"; @@ -32,7 +32,9 @@ type ChannelMembersBarProps = { isAddBotOpen?: boolean; onAddBotOpenChange?: (open: boolean) => void; onManageChannel: () => void; + onOpenChannelPanel?: () => void; onToggleMembers: () => void; + channelPanelOpen?: boolean; variant?: "inline" | "compact"; }; @@ -42,7 +44,9 @@ export function ChannelMembersBar({ isAddBotOpen: isAddBotOpenProp, onAddBotOpenChange, onManageChannel, + onOpenChannelPanel, onToggleMembers, + channelPanelOpen = false, variant = "inline", }: ChannelMembersBarProps) { const [uncontrolledAddBotOpen, setUncontrolledAddBotOpen] = @@ -170,6 +174,15 @@ export function ChannelMembersBar({ Manage channel + {channel.channelType !== "forum" && onOpenChannelPanel ? ( + + + {channelPanelOpen ? "Close panel" : "Open panel"} + + ) : null} ) : ( @@ -210,6 +223,30 @@ export function ChannelMembersBar({ Channel settings + + {channel.channelType !== "forum" && onOpenChannelPanel ? ( + + + + + + {channelPanelOpen ? "Close channel panel" : "Open channel panel"} + + + ) : null} ); diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 70877875f7..a5145be7c4 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -27,6 +27,7 @@ import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar"; import { AgentSessionThreadPanel } from "@/features/channels/ui/AgentSessionThreadPanel"; import { ChannelManagementAuxiliaryPanel } from "@/features/channels/ui/ChannelManagementAuxiliaryPanel"; +import { SignedChannelPanelAuxiliaryPanel } from "@/features/channels/ui/SignedChannelPanelAuxiliaryPanel"; import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; import { ThreadViewModeToggle } from "@/features/channels/ui/ThreadViewModeToggle"; import { FocusThreadDrawer } from "@/features/channels/ui/FocusThreadDrawer"; @@ -75,6 +76,7 @@ export const ChannelPane = React.memo(function ChannelPane({ botTypingEntries, channelFind, channelManagementOpen = false, + channelPanelOpen = false, currentPubkey, editTarget = null, fetchOlder, @@ -95,6 +97,7 @@ export const ChannelPane = React.memo(function ChannelPane({ welcomeKickoffStage = null, welcomeKickoffSettingUp = false, messages, + signedPanelEvents = [], threadSummaries, firstUnreadMessageId = null, unreadCount = 0, @@ -104,6 +107,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onBackFromAgentSession, onCloseAgentSession, onCloseChannelManagement, + onCloseChannelPanel, onChannelManagementDeleted, onCloseProfilePanel, onAddAgent, @@ -504,7 +508,6 @@ export const ChannelPane = React.memo(function ChannelPane({ threadAllMessages, threadHeadMessage, ]); - const isOverlay = useIsThreadPanelOverlay(); const useSplitAuxiliaryPane = !isSinglePanelView && !isOverlay; const threadViewMode = useThreadViewMode(); @@ -536,6 +539,7 @@ export const ChannelPane = React.memo(function ChannelPane({ const hasSplitAuxiliaryPane = useSplitAuxiliaryPane && (channelManagementOpen || + channelPanelOpen || Boolean(threadHeadMessage) || shouldShowThreadSkeleton || Boolean(activeChannel && selectedAgent) || @@ -592,7 +596,6 @@ export const ChannelPane = React.memo(function ChannelPane({ data-testid="channel-shared-header-backdrop" /> ) : null} - {!isSinglePanelView ? (
) : null} - - {/* - * `AnimatePresence` keeps the focus thread drawer mounted through its exit - * animation — without it the drawer's own existence condition - * (`useFocusThreadDrawer`, which is derived from `threadHeadMessage`) goes - * false on the same frame as the close, and there is nothing left to - * animate. It can hold the real thread through the exit rather than a - * frozen snapshot because the panel is fully prop-driven. - */} - {channelManagementOpen && activeChannel ? ( + {channelPanelOpen && activeChannel ? ( + undefined)} + onOpenThread={onOpenThread} + onResetPanelWidth={onResetThreadPanelWidth} + onPanelResizeStart={onThreadPanelResizeStart} + panelWidthPx={threadPanelWidthPx} + sourceMessages={messages} + useSplitAuxiliaryPane={useSplitAuxiliaryPane} + /> + ) : channelManagementOpen && activeChannel ? ( ; channelManagementOpen?: boolean; + channelPanelOpen?: boolean; currentPubkey?: string; editTarget?: { author: string; @@ -61,6 +62,7 @@ export type ChannelPaneProps = { /** The kickoff is still setting up the team — the banner copy reads as setup status. */ welcomeKickoffSettingUp?: boolean; messages: TimelineMessage[]; + signedPanelEvents?: readonly RelayEvent[]; threadSummaries?: ReadonlyMap; firstUnreadMessageId?: string | null; unreadCount?: number; @@ -75,6 +77,7 @@ export type ChannelPaneProps = { onBackFromAgentSession?: () => void; onCloseAgentSession: () => void; onCloseChannelManagement?: () => void; + onCloseChannelPanel?: () => void; onChannelManagementDeleted?: () => void; onCloseProfilePanel: () => void; onAddAgent?: (options?: { beforeSend?: () => void }) => void; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index b4d33b4776..6b37eb2525 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -78,6 +78,7 @@ import { useChannelActivityTyping } from "./useChannelActivityTyping"; import { useChannelAgentSessions } from "./useChannelAgentSessions"; import { useMessageProfiles } from "./useMessageProfiles"; import { useChannelPanelHistoryState } from "./useChannelPanelHistoryState"; +import { useChannelPanelActions } from "./useChannelPanelActions"; import { useChannelProfilePanel } from "./useChannelProfilePanel"; import { useChannelRouteTarget } from "./useChannelRouteTarget"; import { useChannelUnreadState } from "./useChannelUnreadState"; @@ -98,6 +99,7 @@ export function ChannelScreen({ }: ChannelScreenProps) { const { goHome } = useAppNavigation(); const { activeCommunity } = useCommunities(); + const panelHistory = useChannelPanelHistoryState(); const { markChannelRead, markChannelUnread, @@ -117,6 +119,7 @@ export function ChannelScreen({ } = useAppShell(); const { channelManagementOpen, + channelPanelOpen, clearAutoSend, clearMessageRouteTarget, openAgentSessionChannelId, @@ -126,13 +129,14 @@ export function ChannelScreen({ profilePanelTab, profilePanelView, setChannelManagementOpen, + setChannelPanelOpen, setOpenAgentSessionChannelId, setOpenAgentSessionPubkey, setOpenThreadHeadId, setProfilePanelTab, setProfilePanelPubkey, setProfilePanelView, - } = useChannelPanelHistoryState(); + } = panelHistory; const { canReset: canResetThreadPanelWidth, onResetWidth: handleThreadPanelWidthReset, @@ -262,7 +266,6 @@ export function ChannelScreen({ resolvedMessages, threadReplyEvents, ); - const messageEventProfilePubkeys = useMessageEventProfilePubkeys( resolvedMessages, threadReplyEvents, @@ -614,6 +617,26 @@ export function ChannelScreen({ setThreadReplyTargetId, setThreadScrollTargetId, }); + const { + handleManageChannel, + handleOpenAgentSessionWithPanelClosed, + handleOpenChannelPanel, + handleOpenProfilePanelWithPanelClosed, + handleOpenThreadWithPanelClosed, + } = useChannelPanelActions({ + activeChannelType: activeChannel?.channelType, + channelManagementOpen, + channelPanelOpen, + handleCloseAgentSession, + handleOpenAgentSession, + handleOpenProfilePanel, + handleOpenThreadAndCloseAgentSession, + history: panelHistory, + openGlobalChannelManagement, + setExpandedThreadReplyIds, + setThreadReplyTargetId, + setThreadScrollTargetId, + }); const settledChannelIdRef = React.useRef(null); const hasSettledThisChannel = activeChannelId !== null && settledChannelIdRef.current === activeChannelId; @@ -688,12 +711,12 @@ export function ChannelScreen({ threadReplyTargetId, threadReplyTargetMessage, }); - const hasAuxiliaryPanel = Boolean( effectiveOpenThreadHeadId || openAgentSessionPubkey || profilePanelPubkey || - channelManagementOpen, + channelManagementOpen || + channelPanelOpen, ); const displayedThreadHeadMessage = threadPanelData.threadHead; const displayedThreadAllMessages = threadPanelData.messages; @@ -722,39 +745,10 @@ export function ChannelScreen({ resetKey: activeChannelId, enabled: !isSinglePanelView, }); - - const handleManageChannel = React.useCallback(() => { - if (activeChannel?.channelType === "forum") { - openGlobalChannelManagement(); - return; - } - - if (channelManagementOpen) { - setChannelManagementOpen(false); - return; - } - - setOpenThreadHeadId(null); - setExpandedThreadReplyIds(new Set()); - setThreadScrollTargetId(null); - setThreadReplyTargetId(null); - handleCloseAgentSession(); - setProfilePanelPubkey(null); - setChannelManagementOpen(true); - }, [ - activeChannel?.channelType, - channelManagementOpen, - openGlobalChannelManagement, - setChannelManagementOpen, - setOpenThreadHeadId, - handleCloseAgentSession, - setProfilePanelPubkey, - ]); const handleToggleMembers = React.useCallback( () => setIsMembersSidebarOpen((prev) => !prev), [], ); - const channelHeader = React.useMemo( () => ( @@ -792,6 +788,8 @@ export function ChannelScreen({ joinChannelMutation.mutateAsync, handleManageChannel, handleToggleMembers, + channelPanelOpen, + handleOpenChannelPanel, isSinglePanelView, ], ); @@ -862,6 +860,7 @@ export function ChannelScreen({ botTypingEntries={botTypingEntries} channelFind={channelFind} channelManagementOpen={channelManagementOpen} + channelPanelOpen={channelPanelOpen} currentPubkey={currentPubkey} canResetThreadPanelWidth={canResetThreadPanelWidth} fetchOlder={fetchOlder} @@ -898,6 +897,7 @@ export function ChannelScreen({ isSinglePanelView={isSinglePanelView} isTimelineLoading={isTimelineLoading} messages={timelineMessages} + signedPanelEvents={resolvedMessages} threadSummaries={threadSummaries} onCancelEdit={handleCancelEdit} onCancelThreadReply={handleCancelThreadReply} @@ -921,6 +921,7 @@ export function ChannelScreen({ : undefined } onCloseChannelManagement={handleCloseChannelManagement} + onCloseChannelPanel={() => setChannelPanelOpen(false)} onCloseThread={handleCloseThread} onDelete={ activeChannel?.archivedAt ? undefined : handleDelete @@ -932,12 +933,12 @@ export function ChannelScreen({ onMarkUnread={handleMessageMarkUnread} onMarkRead={handleMessageMarkRead} onExpandThreadReplies={handleExpandThreadReplies} - onOpenAgentSession={handleOpenAgentSession} + onOpenAgentSession={handleOpenAgentSessionWithPanelClosed} onOpenDm={handleOpenDm} - onOpenProfilePanel={handleOpenProfilePanel} + onOpenProfilePanel={handleOpenProfilePanelWithPanelClosed} onResetThreadPanelWidth={handleThreadPanelWidthReset} onCloseProfilePanel={handleCloseProfilePanel} - onOpenThread={handleOpenThreadAndCloseAgentSession} + onOpenThread={handleOpenThreadWithPanelClosed} onSelectThreadReplyTarget={handleSelectThreadReplyTarget} onSendMessage={handleSendMessage} onSendVideoReviewComment={effectiveSendVideoReviewComment} diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index a3a8a20231..1da06a8306 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -40,7 +40,9 @@ type ChannelScreenHeaderProps = { onAddBotOpenChange?: (open: boolean) => void; onJoinChannel?: () => Promise; onManageChannel: () => void; + onOpenChannelPanel: () => void; onToggleMembers: () => void; + channelPanelOpen?: boolean; }; export function ChannelScreenHeader({ @@ -60,7 +62,9 @@ export function ChannelScreenHeader({ transparentChrome = false, onJoinChannel, onManageChannel, + onOpenChannelPanel, onToggleMembers, + channelPanelOpen = false, }: ChannelScreenHeaderProps) { const isGroupDm = activeChannel?.channelType === "dm" && @@ -90,7 +94,9 @@ export function ChannelScreenHeader({ isAddBotOpen={isAddBotOpen} onAddBotOpenChange={onAddBotOpenChange} onManageChannel={onManageChannel} + onOpenChannelPanel={onOpenChannelPanel} onToggleMembers={onToggleMembers} + channelPanelOpen={channelPanelOpen} variant={actionsVariant} /> ) diff --git a/desktop/src/features/channels/ui/SignedChannelPanel.tsx b/desktop/src/features/channels/ui/SignedChannelPanel.tsx new file mode 100644 index 0000000000..0eb3500174 --- /dev/null +++ b/desktop/src/features/channels/ui/SignedChannelPanel.tsx @@ -0,0 +1,479 @@ +import { + AlertTriangle, + ArrowUpRight, + CheckCircle2, + CircleDashed, + Clock3, + ExternalLink, + Info, + LoaderCircle, + ShieldCheck, + TriangleAlert, + XCircle, +} from "lucide-react"; +import type * as React from "react"; + +import { + AuxiliaryPanelBody, + AuxiliaryPanelHeader, + AuxiliaryPanelHeaderActions, + AuxiliaryPanelHeaderGroup, + AuxiliaryPanelTitle, + useAuxiliaryPanel, + type AuxiliaryPanelMode, +} from "@/shared/layout/AuxiliaryPanel"; +import { cn } from "@/shared/lib/cn"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import type { + PanelField, + PanelLink, + PanelManifest, + PanelSection, + PanelSourceEvent, + PanelStatus, + SignedChannelPanelState, +} from "./signedChannelPanelTypes"; + +type SignedChannelPanelProps = { + channelName: string; + state: SignedChannelPanelState; + mode: AuxiliaryPanelMode; + onOpenSourceEvent?: (eventId: string) => void; +}; + +const STATUS_LABELS: Record = { + pending: "Pending", + active: "Active", + complete: "Complete", + blocked: "Blocked", + failed: "Failed", + stale: "Stale", + unavailable: "Unavailable", +}; + +const STATUS_STYLES: Record = { + pending: "border-border bg-muted/60 text-muted-foreground", + active: "border-blue-500/30 bg-blue-500/10 text-blue-700 dark:text-blue-300", + complete: + "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", + blocked: + "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300", + failed: "border-destructive/30 bg-destructive/10 text-destructive", + stale: + "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300", + unavailable: "border-border bg-muted/60 text-muted-foreground", +}; + +export function SignedChannelPanel({ + channelName, + mode, + onOpenSourceEvent, + state, +}: SignedChannelPanelProps) { + const panel = useAuxiliaryPanel(); + + return ( +
+ + + Panel + + + + + +
+
+

+ Signed channel workspace +

+

+ #{channelName} +

+
+ + {renderState({ onOpenSourceEvent, state })} +
+
+
+ ); +} + +function renderState({ + onOpenSourceEvent, + state, +}: { + onOpenSourceEvent?: (eventId: string) => void; + state: SignedChannelPanelState; +}) { + switch (state.kind) { + case "loading": + return ; + case "empty": + return ; + case "unavailable": + return ; + case "invalid": + return ; + case "ready": + case "stale": + return ( + + ); + } +} + +function PanelLoadingState() { + return ( + } + label="Loading signed panel" + message="The panel will remain here while its signed source is resolved." + role="status" + testId="signed-channel-panel-loading" + /> + ); +} + +function PanelEmptyState({ message }: { message?: string }) { + return ( + } + label="No panel published" + message={ + message ?? + "This channel has no signed panel projection yet. Source events remain available in the channel timeline." + } + role="status" + testId="signed-channel-panel-empty" + /> + ); +} + +function PanelUnavailableState({ message }: { message: string }) { + return ( + } + label="Panel unavailable" + message={message} + role="status" + testId="signed-channel-panel-unavailable" + /> + ); +} + +function PanelInvalidState({ message }: { message: string }) { + return ( + } + label="Panel could not be displayed" + message={message} + role="alert" + testId="signed-channel-panel-invalid" + /> + ); +} + +function PanelNotice({ + icon, + label, + message, + role, + testId, +}: { + icon: React.ReactNode; + label: string; + message: string; + role: "alert" | "status"; + testId: string; +}) { + return ( +
+
+ {icon} +
+

{label}

+

{message}

+
+
+
+ ); +} + +function PanelManifestView({ + manifest, + onOpenSourceEvent, + stale, +}: { + manifest: PanelManifest; + onOpenSourceEvent?: (eventId: string) => void; + stale: boolean; +}) { + return ( +
+ {stale ? ( +
+ + + This projection may no longer reflect current channel state. + +
+ ) : null} + +
+
+

+ {manifest.title} +

+ +
+ {manifest.description ? ( +

+ {manifest.description} +

+ ) : null} +
+ + Updated {formatPanelTimestamp(manifest.updatedAt)} +
+
+ +
+ {manifest.sections.map((section) => ( + + ))} +
+ + +
+ ); +} + +function PanelSectionView({ + onOpenSourceEvent, + section, +}: { + onOpenSourceEvent?: (eventId: string) => void; + section: PanelSection; +}) { + return ( +
+
+
+ {section.title} +
+ +
+ {section.fields.length > 0 ? ( +
+ {section.fields.map((field) => ( + + ))} +
+ ) : null} + {section.links.length > 0 ? ( +
+ {section.links.map((link) => ( + + ))} +
+ ) : null} +
+ ); +} + +function PanelFieldView({ field }: { field: PanelField }) { + const isStatus = field.presentation === "status"; + return ( +
+
{field.label}
+
+ {isStatus ? ( + + + + ) : field.presentation === "timestamp" ? ( + formatPanelTimestamp(Number(field.value)) + ) : ( + field.value + )} +
+
+ ); +} + +function PanelLinkView({ + link, + onOpenSourceEvent, +}: { + link: PanelLink; + onOpenSourceEvent?: (eventId: string) => void; +}) { + if (link.target === "external" && link.uri?.startsWith("https://")) { + return ( + + {link.label} + + + ); + } + + if (link.sourceEventId) { + return ( + + ); + } + + return ( + + + {link.label} + + ); +} + +function PanelProvenance({ + onOpenSourceEvent, + sourceEvents, +}: { + onOpenSourceEvent?: (eventId: string) => void; + sourceEvents: PanelSourceEvent[]; +}) { + return ( +
+
+ + Signed sources +
+
    + {sourceEvents.map((source) => ( +
  • + +
  • + ))} +
+
+ ); +} + +function PanelStatusBadge({ status }: { status: PanelStatus }) { + const Icon = + status === "complete" + ? CheckCircle2 + : status === "failed" + ? XCircle + : status === "blocked" || status === "stale" + ? TriangleAlert + : status === "active" + ? LoaderCircle + : Info; + return ( + + + {STATUS_LABELS[status]} + + ); +} + +function normalizeStatus(value: string): PanelStatus { + return value.toLowerCase() in STATUS_LABELS + ? (value.toLowerCase() as PanelStatus) + : "pending"; +} + +function formatPanelTimestamp(unixSeconds: number) { + if (!Number.isFinite(unixSeconds) || unixSeconds <= 0) { + return "an unknown time"; + } + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(new Date(unixSeconds * 1_000)); +} + +function shortEventId(eventId: string) { + return eventId.length > 16 + ? `${eventId.slice(0, 8)}…${eventId.slice(-8)}` + : eventId; +} diff --git a/desktop/src/features/channels/ui/SignedChannelPanelAuxiliaryPanel.tsx b/desktop/src/features/channels/ui/SignedChannelPanelAuxiliaryPanel.tsx new file mode 100644 index 0000000000..d8ede87732 --- /dev/null +++ b/desktop/src/features/channels/ui/SignedChannelPanelAuxiliaryPanel.tsx @@ -0,0 +1,104 @@ +import * as React from "react"; + +import type { RelayEvent } from "@/shared/api/types"; +import { SignedChannelPanel } from "@/features/channels/ui/SignedChannelPanel"; +import type { TimelineMessage } from "@/features/messages/types"; +import { composeSignedChannelPanelState } from "@/features/channels/ui/composeSignedChannelPanel"; +import { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanel"; +import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; + +type SignedChannelPanelAuxiliaryPanelProps = { + canResetPanelWidth: boolean; + channelId: string; + channelName: string; + events: readonly RelayEvent[]; + isLoading: boolean; + isSinglePanelView: boolean; + onClose: () => void; + onOpenThread: (message: TimelineMessage) => void; + onResetPanelWidth: () => void; + onPanelResizeStart: (event: React.PointerEvent) => void; + panelWidthPx: number; + sourceMessages: TimelineMessage[]; + useSplitAuxiliaryPane: boolean; +}; + +export function SignedChannelPanelAuxiliaryPanel({ + canResetPanelWidth, + channelId, + channelName, + events, + isLoading, + isSinglePanelView, + onClose, + onOpenThread, + onResetPanelWidth, + onPanelResizeStart, + panelWidthPx, + sourceMessages, + useSplitAuxiliaryPane, +}: SignedChannelPanelAuxiliaryPanelProps) { + const state = React.useMemo( + () => + isLoading + ? { kind: "loading" as const } + : composeSignedChannelPanelState(channelId, events), + [channelId, events, isLoading], + ); + const onOpenSourceEvent = React.useCallback( + (eventId: string) => { + const sourceMessage = sourceMessages.find( + (message) => message.id === eventId, + ); + if (sourceMessage) onOpenThread(sourceMessage); + }, + [onOpenThread, sourceMessages], + ); + const content = ( + + ); + + if (useSplitAuxiliaryPane) { + return ( + + + {content} + + + ); + } + + return ( + + {content} + + ); +} diff --git a/desktop/src/features/channels/ui/channelSearchKeys.ts b/desktop/src/features/channels/ui/channelSearchKeys.ts index 0828d9fec6..ae032396dd 100644 --- a/desktop/src/features/channels/ui/channelSearchKeys.ts +++ b/desktop/src/features/channels/ui/channelSearchKeys.ts @@ -11,6 +11,7 @@ export const CHANNEL_SEARCH_KEYS = [ "autoSend", "channelManagement", "messageId", + "panel", "profile", "profileTab", "profileView", diff --git a/desktop/src/features/channels/ui/composeSignedChannelPanel.test.mjs b/desktop/src/features/channels/ui/composeSignedChannelPanel.test.mjs new file mode 100644 index 0000000000..90e143bd20 --- /dev/null +++ b/desktop/src/features/channels/ui/composeSignedChannelPanel.test.mjs @@ -0,0 +1,87 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { composeSignedChannelPanelState } from "./composeSignedChannelPanel.ts"; + +const CHANNEL_ID = "00000000-0000-4000-8000-000000000001"; +const OTHER_CHANNEL_ID = "00000000-0000-4000-8000-000000000002"; +const NON_V4_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; + +function id(hex) { + return hex.repeat(64); +} + +function event(overrides = {}) { + return { + id: id("a"), + pubkey: id("b"), + created_at: 1_700_000_000, + kind: 43001, + tags: [["h", CHANNEL_ID]], + content: "request", + sig: id("c"), + ...overrides, + }; +} + +test("composes the latest signed job event and preserves source provenance", () => { + const state = composeSignedChannelPanelState(CHANNEL_ID, [ + event({ id: id("d"), created_at: 1_700_000_001, kind: 43002 }), + event({ + id: id("e"), + created_at: 1_700_000_002, + kind: 43004, + content: "done", + }), + ]); + + assert.equal(state.kind, "ready"); + assert.equal(state.manifest.status, "complete"); + assert.equal(state.manifest.updatedAt, 1_700_000_002); + assert.equal(state.manifest.sourceEvents.length, 2); + assert.equal(state.manifest.sourceEvents[1].label, "Job accepted"); + assert.equal(state.manifest.sections[0].fields[2].value, "Job result"); + assert.equal(state.manifest.sections[0].links[0].sourceEventId, id("e")); +}); + +test("maps cancellation and failure to explicit bounded statuses", () => { + const cancelled = composeSignedChannelPanelState(CHANNEL_ID, [ + event({ kind: 43005 }), + ]); + const failed = composeSignedChannelPanelState(CHANNEL_ID, [ + event({ kind: 43006 }), + ]); + + assert.equal(cancelled.kind, "ready"); + assert.equal(cancelled.manifest.status, "blocked"); + assert.equal(failed.kind, "ready"); + assert.equal(failed.manifest.status, "failed"); +}); + +test("ignores unsigned, pending, cross-channel, and non-job events", () => { + const state = composeSignedChannelPanelState(CHANNEL_ID, [ + event({ id: id("f"), pending: true }), + event({ id: id("1"), sig: "" }), + event({ id: id("2"), tags: [["h", OTHER_CHANNEL_ID]] }), + event({ id: id("3"), kind: 9 }), + ]); + + assert.equal(state.kind, "empty"); +}); + +test("renders a bounded plain-text note", () => { + const state = composeSignedChannelPanelState(CHANNEL_ID, [ + event({ content: "line 1\nline 2\u0000" }), + ]); + + assert.equal(state.kind, "ready"); + assert.equal(state.manifest.sections[0].fields[4].value, "line 1 line 2"); +}); + +test("accepts signed activity in non-v4 channel identifiers", () => { + const state = composeSignedChannelPanelState(NON_V4_CHANNEL_ID, [ + event({ tags: [["h", NON_V4_CHANNEL_ID]] }), + ]); + + assert.equal(state.kind, "ready"); +}); diff --git a/desktop/src/features/channels/ui/composeSignedChannelPanel.ts b/desktop/src/features/channels/ui/composeSignedChannelPanel.ts new file mode 100644 index 0000000000..751f0162f7 --- /dev/null +++ b/desktop/src/features/channels/ui/composeSignedChannelPanel.ts @@ -0,0 +1,193 @@ +import { + KIND_JOB_ACCEPTED, + KIND_JOB_CANCEL, + KIND_JOB_ERROR, + KIND_JOB_PROGRESS, + KIND_JOB_REQUEST, + KIND_JOB_RESULT, +} from "@/shared/constants/kinds"; +import type { RelayEvent } from "@/shared/api/types"; +import { getChannelIdFromTags } from "@/features/messages/lib/threading"; +import type { + PanelManifest, + PanelSection, + PanelSourceEvent, + PanelStatus, + SignedChannelPanelState, +} from "./signedChannelPanelTypes"; + +const JOB_KINDS = new Set([ + KIND_JOB_REQUEST, + KIND_JOB_ACCEPTED, + KIND_JOB_PROGRESS, + KIND_JOB_RESULT, + KIND_JOB_CANCEL, + KIND_JOB_ERROR, +]); +const EVENT_ID_RE = /^[0-9a-f]{64}$/; +const CHANNEL_ID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const MAX_SOURCE_EVENTS = 64; +const MAX_NOTE_LENGTH = 512; + +const JOB_LABELS: Record = { + [KIND_JOB_REQUEST]: "Job requested", + [KIND_JOB_ACCEPTED]: "Job accepted", + [KIND_JOB_PROGRESS]: "Progress update", + [KIND_JOB_RESULT]: "Job result", + [KIND_JOB_CANCEL]: "Job cancellation", + [KIND_JOB_ERROR]: "Job failed", +}; + +/** + * Compose a bounded panel projection from already-verified job events. + * + * This intentionally does not interpret job content or introduce domain + * semantics. The latest Buzz job kind supplies the presentation status; the + * signed event ids remain the panel's provenance. + */ +export function composeSignedChannelPanelState( + channelId: string, + events: readonly RelayEvent[], +): SignedChannelPanelState { + if (!CHANNEL_ID_RE.test(channelId)) { + return { kind: "empty" }; + } + + const sourceEvents = events + .filter( + (event) => + JOB_KINDS.has(event.kind) && + event.pending !== true && + event.sig.length > 0 && + EVENT_ID_RE.test(event.id) && + getChannelIdFromTags(event.tags) === channelId && + Number.isSafeInteger(event.created_at) && + event.created_at > 0, + ) + .sort( + (left, right) => + right.created_at - left.created_at || right.id.localeCompare(left.id), + ) + .slice(0, MAX_SOURCE_EVENTS); + + if (sourceEvents.length === 0) { + return { + kind: "empty", + message: "No signed job activity has been published in this channel yet.", + }; + } + + const latest = sourceEvents[0]; + const status = statusForJobKind(latest.kind); + const sourceEventsForManifest = sourceEvents.map((event) => + toSourceEvent(event, channelId), + ); + const manifest: PanelManifest = { + schemaVersion: 1, + panelId: `${channelId}:signed-work-activity`, + channelId, + title: "Signed work activity", + description: + "A read-only projection of signed job lifecycle events in this channel.", + status, + updatedAt: latest.created_at, + sections: [ + buildActivitySection( + status, + latest, + sourceEvents.length, + sourceEventsForManifest, + ), + ], + sourceEvents: sourceEventsForManifest, + }; + + return { kind: "ready", manifest }; +} + +function buildActivitySection( + status: PanelStatus, + latest: RelayEvent, + eventCount: number, + sourceEvents: PanelSourceEvent[], +): PanelSection { + return { + id: "recent-work", + title: "Recent signed work", + status, + fields: [ + { label: "Status", value: status, presentation: "status" }, + { label: "Events", value: String(eventCount), presentation: "text" }, + { + label: "Latest event", + value: labelForJobKind(latest.kind), + presentation: "text", + }, + { + label: "Latest update", + value: String(latest.created_at), + presentation: "timestamp", + }, + { + label: "Latest note", + value: plainTextNote(latest.content), + presentation: "text", + }, + ], + links: [ + { + label: "Open latest source", + target: "event", + sourceEventId: sourceEvents[0]?.eventId, + }, + ], + }; +} + +function toSourceEvent(event: RelayEvent, channelId: string): PanelSourceEvent { + return { + eventId: event.id, + kind: event.kind, + channelId, + label: labelForJobKind(event.kind), + }; +} + +function statusForJobKind(kind: number): PanelStatus { + switch (kind) { + case KIND_JOB_REQUEST: + return "pending"; + case KIND_JOB_ACCEPTED: + case KIND_JOB_PROGRESS: + return "active"; + case KIND_JOB_RESULT: + return "complete"; + case KIND_JOB_CANCEL: + return "blocked"; + case KIND_JOB_ERROR: + return "failed"; + default: + return "unavailable"; + } +} + +function labelForJobKind(kind: number) { + return JOB_LABELS[kind] ?? "Signed job event"; +} + +function plainTextNote(content: string) { + const note = content + .split("") + .map((character) => { + const code = character.charCodeAt(0); + return code < 32 || code === 127 ? " " : character; + }) + .join("") + .replace(/\s+/g, " ") + .trim(); + if (!note) return "No note provided."; + return note.length > MAX_NOTE_LENGTH + ? `${note.slice(0, MAX_NOTE_LENGTH - 1)}…` + : note; +} diff --git a/desktop/src/features/channels/ui/signedChannelPanelTypes.ts b/desktop/src/features/channels/ui/signedChannelPanelTypes.ts new file mode 100644 index 0000000000..cdc170b487 --- /dev/null +++ b/desktop/src/features/channels/ui/signedChannelPanelTypes.ts @@ -0,0 +1,74 @@ +/** + * Desktop's read-only view of the signed channel-panel contract. + * + * Transport adapters should validate the wire payload before constructing + * these values. The renderer intentionally does not fetch or execute anything + * named by a manifest. + */ + +export type PanelStatus = + | "pending" + | "active" + | "complete" + | "blocked" + | "failed" + | "stale" + | "unavailable"; + +export type PanelPresentation = "text" | "monospace" | "timestamp" | "status"; + +export type PanelLinkTarget = + | "canvas" + | "workflow" + | "handoff" + | "thread" + | "event" + | "external"; + +export type PanelLink = { + label: string; + target: PanelLinkTarget; + sourceEventId?: string; + uri?: string; +}; + +export type PanelSourceEvent = { + eventId: string; + kind: number; + channelId: string; + label: string; +}; + +export type PanelField = { + label: string; + value: string; + presentation: PanelPresentation; +}; + +export type PanelSection = { + id: string; + title: string; + status: PanelStatus; + fields: PanelField[]; + links: PanelLink[]; +}; + +export type PanelManifest = { + schemaVersion: number; + panelId: string; + channelId: string; + title: string; + description?: string; + status: PanelStatus; + updatedAt: number; + sections: PanelSection[]; + sourceEvents: PanelSourceEvent[]; +}; + +export type SignedChannelPanelState = + | { kind: "loading" } + | { kind: "empty"; message?: string } + | { kind: "ready"; manifest: PanelManifest } + | { kind: "stale"; manifest: PanelManifest } + | { kind: "unavailable"; message: string } + | { kind: "invalid"; message: string }; diff --git a/desktop/src/features/channels/ui/useChannelPanelActions.ts b/desktop/src/features/channels/ui/useChannelPanelActions.ts new file mode 100644 index 0000000000..aa52f9615a --- /dev/null +++ b/desktop/src/features/channels/ui/useChannelPanelActions.ts @@ -0,0 +1,131 @@ +import * as React from "react"; +import type { TimelineMessage } from "@/features/messages/types"; + +type UseChannelPanelActionsOptions = { + activeChannelType?: string | null; + channelManagementOpen: boolean; + channelPanelOpen: boolean; + handleCloseAgentSession: () => void; + handleOpenAgentSession: (pubkey: string, channelId?: string | null) => void; + handleOpenProfilePanel: (pubkey: string) => void; + handleOpenThreadAndCloseAgentSession: (message: TimelineMessage) => void; + history: { + setChannelManagementOpen: (open: boolean) => void; + setChannelPanelOpen: (open: boolean) => void; + setOpenThreadHeadId: (id: string | null) => void; + setProfilePanelPubkey: (pubkey: string | null) => void; + }; + openGlobalChannelManagement: () => void; + setExpandedThreadReplyIds: (ids: Set) => void; + setThreadReplyTargetId: (id: string | null) => void; + setThreadScrollTargetId: (id: string | null) => void; +}; + +export function useChannelPanelActions({ + activeChannelType, + channelManagementOpen, + channelPanelOpen, + handleCloseAgentSession, + handleOpenAgentSession, + handleOpenProfilePanel, + handleOpenThreadAndCloseAgentSession, + history, + openGlobalChannelManagement, + setExpandedThreadReplyIds, + setThreadReplyTargetId, + setThreadScrollTargetId, +}: UseChannelPanelActionsOptions) { + const { + setChannelManagementOpen, + setChannelPanelOpen, + setOpenThreadHeadId, + setProfilePanelPubkey, + } = history; + const handleOpenChannelPanel = React.useCallback(() => { + if (channelPanelOpen) { + setChannelPanelOpen(false); + return; + } + + setChannelManagementOpen(false); + setOpenThreadHeadId(null); + setExpandedThreadReplyIds(new Set()); + setThreadScrollTargetId(null); + setThreadReplyTargetId(null); + handleCloseAgentSession(); + setProfilePanelPubkey(null); + setChannelPanelOpen(true); + }, [ + channelPanelOpen, + handleCloseAgentSession, + setChannelManagementOpen, + setChannelPanelOpen, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setProfilePanelPubkey, + setThreadReplyTargetId, + setThreadScrollTargetId, + ]); + + const handleOpenThreadWithPanelClosed = React.useCallback( + (message: TimelineMessage) => { + setChannelPanelOpen(false); + handleOpenThreadAndCloseAgentSession(message); + }, + [handleOpenThreadAndCloseAgentSession, setChannelPanelOpen], + ); + const handleOpenAgentSessionWithPanelClosed = React.useCallback( + (pubkey: string, channelId?: string | null) => { + setChannelPanelOpen(false); + handleOpenAgentSession(pubkey, channelId); + }, + [handleOpenAgentSession, setChannelPanelOpen], + ); + const handleOpenProfilePanelWithPanelClosed = React.useCallback( + (pubkey: string) => { + setChannelPanelOpen(false); + handleOpenProfilePanel(pubkey); + }, + [handleOpenProfilePanel, setChannelPanelOpen], + ); + const handleManageChannel = React.useCallback(() => { + if (activeChannelType === "forum") { + openGlobalChannelManagement(); + return; + } + + setChannelPanelOpen(false); + if (channelManagementOpen) { + setChannelManagementOpen(false); + return; + } + + setOpenThreadHeadId(null); + setExpandedThreadReplyIds(new Set()); + setThreadScrollTargetId(null); + setThreadReplyTargetId(null); + handleCloseAgentSession(); + setProfilePanelPubkey(null); + setChannelManagementOpen(true); + }, [ + activeChannelType, + channelManagementOpen, + handleCloseAgentSession, + openGlobalChannelManagement, + setChannelManagementOpen, + setChannelPanelOpen, + setExpandedThreadReplyIds, + setOpenThreadHeadId, + setProfilePanelPubkey, + setThreadReplyTargetId, + setThreadScrollTargetId, + ]); + + return { + handleManageChannel, + handleOpenAgentSessionWithPanelClosed, + handleOpenChannelPanel, + handleOpenProfilePanelWithPanelClosed, + handleOpenThreadWithPanelClosed, + }; +} diff --git a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts index 24ccf1cce9..d0e3008b87 100644 --- a/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts +++ b/desktop/src/features/channels/ui/useChannelPanelHistoryState.ts @@ -26,7 +26,8 @@ export type { ChannelSearchKey } from "./channelSearchKeys"; * tab), `agentSession` (agent session panel pubkey), `agentSessionChannel` * (optional channel scope for the agent session panel), `channelManagement` * (presence flag for the channel-management panel — open/closed only, so it - * carries a sentinel `"1"` rather than an id), `autoSend` (draft auto-submit + * carries a sentinel `"1"` rather than an id), `panel` (presence flag for the + * signed channel-panel surface), `autoSend` (draft auto-submit * trigger — cleared surgically after the auto-submit fires so `thread` and * all other panel state are preserved). */ @@ -39,6 +40,7 @@ export type PanelValueSetter = ( ) => void; const CHANNEL_MANAGEMENT_OPEN_VALUE = "1"; +const CHANNEL_PANEL_OPEN_VALUE = "1"; export function useChannelPanelHistoryState() { const { applyPatch, values } = useHistorySearchState(CHANNEL_SEARCH_KEYS); @@ -94,6 +96,12 @@ export function useChannelPanelHistoryState() { [applyPatch], ); + const setChannelPanelOpen = React.useCallback( + (open: boolean, options?: PanelSetterOptions) => + applyPatch({ panel: open ? CHANNEL_PANEL_OPEN_VALUE : null }, options), + [applyPatch], + ); + const clearMessageRouteTarget = React.useCallback( (options?: PanelSetterOptions) => applyPatch({ messageId: null, threadRootId: null }, options), @@ -112,6 +120,7 @@ export function useChannelPanelHistoryState() { return { channelManagementOpen: values.channelManagement != null, + channelPanelOpen: values.panel != null, clearAutoSend, clearMessageRouteTarget, openAgentSessionChannelId: values.agentSessionChannel, @@ -121,6 +130,7 @@ export function useChannelPanelHistoryState() { profilePanelTab: profilePanelTabFromSearch(values.profileTab), profilePanelView: profilePanelViewFromSearch(values.profileView), setChannelManagementOpen, + setChannelPanelOpen, setOpenAgentSessionChannelId, setOpenAgentSessionPubkey, setOpenThreadHeadId, diff --git a/desktop/src/shared/constants/kinds.test.mjs b/desktop/src/shared/constants/kinds.test.mjs index e842574f99..738e97a197 100644 --- a/desktop/src/shared/constants/kinds.test.mjs +++ b/desktop/src/shared/constants/kinds.test.mjs @@ -7,6 +7,7 @@ import { KIND_STREAM_MESSAGE_V2, KIND_STREAM_MESSAGE_DIFF, KIND_SYSTEM_MESSAGE, + CHANNEL_EVENT_KINDS, KIND_JOB_REQUEST, KIND_JOB_ACCEPTED, KIND_JOB_PROGRESS, @@ -51,6 +52,19 @@ test("isConversationalUnreadKind_allJobKinds_excluded", () => { } }); +test("channel live subscriptions include all signed job kinds", () => { + for (const kind of [ + KIND_JOB_REQUEST, + KIND_JOB_ACCEPTED, + KIND_JOB_PROGRESS, + KIND_JOB_RESULT, + KIND_JOB_CANCEL, + KIND_JOB_ERROR, + ]) { + assert.equal(CHANNEL_EVENT_KINDS.includes(kind), true, `kind ${kind}`); + } +}); + test("isConversationalUnreadKind_huddleLifecycle_excluded", () => { for (const kind of [ KIND_HUDDLE_STARTED, diff --git a/desktop/src/shared/constants/kinds.ts b/desktop/src/shared/constants/kinds.ts index ef3234f4c5..eab159b876 100644 --- a/desktop/src/shared/constants/kinds.ts +++ b/desktop/src/shared/constants/kinds.ts @@ -94,6 +94,12 @@ export const CHANNEL_EVENT_KINDS = [ KIND_STREAM_MESSAGE_EDIT, // 40003 — message edits KIND_STREAM_MESSAGE_DIFF, // 40008 — message diffs KIND_SYSTEM_MESSAGE, // 40099 — system messages (join, leave, etc.) + KIND_JOB_REQUEST, // 43001 — signed job lifecycle + KIND_JOB_ACCEPTED, // 43002 — signed job lifecycle + KIND_JOB_PROGRESS, // 43003 — signed job lifecycle + KIND_JOB_RESULT, // 43004 — signed job lifecycle + KIND_JOB_CANCEL, // 43005 — signed job lifecycle + KIND_JOB_ERROR, // 43006 — signed job lifecycle KIND_HUDDLE_STARTED, // 48100 — visible huddle session card KIND_HUDDLE_PARTICIPANT_JOINED, // 48101 — huddle lifecycle overlay KIND_HUDDLE_PARTICIPANT_LEFT, // 48102 — huddle lifecycle overlay diff --git a/desktop/tests/e2e/signed-channel-panel.spec.ts b/desktop/tests/e2e/signed-channel-panel.spec.ts new file mode 100644 index 0000000000..786e94e10c --- /dev/null +++ b/desktop/tests/e2e/signed-channel-panel.spec.ts @@ -0,0 +1,95 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; + +test.beforeEach(async ({ page }) => { + await installMockBridge(page); + await page.goto("/", { waitUntil: "networkidle" }); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general", 43004); +}); + +async function waitForMockLiveSubscription( + page: import("@playwright/test").Page, + channelName: string, + kind: number, +) { + await expect + .poll(async () => { + return page.evaluate( + ({ currentChannelName, expectedKind }) => { + return ( + ( + window as Window & { + __BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: { + channelName: string; + kind: number; + }) => boolean; + } + ).__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: currentChannelName, + kind: expectedKind, + }) ?? false + ); + }, + { currentChannelName: channelName, expectedKind: kind }, + ); + }) + .toBe(true); +} + +test("opens the signed channel panel and preserves its empty state", async ({ + page, +}) => { + await page.getByTestId("channel-panel-trigger").click(); + + await expect(page.getByTestId("signed-channel-panel")).toBeVisible(); + await expect(page.getByTestId("signed-channel-panel-empty")).toBeVisible(); + await expect(page.getByTestId("signed-channel-panel-empty")).toContainText( + "No signed job activity has been published in this channel yet.", + ); + await expect(page).toHaveURL(/panel=%221%22/); + + await page.getByTestId("auxiliary-panel-close").click(); + await expect(page.getByTestId("signed-channel-panel")).toBeHidden(); + await expect(page).not.toHaveURL(/panel=%22/); +}); + +test("renders signed job activity with source provenance", async ({ page }) => { + await page.evaluate(() => { + ( + window as Window & { + __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { + channelName: string; + content: string; + kind: number; + id: string; + createdAt: number; + }) => unknown; + } + ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: "Validated the signed deliverable and recorded the result.", + kind: 43004, + id: "f".repeat(64), + createdAt: 2_000_000_000, + }); + }); + + await expect(page.getByTestId("channel-panel-trigger")).toBeVisible(); + await page.getByTestId("channel-panel-trigger").click(); + await expect(page.getByTestId("signed-channel-panel-ready")).toBeVisible(); + await expect( + page.getByTestId("signed-channel-panel-status-complete").first(), + ).toBeVisible(); + await expect( + page.getByTestId("signed-channel-panel-provenance"), + ).toContainText("Job result"); + + await waitForAnimations(page); + await page.screenshot({ + path: "test-results/screenshots/signed-channel-panel-ready.png", + }); +}); diff --git a/docs/fixtures/signed-channel-panel.json b/docs/fixtures/signed-channel-panel.json new file mode 100644 index 0000000000..f0b3962420 --- /dev/null +++ b/docs/fixtures/signed-channel-panel.json @@ -0,0 +1,96 @@ +{ + "schemaVersion": 1, + "panelId": "exchange-outcome-demo", + "channelId": "00000000-0000-4000-8000-000000000001", + "title": "Documentation marker", + "description": "A bounded, read-only operational projection.", + "status": "active", + "updatedAt": 1760000000, + "sections": [ + { + "id": "provider-pool", + "title": "Provider pool", + "status": "active", + "fields": [ + { + "label": "Ferris", + "value": "Rust · available", + "presentation": "text" + }, + { + "label": "Sable", + "value": "Security · available", + "presentation": "text" + }, + { + "label": "Prism", + "value": "Frontend · available", + "presentation": "text" + }, + { + "label": "Gauge", + "value": "Testing · available", + "presentation": "text" + }, + { + "label": "Ledger", + "value": "Database · available", + "presentation": "text" + }, + { + "label": "Hook", + "value": "Integration · available", + "presentation": "text" + } + ], + "links": [] + }, + { + "id": "outcome", + "title": "Outcome", + "status": "active", + "fields": [ + { + "label": "Request", + "value": "Add a bounded documentation marker", + "presentation": "text" + }, + { + "label": "Match", + "value": "Ferris selected", + "presentation": "status" + }, + { + "label": "Evidence", + "value": "Patch hash and validation result", + "presentation": "monospace" + }, + { + "label": "Decision", + "value": "Awaiting buyer acceptance", + "presentation": "status" + }, + { + "label": "Settlement", + "value": "Pending acceptance", + "presentation": "status" + } + ], + "links": [] + } + ], + "sourceEvents": [ + { + "eventId": "1111111111111111111111111111111111111111111111111111111111111111", + "kind": 43001, + "channelId": "00000000-0000-4000-8000-000000000001", + "label": "Outcome request" + }, + { + "eventId": "2222222222222222222222222222222222222222222222222222222222222222", + "kind": 43004, + "channelId": "00000000-0000-4000-8000-000000000001", + "label": "Evidence result" + } + ] +} diff --git a/docs/signed-channel-extension-panel.md b/docs/signed-channel-extension-panel.md new file mode 100644 index 0000000000..23357902b4 --- /dev/null +++ b/docs/signed-channel-extension-panel.md @@ -0,0 +1,220 @@ +# Signed channel extension panel + +Status: design proposal for [#3863](https://github.com/block/buzz/issues/3863). + +This document is the contract slice of the panel work. It defines the generic +projection that a later Desktop implementation may render. It does not add a +relay event kind, a new HTTP endpoint, an integration adapter, or a panel to +the client. + +## Product boundary + +A panel is a compact, read-only projection of signed Buzz state in the current +channel. It is not a second source of truth and it does not become a workflow, +acceptance, accounting, or settlement authority. + +Buzz owns: + +- panel placement and rendering; +- community and channel permission checks; +- signature and source-event provenance; +- bounded parsing and fail-closed behavior; and +- fallback links to the source Canvas, workflow, handoff, or thread. + +An integration owns: + +- domain semantics and policy; +- matching, acceptance, and dispute decisions; +- accounting and settlement; and +- external credentials and execution. + +The contract deliberately uses generic sections and fields. A panel may be +useful for an exchange, an incident, a deployment, or another workflow without +Buzz learning that domain's rules. + +## View-model contract + +The transport envelope remains an open decision for maintainers. The first +contract slice defines the content that a verified, channel-scoped source event +or adapter must provide: + +```text +PanelManifest +├── schemaVersion integer, currently 1 +├── panelId stable identifier within the channel +├── channelId canonical channel UUID +├── title short human-readable title +├── description optional plain-text context +├── status pending | active | complete | blocked | failed | +│ stale | unavailable +├── updatedAt Unix seconds +├── sections[] +│ ├── id stable within the manifest +│ ├── title short section heading +│ ├── status same bounded status vocabulary +│ ├── fields[] +│ │ ├── label short plain-text label +│ │ ├── value plain-text value +│ │ └── presentation text | monospace | timestamp | status +│ └── links[] +│ ├── label short action label +│ ├── target canvas | workflow | handoff | thread | event | external +│ ├── sourceEventId optional 64-character event id +│ └── uri optional, only for an external HTTPS destination +└── sourceEvents[] + ├── eventId 64-character event id + ├── kind unsigned event kind + ├── channelId canonical channel UUID + └── label short provenance label +``` + +The wire representation is JSON with the same names and types. Objects MUST +not contain unknown fields, and a client MUST reject a manifest whose +`schemaVersion` is not exactly `1`. Additive fields belong to a later schema +version; clients must not guess how to render an unknown version. + +### Field rules + +- All identifiers are ASCII strings with explicit length limits. `panelId` and + section ids are at most 128 bytes; labels and titles are at most 256 bytes; + field values and descriptions are at most 4,096 bytes. +- `channelId` is a canonical lowercase UUID. Every source event must carry the + same channel id as the manifest. +- `eventId` and `sourceEventId` are lowercase 64-character hexadecimal event + ids. A link that points to a Buzz object uses a source event reference rather + than an arbitrary URL. +- `presentation` is an allowlist, not a CSS class or renderer name. Unknown + hints fall back to `text` only when the schema version is known; unknown + required enum values reject the manifest. +- `status` is conveyed by text and a semantic badge. Color is supplemental and + must never be the only status signal. +- `external` links require an `https:` URI. The renderer does not fetch the URI, + execute its contents, or embed it in an iframe. +- Sections, fields, links, and source events are bounded collections. A + proposed first implementation caps them at 32, 64, 32, and 64 items + respectively, and caps the UTF-8 manifest content at 32 KiB. These limits + keep the payload below the relay's 65,536-byte frame limit with room for the + signed event envelope. + +## Signature and provenance + +The panel may only be built from a verified Nostr event or from events already +verified by the relay and returned through the authenticated query path. A +future transport adapter must preserve the original signed event id and kind; +it must not replace source events with an unsigned integration response. + +Before display, the client or adapter MUST: + +1. resolve the current community from the configured relay and the current + channel from the route/context; +2. require normal channel membership for the current reader; +3. verify that the manifest channel id equals the current channel id; +4. verify every source event reference is channel-local and was obtained from + the same community; +5. verify the event id/signature when the raw signed event is available; and +6. reject malformed, oversized, stale-by-policy, or cross-channel data. + +The panel displays source-event ids as inspectable provenance and offers a +deep link back to the source event. It must not infer authorship, acceptance, +settlement, or correctness from a presentation status alone. + +## Transport decision still required + +This proposal intentionally does not reserve a new event kind. Existing source +events cover several useful cases: + +| Source | Existing kind | What it can provide | +| --- | ---: | --- | +| Canvas revision | 40100 | channel document and author/timestamp provenance | +| Job request/result | 43001–43006 | requested outcome, progress, result, or failure | +| Workflow run | 46001–46012 | trigger, step, approval, and terminal status | +| Thread/message | 9 / 40002 | conversation context and acknowledgements | + +None of these alone is a generic panel manifest. Maintainers should choose +between composing a projection from those source events and registering one +new signed, channel-scoped manifest event. The eventual event choice must be +documented and tested before the Desktop panel PR. This contract PR must not +silently overload kind 9, Canvas, or workflow events with a second meaning. + +Regardless of transport, all writes remain ordinary signed Nostr events and +must use the existing relay authorization, persistence, query, fan-out, audit, +and community-scoping paths. No bespoke HTTP API is part of this proposal. + +## Desktop surface contract + +The renderer PR should use the existing channel auxiliary-panel shell and make +the panel discoverable from the current channel. It should preserve reading, +threading, and composing in the channel instead of replacing the timeline. + +Required states: + +- loading: the panel location is stable while the source query resolves; +- empty: no panel projection is available, with a link to the channel's source + Canvas or thread when one exists; +- ready: title, overall status, sections, last update, attribution, and source + links are visible without raw JSON; +- stale: the last update is visible and the panel explains that the projection + may no longer describe current source events; +- unavailable: the reader lacks access or the source cannot be resolved; +- invalid: malformed or unknown-version data is rejected with a concise error + and a source-event fallback where possible. + +The panel must remain usable in a narrow window. Headings and status badges use +semantic accessibility labels; source links are keyboard-focusable; status is +not communicated by color alone; and the raw manifest is available only as an +explicit inspection affordance, never as the primary presentation. + +## Security boundary + +The panel MUST NOT: + +- execute JavaScript, WebAssembly, provider code, or arbitrary HTML; +- embed remote pages, iframes, plugins, or webviews; +- read another channel or community because a manifest names it; +- display private provider, buyer, credential, or integration data not present + in an authorized source event; +- upload local files or scan a working directory; +- call matching, acceptance, payment, accounting, or settlement APIs; or +- turn a visual status into an authorization or business decision. + +Unknown schemas, invalid signatures, cross-channel references, oversized +payloads, unsupported link schemes, and malformed identifiers all fail closed. + +## Deterministic fixture + +`docs/fixtures/signed-channel-panel.json` is a non-secret manifest fixture for +parser and renderer tests. It describes a small provider-and-deliverable +workflow using generic Buzz fields. It is not an OEXL integration, does not +contain credentials, and is not intended to be published to a live relay. + +Tests that need a signed event should sign this content with an ephemeral test +key inside the test process. No private key belongs in the fixture or the +repository. + +## Verification matrix for follow-up implementation + +The protocol and Desktop slices should cover at least: + +- valid version-1 manifest round-trip; +- unknown schema version rejection; +- malformed JSON, duplicate/unknown required fields, and oversized content; +- invalid event id/signature and a source event from another channel; +- unsupported status, presentation hint, and link scheme; +- loading, empty, ready, stale, unavailable, and invalid rendering; +- human and agent authorship attribution; +- keyboard navigation and screen-reader labels; and +- narrow-window layout without hiding the source-event fallback. + +## Explicit non-goals + +- OEXL matching, pricing, acceptance, dispute, accounting, payments, or + settlement inside Buzz; +- an OEXL-specific mode or marketplace dashboard; +- replacing Canvases, workflows, jobs, threads, or the activity feed; +- a generic third-party plugin runtime; +- arbitrary remote UI; and +- a Desktop implementation in this contract PR. + +The next PR may render this generic contract in the existing channel panel. An +integration adapter should follow only if maintainers want a stable adapter +surface after the transport and rendering contracts are accepted.