diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 2c173646bac..cf35ca34b0e 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -3785,6 +3785,33 @@ fn parse_dm_response(json: serde_json::Value, limit: u32) -> Option Vec { + let Some(raw_tags) = obj.get("tags").and_then(|value| value.as_array()) else { + return Vec::new(); + }; + let tags = raw_tags + .iter() + .filter_map(|value| { + let parts = value + .as_array()? + .iter() + .map(|part| part.as_str().map(str::to_string)) + .collect::>>()?; + nostr::Tag::parse(parts).ok() + }) + .collect::>(); + let tags = nostr::Tags::from_list(tags); + let workstream_id = tags.iter().find_map(|tag| { + let values = tag.as_slice(); + (values.first().map(String::as_str) == Some("h")) + .then(|| values.get(1)) + .flatten() + }); + workstream_id + .map(|id| buzz_sdk::parse_board_references_for_workstream(&tags, id)) + .unwrap_or_default() +} + /// Extract a `ContextMessage` from a JSON message object. /// /// Works with both thread reply objects and channel message objects. @@ -3819,6 +3846,7 @@ fn json_to_context_message(obj: &serde_json::Value) -> Option { pubkey: pubkey.to_string(), timestamp, content: content.to_string(), + board_references: json_board_references(obj), }) } @@ -5846,6 +5874,54 @@ mod tests { assert!(json_to_context_message(&obj).is_none()); } + #[test] + fn test_json_to_context_message_projects_only_strict_board_references() { + let workstream_id = "123e4567-e89b-12d3-a456-426614174000"; + let valid = json!({ + "kind": "workstream", + "identity": workstream_id, + "snapshot": { "label": "Checkout recovery" }, + "placement": { + "workstreamId": workstream_id, + "workstreamLabel": "checkout-recovery", + "sectionId": "workstream", + "sectionLabel": "Workstream" + } + }); + let mut unknown = valid.clone(); + unknown["snapshot"]["extra"] = json!(true); + let mut controlled = valid.clone(); + controlled["snapshot"]["label"] = json!("bad\u{0085}label"); + let mut forged = valid.clone(); + forged["identity"] = json!("123e4567-e89b-12d3-a456-426614174001"); + let tag = |value: serde_json::Value| { + json!([ + buzz_sdk::BOARD_REFERENCE_TAG, + buzz_sdk::BOARD_REFERENCE_VERSION, + serde_json::to_string(&value).unwrap() + ]) + }; + let obj = json!({ + "pubkey": "abc", + "content": "hello", + "created_at": 1710518400, + "tags": [ + ["h", workstream_id], + tag(unknown), + tag(controlled), + tag(forged), + tag(valid.clone()) + ] + }); + + let msg = json_to_context_message(&obj).expect("message survives bad references"); + assert_eq!(msg.board_references.len(), 1); + assert_eq!( + serde_json::to_value(&msg.board_references[0]).unwrap(), + valid + ); + } + #[test] fn test_collect_prompt_pubkeys_includes_authors_mentions_and_context() { let keys = Keys::generate(); @@ -5875,6 +5951,7 @@ mod tests { pubkey: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(), timestamp: "2026-03-25T05:51:25Z".into(), content: "follow up".into(), + board_references: vec![], }], total: 1, truncated: false, @@ -5951,6 +6028,7 @@ mod tests { pubkey: "author".into(), timestamp: "2026-08-09T00:00:00Z".into(), content: content.into(), + board_references: vec![], } } diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index b0f0fa248e3..44f9ecc0e56 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1029,6 +1029,8 @@ pub struct ContextMessage { pub pubkey: String, pub timestamp: String, pub content: String, + /// Validated, ordered Board references; raw reference tags are never projected. + pub board_references: Vec, } /// Channel metadata for prompt formatting. @@ -1107,6 +1109,17 @@ fn format_prompt_actor(pubkey: &str, profile_lookup: Option<&PromptProfileLookup } } +/// Append a validated machine-readable projection associated with one message. +fn append_board_references(output: &mut String, references: &[buzz_sdk::BoardReference]) { + if references.is_empty() { + return; + } + if let Ok(json) = serde_json::to_string(references) { + output.push_str("\nBoard references (validated JSON): "); + output.push_str(&json); + } +} + /// Format the per-event `[Event]` block for a single [`BatchEvent`]. /// /// Includes: event_id, channel (name + UUID), kind, sender (hex + npub), @@ -1152,10 +1165,31 @@ pub(crate) fn format_event_block( ); // Always include tags — they carry structural information. - let tags_json: Vec<&[String]> = be.event.tags.iter().map(|t| t.as_slice()).collect(); + let tags_json: Vec<&[String]> = be + .event + .tags + .iter() + .map(|t| t.as_slice()) + .filter(|tag| tag.first().map(String::as_str) != Some(buzz_sdk::BOARD_REFERENCE_TAG)) + .collect(); if let Ok(tags_str) = serde_json::to_string(&tags_json) { block.push_str(&format!("\nTags: {tags_str}")); } + let board_references = be + .event + .tags + .iter() + .find_map(|tag| { + let values = tag.as_slice(); + (values.first().map(String::as_str) == Some("h")) + .then(|| values.get(1)) + .flatten() + }) + .map(|workstream_id| { + buzz_sdk::parse_board_references_for_workstream(&be.event.tags, workstream_id) + }) + .unwrap_or_default(); + append_board_references(&mut block, &board_references); // Parsed structural fields. let thread = parse_thread_tags(&be.event); @@ -1440,6 +1474,7 @@ fn format_conversation_context( msg.timestamp, msg.content, )); + append_board_references(&mut s, &msg.board_references); } s } @@ -2744,6 +2779,7 @@ mod tests { event_id: String::new(), pubkey: "npub1test".into(), content: "prior message".into(), + board_references: vec![], timestamp: "2024-01-01T00:00:00Z".into(), }], total: 1, @@ -3362,12 +3398,14 @@ mod tests { pubkey: "npub1xyz".into(), timestamp: "2026-03-15T16:30:00Z".into(), content: "Let's refactor auth".into(), + board_references: vec![], }, ContextMessage { event_id: String::new(), pubkey: "npub1def".into(), timestamp: "2026-03-15T16:35:00Z".into(), content: "yes go ahead".into(), + board_references: vec![], }, ], total: 5, @@ -3412,6 +3450,7 @@ mod tests { pubkey: "npub1abc".into(), timestamp: "2026-03-15T16:00:00Z".into(), content: "Can you deploy?".into(), + board_references: vec![], }], total: 1, truncated: false, @@ -3458,6 +3497,7 @@ mod tests { pubkey: author_hex.clone(), timestamp: "2026-03-25T05:51:25Z".into(), content: "follow up".into(), + board_references: vec![], }], total: 1, truncated: false, @@ -3672,6 +3712,7 @@ mod tests { pubkey: "npub1xyz".into(), timestamp: "2026-03-15T16:30:00Z".into(), content: "Should I deploy?".into(), + board_references: vec![], }], total: 1, truncated: false, diff --git a/crates/buzz-sdk/src/board_references.rs b/crates/buzz-sdk/src/board_references.rs new file mode 100644 index 00000000000..4995b5e97af --- /dev/null +++ b/crates/buzz-sdk/src/board_references.rs @@ -0,0 +1,390 @@ +//! Typed, bounded references attached to Board discussion messages. + +use nostr::Tag; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; + +/// Event tag name carrying a Board reference. +pub const BOARD_REFERENCE_TAG: &str = "buzz:board-ref"; +/// Current wire version. +pub const BOARD_REFERENCE_VERSION: &str = "1"; +/// Maximum references accepted on one message. +pub const MAX_BOARD_REFERENCES: usize = 32; +const MAX_PAYLOAD_BYTES: usize = 4096; +const MAX_LABEL_BYTES: usize = 160; +const MAX_ID_BYTES: usize = 512; + +/// As-sent placement used when a live object no longer exists. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BoardPlacement { + /// Canonical workstream channel UUID. + pub workstream_id: String, + /// Least-privilege workstream display label. + pub workstream_label: String, + /// Stable Board section identifier. + pub section_id: String, + /// As-sent section display label. + pub section_label: String, +} + +/// Immutable semantic snapshot safe to show after live data disappears. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BoardSnapshot { + /// Primary display label. + pub label: String, + /// Optional secondary state/relationship annotation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +/// Canonical thread destination bound to a blocker. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct BoardThreadDestination { + /// Destination channel UUID. + pub channel_id: String, + /// Exact target message ID. + pub message_id: String, + /// Thread root ID when the target is in a thread. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub thread_root_id: Option, +} + +/// Supported Board domain terms. `kind` is the discriminant on the wire. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)] +pub enum BoardReference { + /// A workstream card. + Workstream { + /// Canonical channel UUID. + identity: String, + /// As-sent display data. + snapshot: BoardSnapshot, + /// As-sent Board placement. + placement: BoardPlacement, + }, + /// A Buzz or GitHub pull request. + PullRequest { + /// Canonical provider-qualified identity. + identity: String, + /// As-sent display data. + snapshot: BoardSnapshot, + /// As-sent Board placement. + placement: BoardPlacement, + }, + /// An agent identity. + Agent { + /// Canonical lowercase hex pubkey. + identity: String, + /// As-sent display data. + snapshot: BoardSnapshot, + /// As-sent Board placement. + placement: BoardPlacement, + }, + /// A stable agent-team identity. + AgentGroup { + /// Canonical team identity. + identity: String, + /// As-sent display data. + snapshot: BoardSnapshot, + /// As-sent Board placement. + placement: BoardPlacement, + }, + /// A blocker bound to an exact conversation destination. + ThreadBlocker { + /// Stable manual wait key or canonical PR-derived wait identity. + identity: String, + /// Exact conversation destination. + destination: BoardThreadDestination, + /// As-sent display data. + snapshot: BoardSnapshot, + /// As-sent Board placement. + placement: BoardPlacement, + }, +} + +impl BoardReference { + fn dedup_key(&self) -> (String, &'static str, String) { + let workstream_id = self.placement().workstream_id.clone(); + match self { + Self::Workstream { identity, .. } => (workstream_id, "workstream", identity.clone()), + Self::PullRequest { identity, .. } => (workstream_id, "pull-request", identity.clone()), + Self::Agent { identity, .. } => (workstream_id, "agent", identity.clone()), + Self::AgentGroup { identity, .. } => (workstream_id, "agent-group", identity.clone()), + Self::ThreadBlocker { identity, .. } => { + (workstream_id, "thread-blocker", identity.clone()) + } + } + } + + fn identity(&self) -> &str { + match self { + Self::Workstream { identity, .. } + | Self::PullRequest { identity, .. } + | Self::Agent { identity, .. } + | Self::AgentGroup { identity, .. } + | Self::ThreadBlocker { identity, .. } => identity, + } + } + + fn snapshot(&self) -> &BoardSnapshot { + match self { + Self::Workstream { snapshot, .. } + | Self::PullRequest { snapshot, .. } + | Self::Agent { snapshot, .. } + | Self::AgentGroup { snapshot, .. } + | Self::ThreadBlocker { snapshot, .. } => snapshot, + } + } + + fn placement(&self) -> &BoardPlacement { + match self { + Self::Workstream { placement, .. } + | Self::PullRequest { placement, .. } + | Self::Agent { placement, .. } + | Self::AgentGroup { placement, .. } + | Self::ThreadBlocker { placement, .. } => placement, + } + } + + fn validate(&self) -> bool { + let placement = self.placement(); + let snapshot = self.snapshot(); + valid_id(self.identity()) + && valid_uuid(&placement.workstream_id) + && valid_label(&placement.workstream_label) + && valid_id(&placement.section_id) + && valid_label(&placement.section_label) + && valid_label(&snapshot.label) + && snapshot.detail.as_deref().is_none_or(valid_label) + && match self { + Self::Workstream { identity, .. } => identity == &placement.workstream_id, + Self::Agent { identity, .. } => valid_hex64(identity), + Self::ThreadBlocker { destination, .. } => { + valid_uuid(&destination.channel_id) + && valid_hex64(&destination.message_id) + && destination + .thread_root_id + .as_deref() + .is_none_or(valid_hex64) + } + _ => true, + } + } +} + +fn valid_label(value: &str) -> bool { + !value.trim().is_empty() + && value.len() <= MAX_LABEL_BYTES + && !value.chars().any(char::is_control) +} + +fn valid_id(value: &str) -> bool { + !value.trim().is_empty() && value.len() <= MAX_ID_BYTES && !value.chars().any(char::is_control) +} + +fn valid_hex64(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) +} + +fn valid_uuid(value: &str) -> bool { + uuid::Uuid::parse_str(value).is_ok() && value == value.to_ascii_lowercase() +} + +/// Build validated ordered event tags. Invalid input rejects the whole send. +pub fn build_board_reference_tags( + references: &[BoardReference], +) -> Result, crate::SdkError> { + if references.len() > MAX_BOARD_REFERENCES { + return Err(crate::SdkError::InvalidInput(format!( + "too many Board references (max {MAX_BOARD_REFERENCES})" + ))); + } + let mut identities = HashSet::new(); + references + .iter() + .map(|reference| { + if !reference.validate() || !identities.insert(reference.dedup_key()) { + return Err(crate::SdkError::InvalidInput( + "invalid or duplicate Board reference".into(), + )); + } + let payload = serde_json::to_string(reference) + .map_err(|error| crate::SdkError::InvalidInput(error.to_string()))?; + if payload.len() > MAX_PAYLOAD_BYTES { + return Err(crate::SdkError::InvalidInput( + "Board reference payload too large".into(), + )); + } + Tag::parse([ + BOARD_REFERENCE_TAG, + BOARD_REFERENCE_VERSION, + payload.as_str(), + ]) + .map_err(|error| crate::SdkError::InvalidTag(error.to_string())) + }) + .collect() +} + +/// Parse valid references in persisted order. Bad entries fail closed individually. +pub fn parse_board_references(tags: &nostr::Tags) -> Vec { + let mut identities = HashSet::new(); + tags.iter() + .filter_map(|tag| { + let values = tag.as_slice(); + if values.len() != 3 + || values[0] != BOARD_REFERENCE_TAG + || values[1] != BOARD_REFERENCE_VERSION + || values[2].len() > MAX_PAYLOAD_BYTES + { + return None; + } + let reference: BoardReference = serde_json::from_str(&values[2]).ok()?; + (reference.validate() && identities.insert(reference.dedup_key())).then_some(reference) + }) + .take(MAX_BOARD_REFERENCES) + .collect() +} + +/// Parse references authorized for one current Board workstream. +pub fn parse_board_references_for_workstream( + tags: &nostr::Tags, + workstream_id: &str, +) -> Vec { + parse_board_references(tags) + .into_iter() + .filter(|reference| reference.placement().workstream_id == workstream_id) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::Tags; + + fn reference(workstream_id: &str) -> BoardReference { + BoardReference::Workstream { + identity: workstream_id.into(), + snapshot: BoardSnapshot { + label: "Checkout recovery".into(), + detail: Some("Active".into()), + }, + placement: BoardPlacement { + workstream_id: workstream_id.into(), + workstream_label: "checkout-recovery".into(), + section_id: "workstream".into(), + section_label: "Workstream".into(), + }, + } + } + + #[test] + fn round_trip_preserves_order() { + let tags = build_board_reference_tags(&[ + reference("123e4567-e89b-12d3-a456-426614174000"), + reference("123e4567-e89b-12d3-a456-426614174001"), + ]) + .unwrap(); + let parsed = parse_board_references(&Tags::from_list(tags)); + assert_eq!( + parsed, + vec![ + reference("123e4567-e89b-12d3-a456-426614174000"), + reference("123e4567-e89b-12d3-a456-426614174001") + ] + ); + } + + #[test] + fn malformed_unknown_and_duplicate_entries_fail_closed() { + let mut tags = + build_board_reference_tags(&[reference("123e4567-e89b-12d3-a456-426614174000")]) + .unwrap(); + tags.push(Tag::parse([BOARD_REFERENCE_TAG, "99", "{}"]).unwrap()); + tags.push(Tag::parse([BOARD_REFERENCE_TAG, BOARD_REFERENCE_VERSION, "not-json"]).unwrap()); + tags.extend( + build_board_reference_tags(&[ + reference("123e4567-e89b-12d3-a456-426614174000"), + reference("123e4567-e89b-12d3-a456-426614174001"), + ]) + .unwrap(), + ); + assert_eq!( + parse_board_references(&Tags::from_list(tags)), + vec![ + reference("123e4567-e89b-12d3-a456-426614174000"), + reference("123e4567-e89b-12d3-a456-426614174001") + ] + ); + } + #[test] + fn strict_unknown_controls_and_forged_workstream_binding_fail_closed() { + let valid = reference("123e4567-e89b-12d3-a456-426614174000"); + let payload = serde_json::to_value(&valid).unwrap(); + let mut invalid = Vec::new(); + for path in ["top", "snapshot", "placement"] { + let mut value = payload.clone(); + let target = match path { + "top" => value.as_object_mut().unwrap(), + "snapshot" => value.get_mut("snapshot").unwrap().as_object_mut().unwrap(), + _ => value.get_mut("placement").unwrap().as_object_mut().unwrap(), + }; + target.insert("extra".into(), serde_json::json!(true)); + invalid.push(value); + } + let mut controlled = payload.clone(); + controlled["snapshot"]["label"] = serde_json::json!("bad\u{0085}label"); + invalid.push(controlled); + let mut forged = payload; + forged["identity"] = serde_json::json!("123e4567-e89b-12d3-a456-426614174001"); + invalid.push(forged); + + let mut tags = invalid + .into_iter() + .map(|value| { + Tag::parse([ + BOARD_REFERENCE_TAG, + BOARD_REFERENCE_VERSION, + serde_json::to_string(&value).unwrap().as_str(), + ]) + .unwrap() + }) + .collect::>(); + tags.extend(build_board_reference_tags(std::slice::from_ref(&valid)).unwrap()); + assert_eq!(parse_board_references(&Tags::from_list(tags)), vec![valid]); + } + + #[test] + fn dedup_key_is_scoped_to_workstream_placement() { + let first = reference("123e4567-e89b-12d3-a456-426614174000"); + let mut second = first.clone(); + if let BoardReference::Workstream { + identity, + placement, + .. + } = &mut second + { + *identity = "123e4567-e89b-12d3-a456-426614174001".into(); + placement.workstream_id = identity.clone(); + } + let tags = + Tags::from_list(build_board_reference_tags(&[first.clone(), second.clone()]).unwrap()); + assert_eq!(parse_board_references(&tags), vec![first, second]); + } + + #[test] + fn cross_workstream_references_fail_closed() { + let allowed = reference("123e4567-e89b-12d3-a456-426614174000"); + let other = reference("123e4567-e89b-12d3-a456-426614174001"); + let tags = Tags::from_list(build_board_reference_tags(&[allowed.clone(), other]).unwrap()); + assert_eq!( + parse_board_references_for_workstream(&tags, "123e4567-e89b-12d3-a456-426614174000"), + vec![allowed] + ); + } +} diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 71c0f1e73db..abe9e7cf821 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -228,6 +228,28 @@ pub fn build_message( mentions: &[&str], broadcast: bool, media_tags: &[Vec], +) -> Result { + build_message_with_board_references( + channel_id, + content, + thread_ref, + mentions, + broadcast, + media_tags, + &[], + ) +} + +/// Build a stream message carrying validated, ordered Board references. +#[allow(clippy::too_many_arguments)] +pub fn build_message_with_board_references( + channel_id: Uuid, + content: &str, + thread_ref: Option<&ThreadRef>, + mentions: &[&str], + broadcast: bool, + media_tags: &[Vec], + board_references: &[crate::BoardReference], ) -> Result { check_content(content, 64 * 1024)?; let mut tags = vec![tag(&["h", &channel_id.to_string()])?]; @@ -239,6 +261,7 @@ pub fn build_message( tags.push(tag(&["broadcast", "1"])?); } imeta_tags(media_tags, &mut tags)?; + tags.extend(crate::build_board_reference_tags(board_references)?); Ok(EventBuilder::new(Kind::Custom(9), content) .tags(tags) .allow_self_tagging()) diff --git a/crates/buzz-sdk/src/lib.rs b/crates/buzz-sdk/src/lib.rs index 4ee0cd4c882..0e4e52e47a1 100644 --- a/crates/buzz-sdk/src/lib.rs +++ b/crates/buzz-sdk/src/lib.rs @@ -12,10 +12,12 @@ //! The caller signs with their own keys: `builder.sign_with_keys(&keys)?`. //! No keys are held here. No network calls are made. +pub mod board_references; pub mod builders; pub mod mentions; pub mod nip_oa; +pub use board_references::*; pub use builders::*; /// Re-export kind constants so consumers don't need buzz-core directly. diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 6cb13508cca..73352af90fd 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -28,6 +28,7 @@ export default defineConfig({ "**/navigation.spec.ts", "**/channels.spec.ts", "**/workstream-board-message-link.capture.spec.ts", + "**/workstream-board-context-references.capture.spec.ts", "**/channel-shared-header-backdrop.spec.ts", "**/channel-composer-overflow.spec.ts", "**/badge.spec.ts", diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 31559777d2b..70de4e1de1b 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -448,6 +448,7 @@ pub async fn send_channel_message( mention_tags: Option>>, link_preview_tags: Option>>, sent_from_thread_tag: Option>, + board_references: Option>, mention_pubkeys: Option>, kind: Option, expected_relay_url: Option, @@ -462,6 +463,7 @@ pub async fn send_channel_message( let emoji = emoji_tags.unwrap_or_default(); let mention_refs_only = mention_tags.unwrap_or_default(); let link_previews = link_preview_tags.unwrap_or_default(); + let board_references = board_references.unwrap_or_default(); // Resolve the relay AND the signing identity once and use them for every // read and the submission. Callers that captured a tenant scope before an // await (Projects agent sends) pass `expected_relay_url` and @@ -520,7 +522,7 @@ pub async fn send_channel_message( } None => None, }; - events::build_message( + events::build_message_with_client_tags( channel_uuid, content.trim(), thread_ref.as_ref(), @@ -531,6 +533,8 @@ pub async fn send_channel_message( &link_previews, sent_from_thread_tag.as_deref(), &relay_base, + &[], + &board_references, )? } }; @@ -693,6 +697,7 @@ fn build_managed_agent_channel_message( thread_ref: Option<&events::ThreadRef>, mention_pubkeys: &[String], client_tags: &[Vec], + board_references: &[buzz_sdk_pkg::BoardReference], ) -> Result { let mention_refs: Vec<&str> = mention_pubkeys.iter().map(String::as_str).collect(); events::build_message_with_client_tags( @@ -707,6 +712,7 @@ fn build_managed_agent_channel_message( None, &crate::relay::relay_api_base_url(), client_tags, + board_references, ) } @@ -721,6 +727,7 @@ pub async fn send_managed_agent_channel_message( mention_pubkeys: Option>, parent_event_id: Option, additional_markers: Option>, + board_references: Option>, app: AppHandle, state: State<'_, AppState>, ) -> Result { @@ -813,6 +820,7 @@ pub async fn send_managed_agent_channel_message( thread_ref.as_ref(), &mentions, &client_tags, + &board_references.unwrap_or_default(), )?; // Same contract as `send_channel_message`: `created_at` is the signed // event's, not a post-publication clock read. diff --git a/desktop/src-tauri/src/commands/messages_tests.rs b/desktop/src-tauri/src/commands/messages_tests.rs index c0ad03d936b..32f43e09b53 100644 --- a/desktop/src-tauri/src/commands/messages_tests.rs +++ b/desktop/src-tauri/src/commands/messages_tests.rs @@ -41,6 +41,7 @@ fn managed_agent_message_builder_adds_mentions_and_client_marker() { None, std::slice::from_ref(&pubkey), &[vec!["client".to_string(), "welcome-v1".to_string()]], + &[], ) .expect("message should build") .sign_with_keys(&Keys::generate()) @@ -64,6 +65,7 @@ fn managed_agent_message_builder_can_carry_multiple_client_markers() { vec!["client".to_string(), "opener-v1".to_string()], vec!["client".to_string(), "closer-v1".to_string()], ], + &[], ) .expect("message should build") .sign_with_keys(&Keys::generate()) @@ -81,6 +83,7 @@ fn managed_agent_message_builder_rejects_invalid_mentions() { None, &["not-a-pubkey".to_string()], &[], + &[], ) .expect_err("invalid mentions should fail"); assert!(error.contains("pubkey must be a 64-character hex string")); diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 1828b3f5605..448f55c9580 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -273,6 +273,7 @@ pub fn build_message( sent_from_thread_tag, relay_base, &[], + &[], ) } @@ -294,6 +295,7 @@ pub fn build_message_with_client_tags( sent_from_thread_tag: Option<&[String]>, relay_base: &str, client_tags: &[Vec], + board_references: &[buzz_sdk_pkg::BoardReference], ) -> Result { if sent_from_thread_tag.is_some() && thread_ref.is_some() { return Err("sent-from-thread provenance requires a top-level message".into()); @@ -310,6 +312,9 @@ pub fn build_message_with_client_tags( crate::link_preview_tags::append(link_preview_tags, relay_base, &mut tags)?; append_sent_from_thread_tag(sent_from_thread_tag, &mut tags)?; append_client_tags(client_tags, &mut tags)?; + tags.extend( + buzz_sdk_pkg::build_board_reference_tags(board_references).map_err(|e| e.to_string())?, + ); Ok(EventBuilder::new(Kind::Custom(9), content).tags(tags)) } diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 8b457a7adf8..d7c316a71d6 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -41,6 +41,10 @@ import { relayClient, setVisibleChannel } from "@/shared/api/relayClient"; import { customEmojiQueryKey } from "@/features/custom-emoji/hooks"; import { channelsQueryKey } from "@/features/channels/hooks"; import { reactionEmojiUrl } from "@/shared/api/customEmoji"; +import { + boardReferenceTags, + type BoardReference, +} from "@/features/workstream-board/lib/boardReferences"; import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; import { addReaction, @@ -95,6 +99,7 @@ export function createOptimisticMessage( mediaTags: string[][] = [], sentFromThreadRootId: string | null = null, sentFromThreadRootExcerpt: string | null = null, + boardReferences: readonly BoardReference[] = [], ): RelayEvent { const localKey = `optimistic-${crypto.randomUUID()}`; const tags: string[][] = []; @@ -123,6 +128,7 @@ export function createOptimisticMessage( for (const tag of mediaTags) { tags.push(tag); } + for (const tag of boardReferenceTags(boardReferences)) tags.push(tag); if (sentFromThreadRootId) { tags.push( buildSentFromThreadTag(sentFromThreadRootId, sentFromThreadRootExcerpt), @@ -454,6 +460,7 @@ export function useSendMessageMutation( sentFromThreadRootId?: string | null; sentFromThreadRootExcerpt?: string | null; transport?: "auto" | "http"; + boardReferences?: BoardReference[]; }, MessageQueryContext | undefined >({ @@ -468,6 +475,7 @@ export function useSendMessageMutation( sentFromThreadRootId, sentFromThreadRootExcerpt, transport = "auto", + boardReferences = [], }) => { // Prefer a channel captured by the caller at compose time. Otherwise, // resolve a captured id from the shared channel cache so navigation @@ -532,7 +540,8 @@ export function useSendMessageMutation( parentEventId || imetaTags.length > 0 || emojiTags.length > 0 || - linkPreviewTags.length > 0 + linkPreviewTags.length > 0 || + boardReferences.length > 0 ) { const cachedMessages = queryClient.getQueryData( @@ -549,6 +558,9 @@ export function useSendMessageMutation( mentionTags, linkPreviewTags, sentFromThreadTag, + undefined, + undefined, + boardReferences, ); // Build tags matching relay-emitted shape: h, author p, mention ps, reply es, imeta, emoji. @@ -587,6 +599,7 @@ export function useSendMessageMutation( ...emojiTags, ...mentionTags, ...linkPreviewTags, + ...boardReferenceTags(boardReferences), ...(sentFromThreadTag ? [sentFromThreadTag] : []), ], content: content.trim(), @@ -610,6 +623,7 @@ export function useSendMessageMutation( mediaTags, sentFromThreadRootId, sentFromThreadRootExcerpt, + boardReferences = [], }) => { // Mirror mutationFn's target resolution so the optimistic message lands // in the cache for the same channel as the real send. A caller-supplied @@ -647,6 +661,7 @@ export function useSendMessageMutation( mediaTags ?? [], sentFromThreadRootId ?? null, sentFromThreadRootExcerpt ?? null, + boardReferences, ); const nextWindow = mergeLiveChannelWindowEvent( diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index fd5be7d9a86..c964c14a85c 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -59,6 +59,8 @@ import { SentFromThreadLine } from "./SentFromThreadLine"; import { WaveMessageAttachment } from "./WaveMessageAttachment"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { BoardReferenceSet } from "@/features/workstream-board/ui/BoardReferenceSet"; + const DiffMessage = React.lazy(() => import("./DiffMessage")); const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded")); @@ -892,6 +894,10 @@ export const MessageRow = React.memo( {headerNode}
{messageBodyNode} +
@@ -902,6 +908,10 @@ export const MessageRow = React.memo( {headerNode}
{messageBodyNode} +
diff --git a/desktop/src/features/workstream-board/lib/boardReferences.test.mjs b/desktop/src/features/workstream-board/lib/boardReferences.test.mjs new file mode 100644 index 00000000000..b23b9667ba4 --- /dev/null +++ b/desktop/src/features/workstream-board/lib/boardReferences.test.mjs @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + boardReferenceKey, + boardReferenceTags, + parseBoardReferences, + resolveBoardReferences, +} from "./boardReferences.ts"; +const WS = "123e4567-e89b-12d3-a456-426614174000"; +const OTHER = "123e4567-e89b-12d3-a456-426614174001"; +const ref = (kind, identity, workstreamId = WS) => ({ + kind, + identity, + snapshot: { label: identity }, + placement: { + workstreamId, + workstreamLabel: "Checkout", + sectionId: "workstream", + sectionLabel: "Workstream", + }, +}); +test("ordered mixed references round trip while malformed and cross-workstream entries fail closed", () => { + const valid = [ref("workstream", WS), ref("pull-request", "one")]; + const tags = [ + ...boardReferenceTags(valid), + ["buzz:board-ref", "99", "{}"], + ["buzz:board-ref", "1", "bad"], + ...boardReferenceTags([ref("agent-group", "other", OTHER)]), + ]; + assert.deepEqual(parseBoardReferences(tags, WS), valid); +}); +test("resolution distinguishes same identity across kinds and removed objects", () => { + const refs = [ + ref("pull-request", "same"), + ref("agent-group", "same"), + ref("pull-request", "gone"), + ]; + const live = new Map([ + [boardReferenceKey(refs[0]), refs[0]], + [ + boardReferenceKey(refs[1]), + { ...refs[1], snapshot: { label: "changed" } }, + ], + ]); + assert.deepEqual( + resolveBoardReferences(refs, live).map(({ state }) => state), + ["live", "changed", "historical"], + ); +}); + +test("strict decoding rejects unknown fields and Unicode controls per reference", () => { + const valid = ref("workstream", WS); + const malformed = [ + { ...valid, extra: true }, + { ...valid, snapshot: { ...valid.snapshot, extra: true } }, + { ...valid, placement: { ...valid.placement, extra: true } }, + { ...valid, snapshot: { label: "bad\u0085label" } }, + ]; + const tags = malformed.map((value) => [ + "buzz:board-ref", + "1", + JSON.stringify(value), + ]); + assert.deepEqual( + parseBoardReferences([...tags, ...boardReferenceTags([valid])]), + [valid], + ); +}); + +test("strict decoding enforces Rust-compatible UTF-8 byte limits without hiding the message", () => { + const valid = ref("pull-request", "valid"); + const exact160Bytes = "é".repeat(80); + const exact512Bytes = "é".repeat(256); + const over160Bytes = "é".repeat(100); + const over512Bytes = "é".repeat(300); + const malformed = [ + { ...valid, snapshot: { ...valid.snapshot, label: over160Bytes } }, + { ...valid, snapshot: { ...valid.snapshot, detail: over160Bytes } }, + { + ...valid, + placement: { ...valid.placement, workstreamLabel: over160Bytes }, + }, + { ...valid, placement: { ...valid.placement, sectionLabel: over160Bytes } }, + { ...valid, identity: over512Bytes }, + { ...valid, placement: { ...valid.placement, sectionId: over512Bytes } }, + ]; + const exactBoundary = { + ...valid, + identity: exact512Bytes, + snapshot: { label: exact160Bytes, detail: exact160Bytes }, + placement: { + ...valid.placement, + workstreamLabel: exact160Bytes, + sectionId: exact512Bytes, + sectionLabel: exact160Bytes, + }, + }; + assert.equal(Buffer.byteLength(exact160Bytes, "utf8"), 160); + assert.equal(Buffer.byteLength(exact512Bytes, "utf8"), 512); + assert.ok(over160Bytes.length < 160); + assert.ok(Buffer.byteLength(over160Bytes, "utf8") > 160); + assert.ok(over512Bytes.length < 512); + assert.ok(Buffer.byteLength(over512Bytes, "utf8") > 512); + + const malformedTags = malformed.map((value) => [ + "buzz:board-ref", + "1", + JSON.stringify(value), + ]); + assert.deepEqual( + parseBoardReferences([ + ...malformedTags, + ["client-only", "message remains usable"], + ...boardReferenceTags([exactBoundary, valid]), + ]), + [exactBoundary, valid], + ); +}); + +test("workstream identity is canonically bound to placement", () => { + const forged = ref("workstream", OTHER); + forged.placement.workstreamId = WS; + const tag = ["buzz:board-ref", "1", JSON.stringify(forged)]; + assert.deepEqual(parseBoardReferences([tag], WS), []); +}); + +test("same reusable identity is scoped to its recorded workstream", () => { + const inA = ref("agent", "6".repeat(64), WS); + const inB = ref("agent", "6".repeat(64), OTHER); + const live = new Map([ + [boardReferenceKey(inA), inA], + [boardReferenceKey(inB), inB], + ]); + assert.notEqual(boardReferenceKey(inA), boardReferenceKey(inB)); + assert.equal(resolveBoardReferences([inA], live)[0].state, "live"); + live.delete(boardReferenceKey(inA)); + assert.equal(resolveBoardReferences([inA], live)[0].state, "historical"); +}); diff --git a/desktop/src/features/workstream-board/lib/boardReferences.ts b/desktop/src/features/workstream-board/lib/boardReferences.ts new file mode 100644 index 00000000000..d5f1f353396 --- /dev/null +++ b/desktop/src/features/workstream-board/lib/boardReferences.ts @@ -0,0 +1,193 @@ +export const BOARD_REFERENCE_TAG = "buzz:board-ref"; +export const BOARD_REFERENCE_VERSION = "1"; +export const MAX_BOARD_REFERENCES = 32; + +export type BoardPlacement = { + workstreamId: string; + workstreamLabel: string; + sectionId: string; + sectionLabel: string; +}; +export type BoardSnapshot = { label: string; detail?: string }; +type Common = { + identity: string; + snapshot: BoardSnapshot; + placement: BoardPlacement; +}; +export type BoardReference = + | (Common & { kind: "workstream" }) + | (Common & { kind: "pull-request" }) + | (Common & { kind: "agent" }) + | (Common & { kind: "agent-group" }) + | (Common & { + kind: "thread-blocker"; + destination: { + channelId: string; + messageId: string; + threadRootId?: string; + }; + }); + +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const HEX = /^[0-9a-f]{64}$/; +const KINDS = new Set([ + "workstream", + "pull-request", + "agent", + "agent-group", + "thread-blocker", +]); +const CONTROL_CHARACTER = /\p{Cc}/u; +const safe = (value: unknown, max = 160): value is string => + typeof value === "string" && + value.trim().length > 0 && + new TextEncoder().encode(value).byteLength <= max && + !CONTROL_CHARACTER.test(value); + +const hasExactKeys = ( + value: Record, + required: readonly string[], + optional: readonly string[] = [], +): boolean => { + const allowed = new Set([...required, ...optional]); + return ( + required.every((key) => Object.hasOwn(value, key)) && + Object.keys(value).every((key) => allowed.has(key)) + ); +}; + +export function isBoardReference(value: unknown): value is BoardReference { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const v = value as Record; + if (typeof v.kind !== "string" || !KINDS.has(v.kind)) return false; + const topLevelKeys = + v.kind === "thread-blocker" + ? ["kind", "identity", "destination", "snapshot", "placement"] + : ["kind", "identity", "snapshot", "placement"]; + if (!hasExactKeys(v, topLevelKeys) || !safe(v.identity, 512)) return false; + if ( + !v.snapshot || + typeof v.snapshot !== "object" || + Array.isArray(v.snapshot) + ) + return false; + const snapshot = v.snapshot as Record; + if ( + !hasExactKeys(snapshot, ["label"], ["detail"]) || + !safe(snapshot.label) || + (snapshot.detail !== undefined && !safe(snapshot.detail)) + ) + return false; + const p = v.placement as Record | undefined; + if ( + !p || + !hasExactKeys(p, [ + "workstreamId", + "workstreamLabel", + "sectionId", + "sectionLabel", + ]) || + !safe(p.workstreamId, 36) || + !UUID.test(p.workstreamId) || + !safe(p.workstreamLabel) || + !safe(p.sectionId, 512) || + !safe(p.sectionLabel) + ) + return false; + if (v.kind === "workstream" && v.identity !== p.workstreamId) return false; + if (v.kind === "agent" && !HEX.test(v.identity)) return false; + if (v.kind === "thread-blocker") { + const d = v.destination as Record | undefined; + if ( + !d || + !hasExactKeys(d, ["channelId", "messageId"], ["threadRootId"]) || + !safe(d.channelId, 36) || + !UUID.test(d.channelId) || + !safe(d.messageId, 64) || + !HEX.test(d.messageId) || + (d.threadRootId !== undefined && + (!safe(d.threadRootId, 64) || !HEX.test(d.threadRootId))) + ) + return false; + } + return true; +} + +export function boardReferenceTags( + references: readonly BoardReference[], +): string[][] { + if (references.length > MAX_BOARD_REFERENCES) + throw new Error(`Select at most ${MAX_BOARD_REFERENCES} references.`); + const seen = new Set(); + return references.map((reference) => { + if (!isBoardReference(reference) || seen.has(boardReferenceKey(reference))) + throw new Error("Invalid or duplicate Board reference."); + seen.add(boardReferenceKey(reference)); + const payload = JSON.stringify(reference); + if (new TextEncoder().encode(payload).length > 4096) + throw new Error("Board reference is too large."); + return [BOARD_REFERENCE_TAG, BOARD_REFERENCE_VERSION, payload]; + }); +} + +export function parseBoardReferences( + tags: readonly (readonly string[])[] | undefined, + workstreamId?: string, +): BoardReference[] { + const result: BoardReference[] = []; + const seen = new Set(); + for (const tag of tags ?? []) { + if (result.length >= MAX_BOARD_REFERENCES) break; + if ( + tag.length !== 3 || + tag[0] !== BOARD_REFERENCE_TAG || + tag[1] !== BOARD_REFERENCE_VERSION || + new TextEncoder().encode(tag[2]).length > 4096 + ) + continue; + try { + const value: unknown = JSON.parse(tag[2]); + if ( + isBoardReference(value) && + (!workstreamId || value.placement.workstreamId === workstreamId) && + !seen.has(boardReferenceKey(value)) + ) { + seen.add(boardReferenceKey(value)); + result.push(value); + } + } catch { + /* malformed references do not break the message */ + } + } + return result; +} + +export type BoardReferenceResolution = { + reference: BoardReference; + state: "live" | "changed" | "historical"; +}; +export function resolveBoardReferences( + references: readonly BoardReference[], + live: ReadonlyMap, +): BoardReferenceResolution[] { + return references.map((reference) => { + const current = live.get(boardReferenceKey(reference)); + return { + reference, + state: !current + ? "historical" + : JSON.stringify(current.snapshot) === + JSON.stringify(reference.snapshot) && + JSON.stringify(current.placement) === + JSON.stringify(reference.placement) + ? "live" + : "changed", + }; + }); +} + +export function boardReferenceKey( + reference: Pick, +): string { + return `${reference.placement.workstreamId}:${reference.kind}:${reference.identity}`; +} diff --git a/desktop/src/features/workstream-board/ui/BoardReferenceItems.tsx b/desktop/src/features/workstream-board/ui/BoardReferenceItems.tsx new file mode 100644 index 00000000000..90dc089377e --- /dev/null +++ b/desktop/src/features/workstream-board/ui/BoardReferenceItems.tsx @@ -0,0 +1,37 @@ +import type { BoardReference } from "@/features/workstream-board/lib/boardReferences"; +import { boardReferenceKey } from "@/features/workstream-board/lib/boardReferences"; +import { cn } from "@/shared/lib/cn"; + +export function BoardReferenceItems({ + references, + onRemove, +}: { + references: readonly BoardReference[]; + onRemove?: (reference: BoardReference) => void; +}) { + return ( +
    + {references.map((reference, index) => ( +
  1. + + {index + 1}. {reference.kind}: {reference.snapshot.label} + + {onRemove ? ( + + ) : null} +
  2. + ))} +
+ ); +} diff --git a/desktop/src/features/workstream-board/ui/BoardReferenceSet.tsx b/desktop/src/features/workstream-board/ui/BoardReferenceSet.tsx new file mode 100644 index 00000000000..78fec8a94ee --- /dev/null +++ b/desktop/src/features/workstream-board/ui/BoardReferenceSet.tsx @@ -0,0 +1,33 @@ +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { parseBoardReferences } from "@/features/workstream-board/lib/boardReferences"; +import { BoardReferenceItems } from "@/features/workstream-board/ui/BoardReferenceItems"; + +export const BOARD_REPLAY_STORAGE_KEY = "buzz:board-replay:v1"; + +export function BoardReferenceSet({ + channelId, + tags, +}: { + channelId?: string | null; + tags?: readonly (readonly string[])[]; +}) { + const references = parseBoardReferences(tags, channelId ?? undefined); + const { goWorkstreams } = useAppNavigation(); + if (references.length === 0) return null; + return ( + + ); +} diff --git a/desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx b/desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx index a470819a7ec..f1622a2a131 100644 --- a/desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx +++ b/desktop/src/features/workstream-board/ui/WorkstreamBoardScreen.tsx @@ -3,6 +3,16 @@ import * as React from "react"; import { useAppNavigation } from "@/app/navigation/useAppNavigation"; import { useActiveAgentTurnsByChannel } from "@/features/agents/activeAgentTurnsStore"; import { useChannelsQuery } from "@/features/channels/hooks"; +import { + useChannelMessagesQuery, + useSendMessageMutation, +} from "@/features/messages/hooks"; +import { + parseBoardReferences, + boardReferenceKey, + resolveBoardReferences, + type BoardReference, +} from "@/features/workstream-board/lib/boardReferences"; import { useUsersBatchQuery } from "@/features/profile/hooks"; import { filterWorkstreamChannels } from "@/features/workstream-board/lib/discoverWorkstreamChannels"; import { getActiveWorkstreamTurns } from "@/features/workstream-board/lib/activeWorkstreamTurns"; @@ -11,8 +21,11 @@ import { type WorkstreamWait, } from "@/features/workstream-board/lib/workstreamWaits"; import { WorkstreamCard } from "@/features/workstream-board/ui/WorkstreamCard"; +import { BOARD_REPLAY_STORAGE_KEY } from "@/features/workstream-board/ui/BoardReferenceSet"; +import { BoardReferenceItems } from "@/features/workstream-board/ui/BoardReferenceItems"; import { useIdentityQuery } from "@/shared/api/hooks"; import { Button } from "@/shared/ui/button"; +import { cn } from "@/shared/lib/cn"; import { PageHeader } from "@/shared/ui/PageHeader"; const WORKSTREAM_CARD_GRID_CLASS = @@ -36,7 +49,152 @@ export function WorkstreamBoardScreen() { const { goChannel } = useAppNavigation(); const identityQuery = useIdentityQuery(); const channelsQuery = useChannelsQuery(); + const [discussionMode, setDiscussionMode] = React.useState(false); + const [selectedReferences, setSelectedReferences] = React.useState< + BoardReference[] + >([]); + const discussionChannelId = + selectedReferences[0]?.placement.workstreamId ?? null; + const [draft, setDraft] = React.useState(""); + const [replayReferences, setReplayReferences] = React.useState< + BoardReference[] + >([]); + const [replayFocusIndex, setReplayFocusIndex] = React.useState(0); + const startReplay = React.useCallback((references: BoardReference[]) => { + setDiscussionMode(true); + setReplayFocusIndex(0); + setReplayReferences(references); + }, []); + React.useEffect(() => { + if (!discussionMode && replayReferences.length === 0) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + if (replayReferences.length > 0) setReplayReferences([]); + else if ( + (selectedReferences.length === 0 && draft.length === 0) || + window.confirm( + "Discard the unsent discussion draft and reference tray?", + ) + ) { + setDiscussionMode(false); + setSelectedReferences([]); + setDraft(""); + } + } else if ( + replayReferences.length > 0 && + (event.key === "ArrowRight" || event.key === "]") + ) { + event.preventDefault(); + setReplayFocusIndex( + (current) => (current + 1) % replayReferences.length, + ); + } else if ( + replayReferences.length > 0 && + (event.key === "ArrowLeft" || event.key === "[") + ) { + event.preventDefault(); + setReplayFocusIndex( + (current) => + (current - 1 + replayReferences.length) % replayReferences.length, + ); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [discussionMode, draft, replayReferences, selectedReferences.length]); + const [liveReferencesByChannel, setLiveReferencesByChannel] = React.useState< + ReadonlyMap + >(() => new Map()); + React.useEffect(() => { + const stored = sessionStorage.getItem(BOARD_REPLAY_STORAGE_KEY); + if (!stored) return; + sessionStorage.removeItem(BOARD_REPLAY_STORAGE_KEY); + try { + const parsed: unknown = JSON.parse(stored); + if (Array.isArray(parsed)) { + startReplay( + parsed.flatMap((value) => { + const references = parseBoardReferences([ + ["buzz:board-ref", "1", JSON.stringify(value)], + ]); + return references; + }), + ); + } + } catch { + // Malformed cross-route replay state fails closed. + } + }, [startReplay]); const workstreamChannels = filterWorkstreamChannels(channelsQuery.data ?? []); + const discussionChannel = + workstreamChannels.find((channel) => channel.id === discussionChannelId) ?? + null; + const discussionMessages = useChannelMessagesQuery(discussionChannel); + const sendMessage = useSendMessageMutation( + discussionChannel, + identityQuery.data, + ); + const selectedKeys = React.useMemo( + () => new Set(selectedReferences.map(boardReferenceKey)), + [selectedReferences], + ); + const replayKeys = React.useMemo( + () => new Set(replayReferences.map(boardReferenceKey)), + [replayReferences], + ); + const replayFocusedKey = replayReferences[replayFocusIndex] + ? boardReferenceKey(replayReferences[replayFocusIndex]) + : undefined; + const liveReferences = React.useMemo( + () => + new Map( + [...liveReferencesByChannel.values()] + .flat() + .map((reference) => [boardReferenceKey(reference), reference]), + ), + [liveReferencesByChannel], + ); + const resolvedReplayReferences = React.useMemo( + () => resolveBoardReferences(replayReferences, liveReferences), + [liveReferences, replayReferences], + ); + const replayReferenceStates = React.useMemo( + () => + new Map( + resolvedReplayReferences.flatMap((item) => + item.state === "historical" + ? [] + : [[boardReferenceKey(item.reference), item.state] as const], + ), + ), + [resolvedReplayReferences], + ); + const onReferencesChange = React.useCallback( + (channelId: string, references: readonly BoardReference[]) => { + setLiveReferencesByChannel((current) => { + const previous = current.get(channelId); + if (JSON.stringify(previous) === JSON.stringify(references)) + return current; + const next = new Map(current); + next.set(channelId, references); + return next; + }); + }, + [], + ); + const toggleReference = React.useCallback((reference: BoardReference) => { + setSelectedReferences((current) => { + if ( + current.length > 0 && + current[0].placement.workstreamId !== reference.placement.workstreamId + ) + return current; + const key = boardReferenceKey(reference); + return current.some((item) => boardReferenceKey(item) === key) + ? current.filter((item) => boardReferenceKey(item) !== key) + : [...current, reference]; + }); + }, []); const activeTurnsByChannel = useActiveAgentTurnsByChannel(); const activeWorkstreamTurns = React.useMemo( () => getActiveWorkstreamTurns(workstreamChannels, activeTurnsByChannel), @@ -105,6 +263,198 @@ export function WorkstreamBoardScreen() { description="Live canvases for active workstream channels." title="Workstream Board" /> +
+ + {replayReferences.length > 0 ? ( + + ) : null} +
+ {discussionMode ? ( +
+

+ Reference tray ({selectedReferences.length}) +

+ +