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..a6c5a51bbc 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"; @@ -64,6 +65,7 @@ import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; import { channelChrome } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; +const EMPTY_SIGNED_CHANNEL_PANEL_STATE = { kind: "empty" as const }; export const ChannelPane = React.memo(function ChannelPane({ activeChannel, agentPubkeys, @@ -75,6 +77,7 @@ export const ChannelPane = React.memo(function ChannelPane({ botTypingEntries, channelFind, channelManagementOpen = false, + channelPanelOpen = false, currentPubkey, editTarget = null, fetchOlder, @@ -104,6 +107,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onBackFromAgentSession, onCloseAgentSession, onCloseChannelManagement, + onCloseChannelPanel, onChannelManagementDeleted, onCloseProfilePanel, onAddAgent, @@ -536,6 +540,7 @@ export const ChannelPane = React.memo(function ChannelPane({ const hasSplitAuxiliaryPane = useSplitAuxiliaryPane && (channelManagementOpen || + channelPanelOpen || Boolean(threadHeadMessage) || shouldShowThreadSkeleton || Boolean(activeChannel && selectedAgent) || @@ -811,16 +816,21 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : 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. - */} + {/* Keep the focus thread drawer mounted through its exit animation. */} - {channelManagementOpen && activeChannel ? ( + {channelPanelOpen && activeChannel ? ( + undefined)} + onResetPanelWidth={onResetThreadPanelWidth} + onPanelResizeStart={onThreadPanelResizeStart} + panelWidthPx={threadPanelWidthPx} + state={EMPTY_SIGNED_CHANNEL_PANEL_STATE} + useSplitAuxiliaryPane={useSplitAuxiliaryPane} + /> + ) : channelManagementOpen && activeChannel ? ( ; channelManagementOpen?: boolean; + channelPanelOpen?: boolean; currentPubkey?: string; editTarget?: { author: string; @@ -75,6 +76,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..ba9fd82088 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} @@ -921,6 +920,7 @@ export function ChannelScreen({ : undefined } onCloseChannelManagement={handleCloseChannelManagement} + onCloseChannelPanel={() => setChannelPanelOpen(false)} onCloseThread={handleCloseThread} onDelete={ activeChannel?.archivedAt ? undefined : handleDelete @@ -932,12 +932,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..ee316af33d --- /dev/null +++ b/desktop/src/features/channels/ui/SignedChannelPanelAuxiliaryPanel.tsx @@ -0,0 +1,80 @@ +import type * as React from "react"; + +import { SignedChannelPanel } from "@/features/channels/ui/SignedChannelPanel"; +import type { SignedChannelPanelState } from "@/features/channels/ui/signedChannelPanelTypes"; +import { AuxiliaryPanel } from "@/shared/layout/AuxiliaryPanel"; +import { RightAuxiliaryPane } from "@/features/channels/ui/RightAuxiliaryPane"; + +type SignedChannelPanelAuxiliaryPanelProps = { + canResetPanelWidth: boolean; + channelName: string; + isSinglePanelView: boolean; + onClose: () => void; + onOpenSourceEvent?: (eventId: string) => void; + onResetPanelWidth: () => void; + onPanelResizeStart: (event: React.PointerEvent) => void; + panelWidthPx: number; + state: SignedChannelPanelState; + useSplitAuxiliaryPane: boolean; +}; + +export function SignedChannelPanelAuxiliaryPanel({ + canResetPanelWidth, + channelName, + isSinglePanelView, + onClose, + onOpenSourceEvent, + onResetPanelWidth, + onPanelResizeStart, + panelWidthPx, + state, + useSplitAuxiliaryPane, +}: SignedChannelPanelAuxiliaryPanelProps) { + 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/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/tests/e2e/signed-channel-panel.spec.ts b/desktop/tests/e2e/signed-channel-panel.spec.ts new file mode 100644 index 0000000000..fca0c06bdf --- /dev/null +++ b/desktop/tests/e2e/signed-channel-panel.spec.ts @@ -0,0 +1,27 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +test.beforeEach(async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); +}); + +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 panel projection", + ); + 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/); +}); 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.