diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 8a698954a0..b362de32f0 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -211,6 +211,10 @@ pub struct AcpClient { /// deltas. Both goose and buzz-agent emit this notification; goose gates /// on client capability advertisement, buzz-agent emits unconditionally. goose_usage: UsageTracker, + /// Accumulates `agent_message_chunk` text while a one-shot helper (the + /// `complete` subcommand) is capturing the agent's reply. `None` disables + /// capture — the normal harness path never allocates here. + captured_agent_text: Option, } /// Recursively merge `overlay` into `base`, with `overlay` winning on scalar/shape @@ -550,9 +554,23 @@ impl AcpClient { steering_supported: false, steer_rx: None, goose_usage: UsageTracker::default(), + captured_agent_text: None, }) } + /// Start capturing `agent_message_chunk` text from `session/update` + /// notifications. Used by the one-shot `complete` subcommand to collect + /// the agent's reply text; the harness path never enables this. + pub fn begin_text_capture(&mut self) { + self.captured_agent_text = Some(String::new()); + } + + /// Take the text captured since [`begin_text_capture`](Self::begin_text_capture) + /// and disable capture. + pub fn take_captured_text(&mut self) -> String { + self.captured_agent_text.take().unwrap_or_default() + } + /// Attach a local observer feed to this ACP client. pub fn set_observer(&mut self, observer: Option, agent_index: usize) { self.observer = observer; @@ -1715,6 +1733,9 @@ impl AcpClient { "agent_message_chunk" => { if let Some(text) = update["content"]["text"].as_str() { tracing::info!(target: "acp::stream", "{text}"); + if let Some(buffer) = self.captured_agent_text.as_mut() { + buffer.push_str(text); + } } false } diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index e360d24982..606a239b34 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -24,6 +24,27 @@ Run `buzz --help` or `buzz --help` for full usage. For multiline message When opening a pull request in response to channel work, always pass `--channel ` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation. +## Sub-channels + +Sub-channels are focused working sessions under a main channel. They use the one-level naming convention `parent--sub`; a sub-channel cannot itself be a parent. + +Spawn one with `buzz channels create --name --parent --description ""`. The sub-channel inherits the parent's type and visibility; the command constructs the full name, announces the spawn in the parent, and updates both canvases. + +Only ever add someone to a sub-channel if they are already a member of its parent. Humans may join a sub-channel to steer the work or stay out of it. + +Before considering the task done, post a final summary to the parent channel as a thread reply to the spawn announcement. The sub-channel canvas records both the parent channel and the announcement event ID under `spawned-from`. + +## Sharing Buzz Links in Slack + +When sharing a link to Buzz work in Slack, always use this exact format: + +> :buzz: Joah is working on this in Buzz. [Join # to participate](buzz://message?channel=&id=) + +- Start with the `:buzz:` Slack emoji. Do not add a 🤖 AI-agent disclaimer to these messages — this is a deliberate exception. +- `buzz://` has no channel-only deep link; link the relevant message or thread root, which opens the channel in Desktop. +- ALWAYS wait until the channel's name is finalized before sending. Freshly spawned channels get renamed shortly after creation; verify the current name with `buzz channels list` immediately before posting, and if the channel still carries a placeholder or parent name, wait for the rename to land first. +- Whenever Joah gives you a link to a Slack message as context for work, ALWAYS post the Buzz-link message as a reply in that Slack thread so watchers know where the work is happening. + ## Conversational Agent Creation When someone asks to create an agent, ask for at most two things: the agent's name and what it should do day-to-day. Turn the user's rough purpose into the `--system-prompt` yourself; do not separately ask for purpose, tone, constraints, access, runtime, provider, or model unless the user's request is genuinely ambiguous. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index dab61be30a..be9d67d6d0 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -201,6 +201,35 @@ pub struct AuthAgentArgs { pub agent_args: Vec, } +/// CLI args for `buzz-acp complete` — one-shot prompt completion via the +/// configured agent. Spawns the agent, opens a session, sends a single +/// prompt, and prints the accumulated agent reply text. No relay connection. +#[derive(Debug, Parser)] +#[command( + name = "buzz-acp complete", + about = "One-shot prompt completion via the configured agent" +)] +pub struct CompleteArgs { + #[command(flatten)] + pub agent: AuthAgentArgs, + + /// Prompt text to send to the agent. + #[arg(long)] + pub prompt: String, + + /// Optional system prompt for the session. + #[arg(long)] + pub system_prompt: Option, + + /// Optional model id to select via `session/set_model` (best-effort). + #[arg(long)] + pub model: Option, + + /// Output structured JSON (`{"text": ...}`) instead of raw text. + #[arg(long)] + pub json: bool, +} + /// CLI args for `buzz-acp auth-methods` — query adapter-advertised login methods. #[derive(Debug, Parser)] #[command( diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 403512a322..a7f4ef0298 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -30,7 +30,7 @@ use buzz_core::observer::{ }; use clap::Parser; use config::{ - AuthAgentArgs, AuthMethodsArgs, AuthenticateArgs, Config, DedupMode, ModelsArgs, + AuthAgentArgs, AuthMethodsArgs, AuthenticateArgs, CompleteArgs, Config, DedupMode, ModelsArgs, MultipleEventHandling, RespondTo, SubscribeMode, }; use filter::SubscriptionRule; @@ -1251,6 +1251,16 @@ async fn tokio_main() -> Result<()> { return run_models(args).await; } + if is_subcommand("complete") { + let filtered: Vec = std::env::args() + .enumerate() + .filter(|(i, _)| *i != 1) + .map(|(_, a)| a) + .collect(); + let args = CompleteArgs::parse_from(&filtered); + return run_complete(args).await; + } + if is_subcommand("auth-methods") { let filtered: Vec = std::env::args() .enumerate() @@ -4176,6 +4186,98 @@ async fn run_models(args: ModelsArgs) -> Result<()> { Ok(()) } +/// Setup timeout for `buzz-acp complete` (spawn + initialize + session/new). +/// Longer than [`MODELS_TIMEOUT`] because agents cold-start here (goose can +/// take >10s to initialize its first session). +const COMPLETE_SETUP_TIMEOUT: Duration = Duration::from_secs(30); + +/// Idle timeout for `buzz-acp complete` — resets on any agent stdout activity. +const COMPLETE_IDLE_TIMEOUT: Duration = Duration::from_secs(30); + +/// Hard wall-clock cap for `buzz-acp complete`. +const COMPLETE_MAX_DURATION: Duration = Duration::from_secs(90); + +/// Flow: spawn → initialize → session/new → session/prompt → print captured +/// agent text → shutdown. No relay connection, no MCP servers. +/// +/// Powers desktop features that need a bounded request/response completion +/// from the configured agent (e.g. Developer Mode channel naming). +async fn run_complete(args: CompleteArgs) -> Result<()> { + let agent_args = config::normalize_agent_args(&args.agent.agent_command, args.agent.agent_args); + let cwd = std::env::current_dir() + .unwrap_or_else(|_| std::path::PathBuf::from("/")) + .to_string_lossy() + .to_string(); + + // Spawn outside the timeout so we always own the child for cleanup. + let mut client = + match AcpClient::spawn(&args.agent.agent_command, &agent_args, &[], false).await { + Ok(c) => c, + Err(e) => { + eprintln!("error: failed to spawn agent: {e}"); + std::process::exit(1); + } + }; + + let setup_result = tokio::time::timeout(COMPLETE_SETUP_TIMEOUT, async { + client.initialize().await?; + let session = client + .session_new_full(&cwd, vec![], args.system_prompt.as_deref(), None) + .await?; + Ok::<_, acp::AcpError>(session.session_id) + }) + .await; + + let session_id = match setup_result { + Ok(Ok(id)) => id, + Ok(Err(e)) => { + client.shutdown().await; + eprintln!("error: agent communication failed: {e}"); + std::process::exit(1); + } + Err(_) => { + client.shutdown().await; + eprintln!("error: agent timed out ({COMPLETE_SETUP_TIMEOUT:?})"); + std::process::exit(1); + } + }; + + // Best-effort model selection — not all agents support session/set_model. + if let Some(model) = &args.model { + let _ = client.session_set_model(&session_id, model).await; + } + + client.begin_text_capture(); + let prompt_result = client + .session_prompt_with_idle_timeout( + &session_id, + &args.prompt, + COMPLETE_IDLE_TIMEOUT, + COMPLETE_MAX_DURATION, + ) + .await; + let text = client.take_captured_text(); + client.shutdown().await; + + if let Err(e) = prompt_result { + eprintln!("error: prompt failed: {e}"); + std::process::exit(1); + } + + let trimmed = text.trim(); + if trimmed.is_empty() { + eprintln!("error: agent returned no message text"); + std::process::exit(1); + } + + if args.json { + println!("{}", serde_json::json!({ "text": trimmed })); + } else { + println!("{trimmed}"); + } + Ok(()) +} + fn build_mcp_servers(config: &Config) -> Vec { if config.mcp_command.is_empty() { return vec![]; diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 42844bf1e0..5222c28bc6 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -328,6 +328,253 @@ pub async fn cmd_create_channel( Ok(()) } +fn sanitize_sub_channel_name(name: &str) -> Result { + let mut sanitized = String::new(); + let mut previous_hyphen = false; + for ch in name.to_ascii_lowercase().chars() { + if ch.is_ascii_alphanumeric() { + sanitized.push(ch); + previous_hyphen = false; + } else if !previous_hyphen && !sanitized.is_empty() { + sanitized.push('-'); + previous_hyphen = true; + } + } + let sanitized = sanitized.trim_matches('-').to_string(); + if sanitized.is_empty() { + return Err(CliError::Usage( + "--name is empty after sub-channel name sanitization".into(), + )); + } + Ok(sanitized) +} + +fn construct_sub_channel_name(parent_name: &str, name: &str) -> Result { + if parent_name.contains("--") { + return Err(CliError::Usage( + "sub-channels support one nesting level; the parent name cannot contain '--'".into(), + )); + } + Ok(format!( + "{parent_name}--{}", + sanitize_sub_channel_name(name)? + )) +} + +fn append_sub_channel_to_canvas(canvas: &str, sub_name: &str, task: &str) -> String { + let bullet = format!("- #{sub_name} — {}", task.lines().next().unwrap_or(task)); + let heading = "## Sub-channels"; + let mut canvas = canvas.to_string(); + let Some(heading_start) = canvas.lines().position(|line| line.trim() == heading) else { + canvas.push_str("\n\n## Sub-channels\n"); + canvas.push_str(&bullet); + canvas.push('\n'); + return canvas; + }; + + let lines: Vec<&str> = canvas.lines().collect(); + let section_end = lines[heading_start + 1..] + .iter() + .position(|line| line.starts_with("## ")) + .map(|offset| heading_start + 1 + offset) + .unwrap_or(lines.len()); + let insert_line = (heading_start + 1..section_end) + .rfind(|&index| lines[index].starts_with("- #")) + .map(|index| index + 1) + .unwrap_or(heading_start + 1); + let mut output = lines[..insert_line].join("\n"); + if !output.is_empty() { + output.push('\n'); + } + output.push_str(&bullet); + if insert_line < lines.len() { + output.push('\n'); + output.push_str(&lines[insert_line..].join("\n")); + } else { + output.push('\n'); + } + output +} + +async fn resolve_parent_channel( + client: &BuzzClient, + parent: &str, +) -> Result { + let filter = if Uuid::parse_str(parent).is_ok() { + serde_json::json!({"kinds": [39000], "#d": [parent], "limit": 1}) + } else { + serde_json::json!({"kinds": [39000]}) + }; + let matches: Vec = client + .query_paginated(filter, 1000) + .await? + .iter() + .filter_map(ChannelSummary::from_event) + .filter(|channel| channel.channel_id == parent || channel.name.eq_ignore_ascii_case(parent)) + .collect(); + match matches.as_slice() { + [channel] => Ok(ChannelSummary { + channel_id: channel.channel_id.clone(), + name: channel.name.clone(), + channel_type: channel.channel_type.clone(), + visibility: channel.visibility.clone(), + archived: channel.archived, + about: channel.about.clone(), + topic: channel.topic.clone(), + purpose: channel.purpose.clone(), + }), + [] => Err(CliError::Usage(format!( + "parent channel '{parent}' not found" + ))), + _ => Err(CliError::Usage(format!( + "multiple channels are named '{parent}'; use the parent channel UUID" + ))), + } +} + +async fn cmd_create_sub_channel( + client: &BuzzClient, + parent_ref: &str, + name: &str, + channel_type: Option<&str>, + visibility: Option<&str>, + description: Option<&str>, + ttl: Option, +) -> Result<(), CliError> { + let parent = resolve_parent_channel(client, parent_ref).await?; + let parent_uuid = parse_uuid(&parent.channel_id)?; + let member_filter = + serde_json::json!({"kinds": [39002], "#d": [&parent.channel_id], "limit": 1}); + let member_events = client.query_paginated(member_filter, 1).await?; + let caller = client.keys().public_key().to_hex(); + if !member_events + .first() + .map(extract_p_tags) + .unwrap_or_default() + .iter() + .any(|member| member == &caller) + { + return Err(CliError::Usage( + "sub-channel members must be members of the parent; the caller is not a parent member" + .into(), + )); + } + + let final_name = construct_sub_channel_name(&parent.name, name)?; + let channel_type = channel_type + .or(parent.channel_type.as_deref()) + .ok_or_else(|| CliError::Other("parent channel has no type".into()))?; + let visibility = visibility + .or(parent.visibility.as_deref()) + .map(|value| if value == "public" { "open" } else { value }) + .ok_or_else(|| CliError::Other("parent channel has no visibility".into()))?; + let ttl = ttl.map(validate_ttl_seconds).transpose()?; + let sub_uuid = Uuid::new_v4(); + let vis = match visibility { + "open" => buzz_sdk::Visibility::Open, + "private" => buzz_sdk::Visibility::Private, + other => { + return Err(CliError::Other(format!( + "invalid parent visibility: {other}" + ))) + } + }; + let kind = match channel_type { + "stream" => buzz_sdk::ChannelKind::Stream, + "forum" => buzz_sdk::ChannelKind::Forum, + other => { + return Err(CliError::Other(format!( + "invalid parent channel type: {other}" + ))) + } + }; + let builder = buzz_sdk::build_create_channel( + sub_uuid, + &final_name, + Some(vis), + Some(kind), + description, + ttl, + ) + .map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?; + let create_response = client.submit_event(client.sign_event(builder)?).await?; + let task = description.unwrap_or(&final_name); + + let announcement = format!("→ spawned #{final_name}"); + let announcement_result: Result = async { + let builder = buzz_sdk::build_message(parent_uuid, &announcement, None, &[], false, &[]) + .map_err(|e| CliError::Other(format!("build_message failed: {e}")))?; + let response = client.submit_event(client.sign_event(builder)?).await?; + serde_json::from_str::(&response) + .ok() + .and_then(|value| value["event_id"].as_str().map(str::to_string)) + .ok_or_else(|| CliError::Other("announcement response omitted event_id".into())) + } + .await; + + let announcement_id = match announcement_result { + Ok(id) => id, + Err(error) => { + eprintln!("warning: channel created, but parent announcement failed: {error}"); + print_sub_channel_response(&create_response, &sub_uuid, &final_name, None); + return Ok(()); + } + }; + + let sub_canvas = format!( + "# Sub-channel of #{}\n\n- parent: #{} ({})\n- spawned-from: {}\n- task: {}\n\nWhen the work here is complete, post a summary to #{} as a thread reply to the spawn announcement (event {}). Every member of this sub-channel must be a member of #{}.", + parent.name, parent.name, parent.channel_id, announcement_id, task, parent.name, announcement_id, parent.name + ); + if let Err(error) = set_canvas(client, sub_uuid, &sub_canvas).await { + eprintln!("warning: channel created, but sub-channel canvas setup failed: {error}"); + } + + let parent_canvas_result: Result<(), CliError> = async { + let filter = serde_json::json!({"kinds": [40100], "#h": [&parent.channel_id], "limit": 1}); + let events = client.query_paginated(filter, 1).await?; + let current = events + .first() + .and_then(|event| event.get("content")) + .and_then(|content| content.as_str()) + .unwrap_or(""); + let updated = append_sub_channel_to_canvas(current, &final_name, task); + set_canvas(client, parent_uuid, &updated).await + } + .await; + if let Err(error) = parent_canvas_result { + eprintln!("warning: channel created, but parent canvas update failed: {error}"); + } + print_sub_channel_response( + &create_response, + &sub_uuid, + &final_name, + Some(&announcement_id), + ); + Ok(()) +} + +async fn set_canvas(client: &BuzzClient, channel: Uuid, content: &str) -> Result<(), CliError> { + let builder = buzz_sdk::build_set_canvas(channel, content) + .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; + client.submit_event(client.sign_event(builder)?).await?; + Ok(()) +} + +fn print_sub_channel_response( + response: &str, + channel_id: &Uuid, + name: &str, + announcement_event_id: Option<&str>, +) { + let normalized = normalize_write_response(response); + let mut output: serde_json::Value = + serde_json::from_str(&normalized).unwrap_or_else(|_| serde_json::json!({})); + output["channel_id"] = serde_json::json!(channel_id); + output["name"] = serde_json::json!(name); + output["announcement_event_id"] = serde_json::json!(announcement_event_id); + println!("{output}"); +} + /// A resolved live managed-agent instance backing a template persona slug. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] struct ResolvedAgent { @@ -1092,6 +1339,7 @@ pub async fn dispatch( description, ttl, template, + parent, templates_file, } => { if let Some(template_name) = template { @@ -1106,6 +1354,23 @@ pub async fn dispatch( ttl, ) .await + } else if let Some(parent) = parent { + cmd_create_sub_channel( + client, + &parent, + &name, + channel_type + .as_ref() + .map(|value| value.to_string()) + .as_deref(), + visibility + .as_ref() + .map(|value| value.to_string()) + .as_deref(), + description.as_deref(), + ttl, + ) + .await } else { // required_unless_present = "template" guarantees these are // Some when template is None. @@ -1176,10 +1441,10 @@ pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &BuzzClient) -> Resu #[cfg(test)] mod tests { use super::{ - apply_cardinality_rule, build_template_report, cmd_set_add_policy, - finalize_roster_resolution, name_matches, resolve_roster_with_archive_filter, - validate_ttl_seconds, ArchivedExclusion, ChannelSummary, ResolvedAgent, RosterResolution, - SkippedSlug, + append_sub_channel_to_canvas, apply_cardinality_rule, build_template_report, + cmd_set_add_policy, construct_sub_channel_name, finalize_roster_resolution, name_matches, + resolve_roster_with_archive_filter, validate_ttl_seconds, ArchivedExclusion, + ChannelSummary, ResolvedAgent, RosterResolution, SkippedSlug, }; use crate::client::BuzzClient; use crate::CliError; @@ -1189,6 +1454,34 @@ mod tests { json!({ "tags": tags }) } + #[test] + fn sanitizes_and_constructs_sub_channel_names() { + assert_eq!( + construct_sub_channel_name("engineering", " API / Client---Work! ").unwrap(), + "engineering--api-client-work" + ); + assert!(construct_sub_channel_name("engineering", "💥").is_err()); + assert!(construct_sub_channel_name("engineering--api", "work").is_err()); + } + + #[test] + fn appends_sub_channel_section_and_uses_first_task_line() { + assert_eq!( + append_sub_channel_to_canvas("# Parent", "parent--api", "Build API\nDetails"), + "# Parent\n\n## Sub-channels\n- #parent--api — Build API\n" + ); + } + + #[test] + fn appends_sub_channel_bullet_after_existing_bullets() { + let canvas = + "# Parent\n\n## Sub-channels\n- #parent--one — One\n\nNotes\n\n## Links\n- link"; + assert_eq!( + append_sub_channel_to_canvas(canvas, "parent--two", "Two"), + "# Parent\n\n## Sub-channels\n- #parent--one — One\n- #parent--two — Two\n\nNotes\n\n## Links\n- link" + ); + } + #[test] fn from_event_extracts_known_tags() { let ev = event(json!([ diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 02a58a618e..53a7d2a6c2 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -544,17 +544,17 @@ pub enum ChannelsCmd { }, /// Create a new channel #[command( - after_help = "Examples:\n buzz channels create --name general --type stream --visibility open\n buzz channels create --name design --type forum --visibility open --description \"Design discussions\"\n buzz channels create --name standup --type stream --visibility open --ttl 3600 # ephemeral, archived after 1h idle\n buzz channels create --name project-x --template \"Buzz Team\" # type/visibility/canvas/roster from the template; explicit flags override" + after_help = "Examples:\n buzz channels create --name general --type stream --visibility open\n buzz channels create --name design --type forum --visibility open --description \"Design discussions\"\n buzz channels create --name standup --type stream --visibility open --ttl 3600 # ephemeral, archived after 1h idle\n buzz channels create --name implementation --parent general --description \"Implement the feature\"\n buzz channels create --name project-x --template \"Buzz Team\" # type/visibility/canvas/roster from the template; explicit flags override" )] Create { /// Channel name #[arg(long)] name: String, /// Channel type. Required unless --template supplies one. - #[arg(long = "type", value_enum, required_unless_present = "template")] + #[arg(long = "type", value_enum, required_unless_present_any = ["template", "parent"])] channel_type: Option, /// Channel visibility. Required unless --template supplies one. - #[arg(long, value_enum, required_unless_present = "template")] + #[arg(long, value_enum, required_unless_present_any = ["template", "parent"])] visibility: Option, /// Channel description #[arg(long)] @@ -568,6 +568,9 @@ pub enum ChannelsCmd { /// its agent roster against the relay to add as members. #[arg(long)] template: Option, + /// Parent channel UUID or exact name. Creates a one-level sub-channel. + #[arg(long, conflicts_with = "template", value_name = "CHANNEL")] + parent: Option, /// Override the channel-templates.json path (default: the desktop /// app's prod app-data dir). Mainly for the dev store or testing. #[arg(long, value_name = "PATH")] diff --git a/desktop/package.json b/desktop/package.json index 2226a0cb12..7d1ad32f61 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -30,6 +30,7 @@ "@emoji-mart/data": "^1.2.1", "@emoji-mart/react": "^1.1.1", "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/jetbrains-mono": "^5.3.0", "@mediapipe/tasks-vision": "^0.10.35", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.11", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 459fa75743..5429eaec43 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -27,6 +27,16 @@ export default defineConfig({ "**/channels.spec.ts", "**/channel-shared-header-backdrop.spec.ts", "**/channel-composer-overflow.spec.ts", + "**/dev-mode-overflow.spec.ts", + "**/dev-mode-members.spec.ts", + "**/dev-mode-mentions.spec.ts", + "**/dev-mode-palette.spec.ts", + "**/dev-mode-shortcuts.spec.ts", + "**/dev-mode-composer-default.spec.ts", + "**/dev-mode-first-reply.spec.ts", + "**/dev-mode-sub-channels.spec.ts", + "**/dev-mode-unread.spec.ts", + "**/dev-mode-media.spec.ts", "**/badge.spec.ts", "**/channel-browser.spec.ts", "**/channel-add-screenshots.spec.ts", diff --git a/desktop/public/sounds/bong.mp3 b/desktop/public/sounds/bong.mp3 index a62f2a442f..e1e6f17daf 100644 Binary files a/desktop/public/sounds/bong.mp3 and b/desktop/public/sounds/bong.mp3 differ diff --git a/desktop/public/sounds/bong.svg b/desktop/public/sounds/bong.svg index 9088087299..de559051a4 100644 --- a/desktop/public/sounds/bong.svg +++ b/desktop/public/sounds/bong.svg @@ -1 +1 @@ - + diff --git a/desktop/public/sounds/boo.mp3 b/desktop/public/sounds/boo.mp3 index 1d672a7d94..9431dc1188 100644 Binary files a/desktop/public/sounds/boo.mp3 and b/desktop/public/sounds/boo.mp3 differ diff --git a/desktop/public/sounds/boo.svg b/desktop/public/sounds/boo.svg index 977037bd3f..0190f87326 100644 --- a/desktop/public/sounds/boo.svg +++ b/desktop/public/sounds/boo.svg @@ -1 +1 @@ - + diff --git a/desktop/public/sounds/dng.mp3 b/desktop/public/sounds/dng.mp3 index 29b111b10a..132c51f64a 100644 Binary files a/desktop/public/sounds/dng.mp3 and b/desktop/public/sounds/dng.mp3 differ diff --git a/desktop/public/sounds/dng.svg b/desktop/public/sounds/dng.svg index b702b0534e..cc779fb9fc 100644 --- a/desktop/public/sounds/dng.svg +++ b/desktop/public/sounds/dng.svg @@ -1 +1 @@ - + diff --git a/desktop/public/sounds/doo.mp3 b/desktop/public/sounds/doo.mp3 index 6eb8f3b56e..fa9190b7ba 100644 Binary files a/desktop/public/sounds/doo.mp3 and b/desktop/public/sounds/doo.mp3 differ diff --git a/desktop/public/sounds/doo.svg b/desktop/public/sounds/doo.svg index 5fe260a236..a1a6e21bf5 100644 --- a/desktop/public/sounds/doo.svg +++ b/desktop/public/sounds/doo.svg @@ -1 +1 @@ - + diff --git a/desktop/public/sounds/doodone.mp3 b/desktop/public/sounds/doodone.mp3 index c21ee7ad01..f65b8a862a 100644 Binary files a/desktop/public/sounds/doodone.mp3 and b/desktop/public/sounds/doodone.mp3 differ diff --git a/desktop/public/sounds/doodone.svg b/desktop/public/sounds/doodone.svg index bc6275de48..9433f41e22 100644 --- a/desktop/public/sounds/doodone.svg +++ b/desktop/public/sounds/doodone.svg @@ -1 +1 @@ - + diff --git a/desktop/public/sounds/doong.mp3 b/desktop/public/sounds/doong.mp3 index 7c81649b91..8ece1d4ed1 100644 Binary files a/desktop/public/sounds/doong.mp3 and b/desktop/public/sounds/doong.mp3 differ diff --git a/desktop/public/sounds/doong.svg b/desktop/public/sounds/doong.svg index 43728d02b4..d16d2db368 100644 --- a/desktop/public/sounds/doong.svg +++ b/desktop/public/sounds/doong.svg @@ -1 +1 @@ - + diff --git a/desktop/public/sounds/doop.mp3 b/desktop/public/sounds/doop.mp3 index bbece1cb73..84fa111eb1 100644 Binary files a/desktop/public/sounds/doop.mp3 and b/desktop/public/sounds/doop.mp3 differ diff --git a/desktop/public/sounds/doop.svg b/desktop/public/sounds/doop.svg index 4fdce28a34..6891b44b5c 100644 --- a/desktop/public/sounds/doop.svg +++ b/desktop/public/sounds/doop.svg @@ -1 +1 @@ - + diff --git a/desktop/public/sounds/flirl.mp3 b/desktop/public/sounds/flirl.mp3 index 1edaa772ef..229258ab86 100644 Binary files a/desktop/public/sounds/flirl.mp3 and b/desktop/public/sounds/flirl.mp3 differ diff --git a/desktop/public/sounds/flirl.svg b/desktop/public/sounds/flirl.svg index 5daa5e2a4b..08df112ebc 100644 --- a/desktop/public/sounds/flirl.svg +++ b/desktop/public/sounds/flirl.svg @@ -1 +1 @@ - + diff --git a/desktop/public/sounds/flutter.mp3 b/desktop/public/sounds/flutter.mp3 index f6b7e3db6e..807c19b091 100644 Binary files a/desktop/public/sounds/flutter.mp3 and b/desktop/public/sounds/flutter.mp3 differ diff --git a/desktop/public/sounds/flutter.svg b/desktop/public/sounds/flutter.svg index 81a87769cb..e470f61721 100644 --- a/desktop/public/sounds/flutter.svg +++ b/desktop/public/sounds/flutter.svg @@ -1 +1 @@ - + diff --git a/desktop/public/sounds/oh-no.mp3 b/desktop/public/sounds/oh-no.mp3 index c95b6dfb56..65c6ac6b25 100644 Binary files a/desktop/public/sounds/oh-no.mp3 and b/desktop/public/sounds/oh-no.mp3 differ diff --git a/desktop/public/sounds/oh-no.svg b/desktop/public/sounds/oh-no.svg index 26ad14a5b4..36e127bac5 100644 --- a/desktop/public/sounds/oh-no.svg +++ b/desktop/public/sounds/oh-no.svg @@ -1 +1 @@ - + diff --git a/desktop/public/sounds/ping.mp3 b/desktop/public/sounds/ping.mp3 index 9cb4dc6352..4210dc3763 100644 Binary files a/desktop/public/sounds/ping.mp3 and b/desktop/public/sounds/ping.mp3 differ diff --git a/desktop/public/sounds/ping.svg b/desktop/public/sounds/ping.svg index c96cfbdea4..d0df0b4b9f 100644 --- a/desktop/public/sounds/ping.svg +++ b/desktop/public/sounds/ping.svg @@ -1 +1 @@ - + diff --git a/desktop/public/sounds/unison.mp3 b/desktop/public/sounds/unison.mp3 index 143119017e..5c6495ae4d 100644 Binary files a/desktop/public/sounds/unison.mp3 and b/desktop/public/sounds/unison.mp3 differ diff --git a/desktop/public/sounds/unison.svg b/desktop/public/sounds/unison.svg index 6759d3df77..38ed6754de 100644 --- a/desktop/public/sounds/unison.svg +++ b/desktop/public/sounds/unison.svg @@ -1 +1 @@ - + diff --git a/desktop/src-tauri/src/commands/agent_completion.rs b/desktop/src-tauri/src/commands/agent_completion.rs new file mode 100644 index 0000000000..16e3cf7536 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_completion.rs @@ -0,0 +1,111 @@ +use tauri::{AppHandle, State}; + +use super::agent_model_process::run_acp_helper_subprocess; +use super::agent_models::{agent_model_discovery_config, AgentModelDiscoveryConfig}; + +use crate::{ + app_state::AppState, + managed_agents::{ + discovery_env_with_baked_floor, known_acp_runtime, load_global_agent_config, + load_managed_agents, load_personas, missing_command_message, resolve_command, + runtime_metadata_env_vars, user_facing_harness_error, + }, +}; + +/// Run one bounded prompt through a managed agent and return its reply text. +/// +/// Spawns a short-lived `buzz-acp complete` subprocess against the agent's +/// effective harness config (same descriptor resolution as spawn/model +/// discovery). No relay connection — the exchange never appears in any +/// channel. Powers Developer Mode channel naming. +#[tauri::command] +pub async fn generate_agent_completion( + pubkey: String, + prompt: String, + system_prompt: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let (resolved_acp, agent_command, discovery) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + let records = load_managed_agents(&app)?; + let record = records + .iter() + .find(|r| r.pubkey == pubkey) + .ok_or_else(|| format!("agent {pubkey} not found"))?; + + let resolved = resolve_command(&record.acp_command) + .ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?; + + let personas = load_personas(&app).unwrap_or_default(); + let global = load_global_agent_config(&app).unwrap_or_default(); + let discovery = agent_model_discovery_config(record, &personas, &global).map_err(|e| { + format!( + "cannot run completion for {pubkey}: {}", + user_facing_harness_error(&e) + ) + })?; + + let resolved_agent = resolve_command(&discovery.command) + .map(|p| p.display().to_string()) + .unwrap_or_else(|| discovery.command.clone()); + + (resolved, resolved_agent, discovery) + }; // store lock released — subprocess runs without holding the lock + + let AgentModelDiscoveryConfig { + command: descriptor_command, + args: agent_args, + model, + provider, + env: merged_env, + .. + } = discovery; + let mut merged_env = discovery_env_with_baked_floor(merged_env); + + // Mirror spawn_agent_child: the harness reads model/provider from its + // runtime env vars, and those override the layered descriptor env. + if let Some(meta) = known_acp_runtime(&descriptor_command) { + for (key, value) in runtime_metadata_env_vars( + meta.model_env_var, + meta.provider_env_var, + meta.provider_locked, + model.as_deref(), + provider.as_deref(), + ) { + merged_env.insert(key.to_string(), value.to_string()); + } + } + + let mut helper_args = vec![ + "complete".to_string(), + "--json".to_string(), + "--prompt".to_string(), + prompt, + ]; + if let Some(system) = system_prompt.filter(|s| !s.trim().is_empty()) { + helper_args.push("--system-prompt".to_string()); + helper_args.push(system); + } + if let Some(model) = model { + helper_args.push("--model".to_string()); + helper_args.push(model); + } + + let raw = run_acp_helper_subprocess( + resolved_acp, + agent_command, + agent_args, + merged_env, + helper_args, + ) + .await?; + + raw.get("text") + .and_then(|v| v.as_str()) + .map(str::to_owned) + .ok_or_else(|| "buzz-acp complete returned no text".to_string()) +} diff --git a/desktop/src-tauri/src/commands/agent_model_process.rs b/desktop/src-tauri/src/commands/agent_model_process.rs index 998edeca27..f02115c386 100644 --- a/desktop/src-tauri/src/commands/agent_model_process.rs +++ b/desktop/src-tauri/src/commands/agent_model_process.rs @@ -7,21 +7,32 @@ use crate::managed_agents::{ use super::agent_models::normalize_agent_models; -pub(super) async fn run_agent_models_command( +/// Run a lightweight `buzz-acp` helper subcommand (`models`, `complete`, ...) +/// against the configured agent and parse its JSON stdout. +/// +/// `helper_args` is the full subcommand argv (e.g. `["models", "--json"]`). +/// Env layering mirrors runtime agent spawn: augmented PATH, runtime default +/// env, baked provider defaults, then user env last so it always wins. +pub(super) async fn run_acp_helper_subprocess( resolved_acp: PathBuf, agent_command: String, agent_args: Vec, - persisted_model: Option, merged_env: BTreeMap, -) -> Result { + helper_args: Vec, +) -> Result { + let subcommand = helper_args + .first() + .cloned() + .unwrap_or_else(|| "helper".into()); // Clone the env map for redaction below — `merged_env` is moved // into the spawn_blocking closure and we still need the values to // scrub any user-supplied secrets that the child surfaces in stderr. let env_for_redaction = merged_env.clone(); + let subcommand_in_closure = subcommand.clone(); // Use spawn_blocking because the desktop Tauri crate doesn't enable // tokio's `process` feature. std::process::Command is synchronous - // but fine for a short-lived subprocess (~2-5s). + // but fine for a short-lived subprocess. let output = tokio::task::spawn_blocking(move || { let mut cmd = std::process::Command::new(&resolved_acp); if let Some(home) = default_agent_workdir() { @@ -36,8 +47,7 @@ pub(super) async fn run_agent_models_command( if let Some(ref path) = crate::managed_agents::readiness::cli_probe::augmented_path() { cmd.env("PATH", path); } - cmd.arg("models") - .arg("--json") + cmd.args(&helper_args) .env("BUZZ_ACP_AGENT_COMMAND", &agent_command) .env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); if let Some(meta) = known_acp_runtime(&agent_command) { @@ -59,10 +69,10 @@ pub(super) async fn run_agent_models_command( cmd.stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .output() - .map_err(|e| format!("failed to spawn buzz-acp models: {e}")) + .map_err(|e| format!("failed to spawn buzz-acp {subcommand_in_closure}: {e}")) }) .await - .map_err(|e| format!("model discovery task failed: {e}"))? + .map_err(|e| format!("buzz-acp {subcommand} task failed: {e}"))? .map_err(|e: String| e)?; if !output.status.success() { @@ -72,13 +82,30 @@ pub(super) async fn run_agent_models_command( // a failing child process echoed back. let stderr_redacted = redact_env_values_in(stderr.as_ref(), &env_for_redaction); return Err(format!( - "buzz-acp models failed (exit {}): {stderr_redacted}", + "buzz-acp {subcommand} failed (exit {}): {stderr_redacted}", output.status.code().unwrap_or(-1) )); } - let raw: serde_json::Value = serde_json::from_slice(&output.stdout) - .map_err(|e| format!("failed to parse model JSON: {e}"))?; + serde_json::from_slice(&output.stdout) + .map_err(|e| format!("failed to parse buzz-acp {subcommand} JSON: {e}")) +} + +pub(super) async fn run_agent_models_command( + resolved_acp: PathBuf, + agent_command: String, + agent_args: Vec, + persisted_model: Option, + merged_env: BTreeMap, +) -> Result { + let raw = run_acp_helper_subprocess( + resolved_acp, + agent_command, + agent_args, + merged_env, + vec!["models".into(), "--json".into()], + ) + .await?; Ok(normalize_agent_models(&raw, persisted_model)) } diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 7ce03b140b..d3cff9a215 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -167,9 +167,9 @@ fn model_discovery_error(pubkey: &str, error: &str) -> String { #[path = "agent_models_discovery_config.rs"] mod discovery_config; -use discovery_config::{ - agent_model_discovery_config, draft_agent_model_discovery_env, AgentModelDiscoveryConfig, -}; +use discovery_config::draft_agent_model_discovery_env; +// Re-exported for `agent_completion` (channel naming) to launch discovery. +pub(super) use discovery_config::{agent_model_discovery_config, AgentModelDiscoveryConfig}; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/desktop/src-tauri/src/commands/agent_models_discovery_config.rs b/desktop/src-tauri/src/commands/agent_models_discovery_config.rs index e43f09495b..4bd5fe80ee 100644 --- a/desktop/src-tauri/src/commands/agent_models_discovery_config.rs +++ b/desktop/src-tauri/src/commands/agent_models_discovery_config.rs @@ -17,23 +17,23 @@ use crate::managed_agents::known_acp_runtime; /// one pure step so the linked-agent regression test can bind the exact values /// the command consumes. #[derive(Debug, PartialEq, Eq)] -pub(super) struct AgentModelDiscoveryConfig { +pub(in crate::commands) struct AgentModelDiscoveryConfig { /// Effective harness command (descriptor-resolved), for `resolve_command`. - pub(super) command: String, + pub(in crate::commands) command: String, /// Effective harness args (descriptor-resolved). - pub(super) args: Vec, + pub(in crate::commands) args: Vec, /// Model from the authoritative resolver spawn uses — linked instances /// read their definition, never stale `record.model` bytes. - pub(super) model: Option, + pub(in crate::commands) model: Option, /// Provider from the same authoritative resolver — never stale /// `record.provider` bytes for linked instances. - pub(super) provider: Option, + pub(in crate::commands) provider: Option, /// The runtime's provider env var (e.g. `GOOSE_PROVIDER`), so discovery /// can recover the provider from the env when the resolver yields none. /// `None` for runtimes that do not take a provider, or an unknown command. - pub(super) provider_env_var: Option<&'static str>, + pub(in crate::commands) provider_env_var: Option<&'static str>, /// The descriptor's fully layered env (definition/persona/global/agent). - pub(super) env: BTreeMap, + pub(in crate::commands) env: BTreeMap, } /// Resolve the model-discovery config for a saved agent — the descriptor-backed @@ -55,7 +55,7 @@ pub(super) struct AgentModelDiscoveryConfig { /// Returns `Err("DANGLING_HARNESS_ID:")` from the descriptor resolver when /// the harness id no longer exists; the caller routes it through /// `model_discovery_error`. -pub(super) fn agent_model_discovery_config( +pub(in crate::commands) fn agent_model_discovery_config( record: &crate::managed_agents::ManagedAgentRecord, personas: &[crate::managed_agents::AgentDefinition], global: &crate::managed_agents::GlobalAgentConfig, diff --git a/desktop/src-tauri/src/commands/link_labels.rs b/desktop/src-tauri/src/commands/link_labels.rs new file mode 100644 index 0000000000..d5e42cd3aa --- /dev/null +++ b/desktop/src-tauri/src/commands/link_labels.rs @@ -0,0 +1,379 @@ +//! Rich link labels resolved through the locally installed `sq agent-tools` +//! CLI. Slack permalinks sit behind an auth wall, and private Google files +//! return "Sign in" pages, so plain HTML title fetching (link_preview.rs) +//! can't label them. When the user has `sq` on their machine we ask the +//! authenticated agent-tools extensions instead: +//! +//! - Slack channel message → "author in #channel" +//! - Slack DM message → "author with partner" +//! - Slack channel link → "#channel" +//! - Google Docs/Drive file → the file's real title +//! +//! Everything is best-effort: a missing `sq` binary, CLI errors, and +//! timeouts all yield `Ok(None)` so the caller falls back to the generic +//! page-title path. + +use std::path::PathBuf; +use std::time::Duration; + +use serde_json::Value; +use url::Url; + +const SQ_TIMEOUT: Duration = Duration::from_secs(20); + +#[derive(Debug, PartialEq)] +enum AgentLink { + SlackMessage { channel: String, ts: String }, + SlackChannel { channel: String }, + GoogleFile, +} + +#[tauri::command] +pub async fn fetch_agent_link_label(href: String) -> Result, String> { + let url = Url::parse(href.trim()).map_err(|error| format!("invalid URL: {error}"))?; + let Some(link) = classify_agent_link(&url) else { + return Ok(None); + }; + let Some(sq) = sq_binary() else { + return Ok(None); + }; + + let label = match link { + AgentLink::SlackMessage { channel, ts } => { + resolve_slack_message_label(&sq, &channel, &ts).await + } + AgentLink::SlackChannel { channel } => resolve_slack_conversation_label(&sq, &channel) + .await + .map(|conversation| conversation.label), + AgentLink::GoogleFile => resolve_google_file_label(&sq, url.as_str()).await, + }; + Ok(label) +} + +fn classify_agent_link(url: &Url) -> Option { + if url.scheme() != "https" { + return None; + } + let host = url.host_str()?.to_ascii_lowercase(); + let segments: Vec<&str> = url.path_segments()?.filter(|s| !s.is_empty()).collect(); + + if host == "slack.com" || host.ends_with(".slack.com") { + return match segments.as_slice() { + ["archives", channel] if looks_like_slack_conversation_id(channel) => { + Some(AgentLink::SlackChannel { + channel: (*channel).to_string(), + }) + } + ["archives", channel, message] if looks_like_slack_conversation_id(channel) => { + let ts = parse_slack_message_ts(message)?; + Some(AgentLink::SlackMessage { + channel: (*channel).to_string(), + ts, + }) + } + _ => None, + }; + } + + super::is_supported_google_link(url).then_some(AgentLink::GoogleFile) +} + +fn looks_like_slack_conversation_id(value: &str) -> bool { + let mut chars = value.chars(); + matches!(chars.next(), Some('C' | 'D' | 'G')) + && value.len() >= 9 + && chars.all(|c| c.is_ascii_alphanumeric()) +} + +/// Slack permalinks encode the message timestamp as `p1738012345123456`; +/// the API wants `1738012345.123456`. +fn parse_slack_message_ts(segment: &str) -> Option { + let digits = segment.strip_prefix('p')?; + if digits.len() <= 10 || !digits.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let (seconds, fraction) = digits.split_at(10); + Some(format!("{seconds}.{fraction}")) +} + +struct SlackConversation { + label: String, + is_dm: bool, +} + +async fn resolve_slack_message_label(sq: &PathBuf, channel: &str, ts: &str) -> Option { + let author_args = [ + "agent-tools", + "slack", + "read-thread", + "--channel", + channel, + "--thread-ts", + ts, + "--limit", + "1", + ]; + let author = run_sq_json(sq, &author_args); + let conversation = resolve_slack_conversation_label(sq, channel); + let (author, conversation) = tokio::join!(author, conversation); + + let author = author.as_ref().and_then(extract_message_author)?; + let conversation = conversation?; + let joiner = if conversation.is_dm { "with" } else { "in" }; + Some(format!("{author} {joiner} {}", conversation.label)) +} + +async fn resolve_slack_conversation_label( + sq: &PathBuf, + channel: &str, +) -> Option { + if channel.starts_with('D') { + // resolve-name can't see DM conversation IDs, but search-channels + // matches them and returns the DM partner. + let result = run_sq_json( + sq, + &[ + "agent-tools", + "slack", + "search-channels", + "--query", + channel, + "--limit", + "3", + ], + ) + .await?; + let partner = extract_dm_partner(&result, channel)?; + return Some(SlackConversation { + label: partner, + is_dm: true, + }); + } + + let result = run_sq_json( + sq, + &[ + "agent-tools", + "slack", + "resolve-name", + "--name", + channel, + "--entity-type", + "channel", + ], + ) + .await?; + let name = result.get("name")?.as_str()?.trim(); + if name.is_empty() || name == channel { + return None; + } + Some(SlackConversation { + label: format!("#{}", name.trim_start_matches('#')), + is_dm: result.get("type").and_then(Value::as_str) == Some("im"), + }) +} + +fn extract_message_author(result: &Value) -> Option { + let user = result.get("messages")?.as_array()?.first()?.get("user")?; + let author = user + .get("username") + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) + .or_else(|| { + user.get("real_name") + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) + })?; + Some(author.trim().to_string()) +} + +fn extract_dm_partner(result: &Value, channel: &str) -> Option { + let results = result.get("results")?.as_array()?; + let dm = results + .iter() + .find(|entry| entry.get("id").and_then(Value::as_str) == Some(channel))?; + let partner = dm.get("dm_user")?; + let name = partner + .get("username") + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) + .or_else(|| { + partner + .get("real_name") + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) + })?; + Some(name.trim().to_string()) +} + +async fn resolve_google_file_label(sq: &PathBuf, href: &str) -> Option { + let result = run_sq_json( + sq, + &[ + "agent-tools", + "google-drive", + "get-file-metadata", + "--file-id-or-url", + href, + ], + ) + .await?; + let name = result.get("file")?.get("name")?.as_str()?.trim(); + (!name.is_empty()).then(|| name.chars().take(180).collect()) +} + +fn sq_binary() -> Option { + crate::managed_agents::resolve_command("sq") +} + +async fn run_sq_json(sq: &PathBuf, args: &[&str]) -> Option { + let mut command = tokio::process::Command::new(sq); + command + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()) + .kill_on_drop(true); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW + } + + let output = tokio::time::timeout(SQ_TIMEOUT, command.output()) + .await + .ok()? + .ok()?; + if !output.status.success() { + return None; + } + serde_json::from_slice(&output.stdout).ok() +} + +#[cfg(test)] +mod tests { + use super::{ + classify_agent_link, extract_dm_partner, extract_message_author, parse_slack_message_ts, + AgentLink, + }; + use serde_json::json; + use url::Url; + + fn classify(href: &str) -> Option { + classify_agent_link(&Url::parse(href).unwrap()) + } + + #[test] + fn slack_message_permalinks_classify_with_api_ts() { + assert_eq!( + classify("https://block.slack.com/archives/C0AJ54K0KNY/p1785361516443929"), + Some(AgentLink::SlackMessage { + channel: "C0AJ54K0KNY".to_string(), + ts: "1785361516.443929".to_string(), + }) + ); + // Thread-reply permalinks carry a query string; the p-segment is + // still the linked message. + assert_eq!( + classify( + "https://block.slack.com/archives/D0BLPGME058/p1785367578361779?thread_ts=1785361516.443929&cid=D0BLPGME058" + ), + Some(AgentLink::SlackMessage { + channel: "D0BLPGME058".to_string(), + ts: "1785367578.361779".to_string(), + }) + ); + } + + #[test] + fn slack_channel_links_classify_without_ts() { + assert_eq!( + classify("https://block.slack.com/archives/C0AJ54K0KNY"), + Some(AgentLink::SlackChannel { + channel: "C0AJ54K0KNY".to_string(), + }) + ); + } + + #[test] + fn non_archive_and_non_slack_urls_do_not_classify_as_slack() { + assert_eq!(classify("https://block.slack.com/team/U123456"), None); + assert_eq!( + classify("https://notslack.com/archives/C0AJ54K0KNY/p1785361516443929"), + None + ); + assert_eq!( + classify("http://block.slack.com/archives/C0AJ54K0KNY"), + None + ); + // Malformed p-segments fall through entirely. + assert_eq!( + classify("https://block.slack.com/archives/C0AJ54K0KNY/p123"), + None + ); + assert_eq!( + classify("https://block.slack.com/archives/general/p1785361516443929"), + None + ); + } + + #[test] + fn google_file_links_classify() { + assert_eq!( + classify("https://docs.google.com/document/d/1WQchQDNfF6ZD/edit"), + Some(AgentLink::GoogleFile) + ); + assert_eq!( + classify("https://drive.google.com/file/d/abc123/view"), + Some(AgentLink::GoogleFile) + ); + assert_eq!(classify("https://docs.google.com/"), None); + } + + #[test] + fn permalink_ts_requires_p_prefix_and_digits() { + assert_eq!( + parse_slack_message_ts("p1785361516443929").as_deref(), + Some("1785361516.443929") + ); + assert_eq!(parse_slack_message_ts("1785361516443929"), None); + assert_eq!(parse_slack_message_ts("p1785361516"), None); + assert_eq!(parse_slack_message_ts("p17853615164439x9"), None); + } + + #[test] + fn author_prefers_username_then_real_name() { + let with_username = json!({ + "messages": [{ "user": { "username": "gpap", "real_name": "Gábor Pap" } }] + }); + assert_eq!( + extract_message_author(&with_username).as_deref(), + Some("gpap") + ); + + let real_name_only = json!({ + "messages": [{ "user": { "username": "", "real_name": "Gábor Pap" } }] + }); + assert_eq!( + extract_message_author(&real_name_only).as_deref(), + Some("Gábor Pap") + ); + + assert_eq!(extract_message_author(&json!({ "messages": [] })), None); + } + + #[test] + fn dm_partner_matches_conversation_id_exactly() { + let result = json!({ + "results": [ + { "id": "D0OTHER", "dm_user": { "username": "wrong" } }, + { "id": "D0BLPGME058", "dm_user": { "username": "dreya", "real_name": "Dreya Griffin" } } + ] + }); + assert_eq!( + extract_dm_partner(&result, "D0BLPGME058").as_deref(), + Some("dreya") + ); + assert_eq!(extract_dm_partner(&result, "D0MISSING"), None); + } +} diff --git a/desktop/src-tauri/src/commands/link_preview.rs b/desktop/src-tauri/src/commands/link_preview.rs index 5cc9c01d33..d1c8f7b5e3 100644 --- a/desktop/src-tauri/src/commands/link_preview.rs +++ b/desktop/src-tauri/src/commands/link_preview.rs @@ -55,7 +55,74 @@ pub async fn fetch_link_preview_title(href: String) -> Result, St Ok(extract_google_title(&body)) } -fn is_supported_google_link(url: &Url) -> bool { +/// Fetch the page title of any https URL — og:title / twitter:title falling +/// back to ``. Best-effort: auth walls, non-HTML responses, and +/// timeouts all yield `Ok(None)` so callers can render the bare link. +#[tauri::command] +pub async fn fetch_page_title(href: String) -> Result<Option<String>, String> { + let url = Url::parse(href.trim()).map_err(|error| format!("invalid URL: {error}"))?; + if url.scheme() != "https" { + return Ok(None); + } + + let client = reqwest::Client::builder() + .redirect(Policy::limited(3)) + .pool_idle_timeout(Duration::from_secs(10)) + .pool_max_idle_per_host(1) + .build() + .map_err(|error| format!("page title client failed: {error}"))?; + + let request = client + .get(url.as_str()) + .header( + ACCEPT, + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + ) + .header(USER_AGENT, "Buzz Desktop link preview"); + + let response = match tokio::time::timeout(TITLE_FETCH_TIMEOUT, request.send()).await { + Ok(Ok(response)) => response, + Ok(Err(_)) | Err(_) => return Ok(None), + }; + + if !response.status().is_success() { + return Ok(None); + } + + let is_html = response + .headers() + .get(CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.to_ascii_lowercase().contains("text/html")) + .unwrap_or(true); + if !is_html { + return Ok(None); + } + + let body = read_limited_text(response).await?; + Ok(extract_page_title(&body)) +} + +fn extract_page_title(html: &str) -> Option<String> { + let raw = extract_meta_title(html).or_else(|| extract_title_tag(html))?; + let title = decode_html_entities(&raw) + .split_whitespace() + .collect::<Vec<_>>() + .join(" "); + if title.is_empty() || is_auth_wall_title(&title) { + return None; + } + Some(title) +} + +/// Auth walls (Slack, Google, Okta, ...) title their login pages "Sign in +/// ..." — worse than useless as a link label, so render the bare URL instead. +fn is_auth_wall_title(title: &str) -> bool { + let lower = title.to_ascii_lowercase(); + lower.starts_with("sign in") || lower.starts_with("log in") || lower.starts_with("login") +} + +pub(crate) fn is_supported_google_link(url: &Url) -> bool { if url.scheme() != "https" { return false; } @@ -251,9 +318,52 @@ fn decode_html_entities(value: &str) -> String { #[cfg(test)] mod tests { - use super::{extract_google_title, is_supported_google_link}; + use super::{extract_google_title, extract_page_title, is_supported_google_link}; use url::Url; + #[test] + fn page_title_prefers_open_graph_then_title_tag() { + let html = r#" + <html> + <head> + <meta property="og:title" content="Slack thread & replies"> + <title>Fallback title + + + "#; + assert_eq!( + extract_page_title(html).as_deref(), + Some("Slack thread & replies") + ); + assert_eq!( + extract_page_title("Plain\n Page Title").as_deref(), + Some("Plain Page Title") + ); + } + + #[test] + fn page_title_none_when_absent_or_blank() { + assert_eq!( + extract_page_title("no title"), + None + ); + assert_eq!(extract_page_title(" "), None); + } + + #[test] + fn page_title_rejects_auth_wall_titles() { + assert_eq!(extract_page_title("Sign in to Block"), None); + assert_eq!( + extract_page_title("Log in | Workspace"), + None + ); + assert_eq!(extract_page_title("LOGIN"), None); + assert_eq!( + extract_page_title("Design signals").as_deref(), + Some("Design signals") + ); + } + #[test] fn title_prefers_open_graph_title() { let html = r#" diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 66ef7ef17b..ef8b7efbbe 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,4 +1,5 @@ mod agent_auth; +mod agent_completion; mod agent_config; mod agent_discovery; mod agent_logs; @@ -23,6 +24,7 @@ mod identity; mod identity_archive; mod join_policy; mod legacy_storage; +mod link_labels; mod link_preview; pub(crate) mod media; mod media_animated; @@ -62,6 +64,7 @@ mod workflows; mod workspace; pub use agent_auth::*; +pub use agent_completion::*; pub use agent_config::*; pub use agent_discovery::*; pub use agent_logs::*; @@ -82,6 +85,7 @@ pub use identity::*; pub use identity_archive::*; pub use join_policy::*; pub use legacy_storage::*; +pub use link_labels::*; pub use link_preview::*; pub use media::*; pub use media_download::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 35f4eae866..5fc5c3545a 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -693,7 +693,9 @@ pub fn run() { get_relay_ws_url, get_relay_http_url, get_media_proxy_port, + fetch_agent_link_label, fetch_link_preview_title, + fetch_page_title, discover_acp_auth_methods, discover_acp_providers, discover_git_bash_prerequisite, @@ -786,6 +788,7 @@ pub fn run() { get_managed_agent_log, get_agent_models, discover_agent_models, + generate_agent_completion, get_agent_config_surface, get_runtime_file_config, get_baked_build_env_keys, diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index abbbf29610..c47adea0c8 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -13,6 +13,7 @@ import { useLiveHomeFeedActions } from "@/app/useLiveHomeFeedActions"; import { useChannelBrowserDialog } from "@/app/useChannelBrowserDialog"; import { useMarkAsReadShortcuts } from "@/app/useMarkAsReadShortcuts"; import { useSettingsShortcuts } from "@/app/useSettingsShortcuts"; +import { useGlobalActionShortcuts } from "@/app/useGlobalActionShortcuts"; import { useAppShellDesktopNotifications } from "@/app/useAppShellDesktopNotifications"; import { useAppShellLifecycleEffects } from "@/app/useAppShellLifecycleEffects"; import { useThreadActivityFeedItems } from "@/app/useThreadActivityFeedItems"; @@ -80,6 +81,7 @@ import { } from "@/features/communities/communityNavigationStorage"; import { useAddCommunityDialogState } from "@/features/communities/addCommunityPrefill"; import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate"; +import { useDisplayStyle } from "@/features/dev-mode/lib/displayStylePreference"; import { relayClient } from "@/shared/api/relayClient"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal"; @@ -91,7 +93,6 @@ import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationCon import { MainInsetProvider } from "@/shared/layout/MainInsetContext"; import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout"; import { cn } from "@/shared/lib/cn"; -import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; @@ -102,10 +103,16 @@ const LazySettingsScreen = React.lazy(async () => { return { default: module.SettingsScreen }; }); +const LazyDevModeShell = React.lazy(async () => { + const module = await import("@/features/dev-mode/ui/DevModeShell"); + return { default: module.DevModeShell }; +}); + export function AppShell() { useWebviewZoomShortcuts(); useTauriWindowDrag(); useWebviewScrollBoundaryLock(); + const displayStyle = useDisplayStyle(); const communitiesHook = useCommunities(); const hasCommunityRail = communitiesHook.communities.length > 1; const addCommunityDialog = useAddCommunityDialogState(); @@ -328,6 +335,7 @@ export function AppShell() { markChannelRead, markChannelUnread, unreadChannelIds, + topLevelUnreadChannelIds, unreadChannelCounts, highPriorityUnreadChannelIds, unreadChannelNotificationCount, @@ -632,69 +640,15 @@ export function AppShell() { () => setIsCreateChannelOpen(true), [], ); - React.useLayoutEffect(() => { - if (settingsOpen) { - return; - } - - function handleKeyDown(event: KeyboardEvent) { - if (!hasPrimaryShortcutModifier(event) || event.altKey || event.repeat) { - return; - } - - // A focused surface may claim the shortcut first — e.g. the composer - // consumes ⌘K to open the link editor when text is selected. Its - // element-level handler runs before this window-level bubble listener - // and calls `preventDefault()`; respect that instead of also opening - // the global dialog. - if (event.defaultPrevented) { - return; - } - - const key = event.key.toLowerCase(); - if (key === "k" && !event.shiftKey) { - event.preventDefault(); - handleOpenSearch(); - return; - } - - if (key === "k" && event.shiftKey) { - event.preventDefault(); - handleOpenNewDm(); - return; - } - - if (key === "n" && event.shiftKey) { - event.preventDefault(); - handleOpenCreateChannel(); - return; - } - - if (key === "o" && event.shiftKey) { - event.preventDefault(); - handleOpenBrowseChannels(); - return; - } - - if (key === "a" && event.shiftKey) { - event.preventDefault(); - void goHome(); - return; - } - } - - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [ - handleOpenBrowseChannels, - handleOpenNewDm, - handleOpenCreateChannel, - handleOpenSearch, - goHome, + const handleGoHomeShortcut = React.useCallback(() => void goHome(), [goHome]); + useGlobalActionShortcuts({ settingsOpen, - ]); + onOpenSearch: handleOpenSearch, + onOpenNewDm: handleOpenNewDm, + onOpenCreateChannel: handleOpenCreateChannel, + onOpenBrowseChannels: handleOpenBrowseChannels, + onGoHome: handleGoHomeShortcut, + }); useSettingsShortcuts({ onClose: handleCloseSettings, onOpenSettings: handleOpenSettings, @@ -766,10 +720,15 @@ export function AppShell() { onSwitchCommunity={handleSwitchCommunity} onUpdateCommunity={communitiesHook.updateCommunity} communities={communitiesHook.communities} + variant={ + !settingsOpen && displayStyle === "developer" + ? "developer" + : "standard" + } /> ) : null} - {!settingsOpen ? ( + {!settingsOpen && displayStyle !== "developer" ? ( + ) : displayStyle === "developer" ? ( +
+ + + +
) : (
void; + onOpenNewDm: () => void; + onOpenCreateChannel: () => void; + onOpenBrowseChannels: () => void; + onGoHome: () => void; +}) { + React.useLayoutEffect(() => { + if (settingsOpen) { + return; + } + + function handleKeyDown(event: KeyboardEvent) { + if (!hasPrimaryShortcutModifier(event) || event.altKey || event.repeat) { + return; + } + + // A focused surface may claim the shortcut first — e.g. the composer + // consumes ⌘K to open the link editor when text is selected. Its + // element-level handler runs before this window-level bubble listener + // and calls `preventDefault()`; respect that instead of also opening + // the global dialog. + if (event.defaultPrevented) { + return; + } + + const key = event.key.toLowerCase(); + if (key === "k" && !event.shiftKey) { + event.preventDefault(); + onOpenSearch(); + return; + } + + if (key === "k" && event.shiftKey) { + event.preventDefault(); + onOpenNewDm(); + return; + } + + if (key === "n" && event.shiftKey) { + event.preventDefault(); + onOpenCreateChannel(); + return; + } + + if (key === "o" && event.shiftKey) { + event.preventDefault(); + onOpenBrowseChannels(); + return; + } + + if (key === "a" && event.shiftKey) { + event.preventDefault(); + onGoHome(); + return; + } + + if (key === "d" && event.shiftKey) { + event.preventDefault(); + toggleDisplayStyle(); + return; + } + } + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [ + settingsOpen, + onOpenSearch, + onOpenNewDm, + onOpenCreateChannel, + onOpenBrowseChannels, + onGoHome, + ]); +} diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts index 9003d0f5a5..626c7ddef1 100644 --- a/desktop/src/features/channels/hooks.ts +++ b/desktop/src/features/channels/hooks.ts @@ -36,6 +36,10 @@ import { readChannelSnapshot, writeChannelSnapshot, } from "@/features/channels/channelSnapshot"; +import { + applySubChannelRenames, + planSubChannelRenames, +} from "@/features/dev-mode/lib/subChannels"; export const channelsQueryKey = ["channels"] as const; const channelDetailQueryKey = (channelId: string) => @@ -187,6 +191,62 @@ function setChannelArchivedState( ); } +/** + * Patch one channel in the list + detail caches. Used for optimistic + * membership updates so join/leave/add/archive feel instant; the settle-time + * invalidate reconciles with the relay's answer. + */ +function patchChannelState( + queryClient: ReturnType, + channelId: string, + patch: (channel: Channel) => Channel, +) { + queryClient.setQueryData(channelsQueryKey, (current = []) => + sortChannels( + current.map((channel) => + channel.id === channelId ? patch(channel) : channel, + ), + ), + ); + + queryClient.setQueryData( + channelDetailQueryKey(channelId), + (current) => (current ? { ...current, ...patch(current) } : current), + ); +} + +/** + * Snapshot the caches an optimistic channel patch touches, for onError + * rollback. Restoring the full list is simpler and safer than un-applying + * the patch. + */ +function snapshotChannelState( + queryClient: ReturnType, + channelId: string, +) { + return { + channels: queryClient.getQueryData(channelsQueryKey), + detail: queryClient.getQueryData( + channelDetailQueryKey(channelId), + ), + }; +} + +function restoreChannelState( + queryClient: ReturnType, + channelId: string, + snapshot: ReturnType | undefined, +) { + if (!snapshot) return; + if (snapshot.channels) { + queryClient.setQueryData(channelsQueryKey, snapshot.channels); + } + queryClient.setQueryData( + channelDetailQueryKey(channelId), + snapshot.detail, + ); +} + export function useChannelsQuery(options?: { enabled?: boolean }) { const { activeCommunity } = useCommunities(); const relayUrl = activeCommunity?.relayUrl ?? null; @@ -341,12 +401,41 @@ export function useUpdateChannelMutation(channelId: string | null) { const queryClient = useQueryClient(); return useMutation({ - mutationFn: (input: Omit) => { + mutationFn: async (input: Omit) => { if (!channelId) { throw new Error("No channel selected."); } - return updateChannel({ ...input, channelId }); + const previousName = queryClient + .getQueryData(channelsQueryKey) + ?.find((channel) => channel.id === channelId)?.name; + const updated = await updateChannel({ ...input, channelId }); + + // A `parent--sub` channel is linked to its parent purely by name, so + // renaming a parent must rename every sub or the family falls apart. + // Best-effort, bounded concurrency — a parent can have hundreds. + if (previousName && previousName !== updated.name) { + const plan = planSubChannelRenames( + queryClient.getQueryData(channelsQueryKey) ?? [], + previousName, + updated.name, + ); + if (plan.length > 0) { + const { failed } = await applySubChannelRenames( + plan, + async (subChannelId, newName) => { + await updateChannel({ channelId: subChannelId, name: newName }); + }, + ); + if (failed.length > 0) { + console.warn( + `channel rename: ${failed.length} of ${plan.length} sub-channel(s) failed to follow "${previousName}" → "${updated.name}"`, + ); + } + } + } + + return updated; }, onMutate: () => ({ channelId }), onSuccess: (updatedChannel) => { @@ -427,12 +516,15 @@ export function useArchiveChannelMutation(channelId: string | null) { await archiveChannel(channelId); }, - onSuccess: () => { - if (!channelId) { - return; - } - + onMutate: () => { + if (!channelId) return undefined; + const snapshot = snapshotChannelState(queryClient, channelId); setChannelArchivedState(queryClient, channelId, new Date().toISOString()); + return snapshot; + }, + onError: (_error, _variables, snapshot) => { + if (!channelId) return; + restoreChannelState(queryClient, channelId, snapshot); }, onSettled: async () => { await invalidateChannelState(queryClient, channelId); @@ -517,6 +609,22 @@ export function useAddChannelMembersMutation(channelId: string | null) { return addChannelMembers({ ...rest, channelId: effectiveChannelId }); }, + onMutate: (variables) => { + const effectiveChannelId = variables?.channelId ?? channelId; + if (!effectiveChannelId) return undefined; + const snapshot = snapshotChannelState(queryClient, effectiveChannelId); + const added = variables.pubkeys.length; + patchChannelState(queryClient, effectiveChannelId, (channel) => ({ + ...channel, + memberCount: channel.memberCount + added, + })); + return snapshot; + }, + onError: (_error, variables, snapshot) => { + const effectiveChannelId = variables?.channelId ?? channelId; + if (!effectiveChannelId) return; + restoreChannelState(queryClient, effectiveChannelId, snapshot); + }, onSettled: async (_data, _err, variables) => { // Invalidate the effective channel (the one actually mutated) not the // live hook-closure channel, which may have changed mid-send. @@ -551,15 +659,38 @@ export function useJoinChannelMutation(channelId: string | null) { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async () => { - if (!channelId) { + // Accepts a per-call channel override so one hook instance can join + // arbitrary channels (e.g. palette search results). + // biome-ignore lint/suspicious/noConfusingVoidType: `| void` (not `| undefined`) is what lets existing no-arg `mutate()` callers compile. + mutationFn: async (variables: { channelId?: string } | void) => { + const effectiveChannelId = variables?.channelId ?? channelId; + if (!effectiveChannelId) { throw new Error("No channel selected."); } - await joinChannel(channelId); + await joinChannel(effectiveChannelId); }, - onSettled: async () => { - await invalidateChannelState(queryClient, channelId); + onMutate: (variables) => { + const effectiveChannelId = variables?.channelId ?? channelId; + if (!effectiveChannelId) return undefined; + const snapshot = snapshotChannelState(queryClient, effectiveChannelId); + patchChannelState(queryClient, effectiveChannelId, (channel) => ({ + ...channel, + isMember: true, + memberCount: channel.memberCount + 1, + })); + return snapshot; + }, + onError: (_error, variables, snapshot) => { + const effectiveChannelId = variables?.channelId ?? channelId; + if (!effectiveChannelId) return; + restoreChannelState(queryClient, effectiveChannelId, snapshot); + }, + onSettled: async (_data, _error, variables) => { + await invalidateChannelState( + queryClient, + variables?.channelId ?? channelId, + ); }, }); } @@ -575,6 +706,20 @@ export function useLeaveChannelMutation(channelId: string | null) { await leaveChannel(channelId); }, + onMutate: () => { + if (!channelId) return undefined; + const snapshot = snapshotChannelState(queryClient, channelId); + patchChannelState(queryClient, channelId, (channel) => ({ + ...channel, + isMember: false, + memberCount: Math.max(0, channel.memberCount - 1), + })); + return snapshot; + }, + onError: (_error, _variables, snapshot) => { + if (!channelId) return; + restoreChannelState(queryClient, channelId, snapshot); + }, onSettled: async () => { await invalidateChannelState(queryClient, channelId); }, diff --git a/desktop/src/features/channels/unreadChannelCounts.ts b/desktop/src/features/channels/unreadChannelCounts.ts index cb33c62975..274af64b38 100644 --- a/desktop/src/features/channels/unreadChannelCounts.ts +++ b/desktop/src/features/channels/unreadChannelCounts.ts @@ -106,6 +106,25 @@ export function countUnreadAppBadgeObservedEvents( return count; } +/** + * Unread channel-level posts only (rootId === null) — thread replies never + * count. Dev mode's channel list uses this so a channel reads as unread only + * when a top-level message is unread, not when unopened threads have replies. + */ +export function countUnreadTopLevelObservedEvents( + eventsById: ReadonlyMap | undefined, + getReadAt: (event: ObservedUnreadEvent) => number | null, +): number { + if (!eventsById) return 0; + let count = 0; + for (const event of eventsById.values()) { + if (event.rootId !== null) continue; + const readAt = getReadAt(event); + if (readAt === null || event.createdAt > readAt) count += 1; + } + return count; +} + export function countUnreadHighPriorityObservedEvents( eventsById: ReadonlyMap | undefined, getReadAt: (event: ObservedUnreadEvent) => number | null, diff --git a/desktop/src/features/channels/unreadReadMarker.test.mjs b/desktop/src/features/channels/unreadReadMarker.test.mjs index e05e2ba0b2..fb09b0fb12 100644 --- a/desktop/src/features/channels/unreadReadMarker.test.mjs +++ b/desktop/src/features/channels/unreadReadMarker.test.mjs @@ -7,15 +7,16 @@ import { countUnreadBadgeObservedEvents, countUnreadHighPriorityObservedEvents, countUnreadObservedEvents, + countUnreadTopLevelObservedEvents, observedUnreadEventReadAt, recordObservedUnreadEvent, } from "./unreadChannelCounts.ts"; +import { addThreadActivityItems } from "./useUnreadChannels.ts"; import { - addThreadActivityItems, channelCatchUpEventKinds, resolveChannelReadMarker, resolveObservedUnreadRootId, -} from "./useUnreadChannels.ts"; +} from "./unreadReadMarker.ts"; import { isChannelUnreadTriggerKind, withChannelTagFallback, @@ -333,6 +334,38 @@ test("sidebarPipeline_openedReplyMessageMarkerClearsDelayedReplay", () => { ); }); +test("countUnreadTopLevelObservedEvents_ignoresThreadReplies", () => { + // Channel marker covers the newest top-level post (300). Two thread replies + // are strictly newer and unread, but a dev-mode channel dot only tracks + // channel-level posts, so the top-level count must be zero. + const events = new Map([ + ["reply-a", observed("reply-a", 400, "root-a")], + ["reply-b", observed("reply-b", 500, "root-b")], + ]); + const getReadAt = readAtFor(300, new Map()); + + assert.equal(countUnreadObservedEvents(events, getReadAt), 2); + assert.equal(countUnreadTopLevelObservedEvents(events, getReadAt), 0); +}); + +test("countUnreadTopLevelObservedEvents_countsUnreadChannelLevelPosts", () => { + const events = new Map([ + ["post", observed("post", 400, null)], + ["reply", observed("reply", 500, "root-a")], + ]); + + // Marker behind the top-level post: it counts; the newer reply never does. + assert.equal( + countUnreadTopLevelObservedEvents(events, readAtFor(300, new Map())), + 1, + ); + // Marker at the top-level post: read. + assert.equal( + countUnreadTopLevelObservedEvents(events, readAtFor(400, new Map())), + 0, + ); +}); + test("latestObservedEvent_latestThreadReadDoesNotImplyChannelClear", () => { const events = new Map([ ["older", observed("older", 400, "root-older")], diff --git a/desktop/src/features/channels/unreadReadMarker.ts b/desktop/src/features/channels/unreadReadMarker.ts new file mode 100644 index 0000000000..b6b6a62777 --- /dev/null +++ b/desktop/src/features/channels/unreadReadMarker.ts @@ -0,0 +1,57 @@ +import { + getThreadReference, + isBroadcastReply, +} from "@/features/messages/lib/threading"; +import type { Channel } from "@/shared/api/types"; +import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds"; +import { DM_NOTIFIABLE_EVENT_KINDS } from "./isDmNotifiableKind"; + +export function channelCatchUpEventKinds( + channelType: Channel["channelType"] | undefined, +) { + return channelType === "dm" + ? DM_NOTIFIABLE_EVENT_KINDS + : CHANNEL_MESSAGE_EVENT_KINDS; +} + +function parseTimestamp(value: string | null | undefined) { + if (!value) { + return null; + } + + const timestamp = Date.parse(value); + return Number.isNaN(timestamp) ? null : timestamp; +} + +function toUnixSeconds(isoOrMs: string | null | undefined): number | null { + const ms = parseTimestamp(isoOrMs); + return ms === null ? null : Math.floor(ms / 1_000); +} + +// Resolve where the read marker should land when a channel is marked read. +// Folds the caller's timeline position together with the newest event this +// client has observed live (`observedLatest`), so an explicit "mark read" still +// covers messages that arrived faster than channel metadata — this fold is +// load-bearing for the Esc shortcut, sidebar mark-read, and empty-channel open, +// all of which pass a null/stale caller value. `clearObserved` reports whether +// the resulting marker covers the observed timestamp, signalling the caller to +// drop its observed refs so the unread memo sees `latest === undefined` until a +// genuinely newer event arrives. +export function resolveChannelReadMarker( + callerReadAt: string | null | undefined, + observedLatest: number | undefined, +): { markAt: number | null; clearObserved: boolean } { + const callerUnix = toUnixSeconds(callerReadAt); + const markAt = Math.max(callerUnix ?? 0, observedLatest ?? 0) || null; + return { + markAt, + clearObserved: + markAt !== null && + observedLatest !== undefined && + observedLatest <= markAt, + }; +} + +export function resolveObservedUnreadRootId(tags: string[][]): string | null { + return isBroadcastReply(tags) ? null : getThreadReference(tags).rootId; +} diff --git a/desktop/src/features/channels/useUnreadChannels.ts b/desktop/src/features/channels/useUnreadChannels.ts index a2376f9b7b..e7f5e372b3 100644 --- a/desktop/src/features/channels/useUnreadChannels.ts +++ b/desktop/src/features/channels/useUnreadChannels.ts @@ -9,12 +9,18 @@ import { countUnreadBadgeObservedEvents, countUnreadHighPriorityObservedEvents, countUnreadObservedEvents, + countUnreadTopLevelObservedEvents, makeObservedUnreadEvent, mapsEqual, observedUnreadEventReadAt, recordObservedUnreadEvent, type ObservedUnreadEvent, } from "@/features/channels/unreadChannelCounts"; +import { + channelCatchUpEventKinds, + resolveChannelReadMarker, + resolveObservedUnreadRootId, +} from "@/features/channels/unreadReadMarker"; import { useReadState } from "@/features/channels/readState/useReadState"; import { makeRootIdStore } from "@/features/channels/unreadRootIdStore"; import { @@ -32,9 +38,7 @@ import { } from "@/features/notifications/lib/shouldNotify"; import type { RelayClient } from "@/shared/api/relayClientSession"; import type { Channel, RelayEvent } from "@/shared/api/types"; -import { CHANNEL_MESSAGE_EVENT_KINDS } from "@/shared/constants/kinds"; import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage"; -import { DM_NOTIFIABLE_EVENT_KINDS } from "./isDmNotifiableKind"; import { activityScopeKey, addThreadActivityItems, @@ -67,14 +71,6 @@ type UseUnreadChannelsOptions = UseLiveChannelUpdatesOptions & { // per-channel limit elsewhere in the app. const CATCH_UP_LIMIT = 1000; -export function channelCatchUpEventKinds( - channelType: Channel["channelType"] | undefined, -) { - return channelType === "dm" - ? DM_NOTIFIABLE_EVENT_KINDS - : CHANNEL_MESSAGE_EVENT_KINDS; -} - const participationStore = makeRootIdStore("buzz-thread-participation.v1"); const authoredStore = makeRootIdStore("buzz-thread-authored.v1"); // Thread roots where an external message @-mentioned the current user. The @@ -83,48 +79,6 @@ const authoredStore = makeRootIdStore("buzz-thread-authored.v1"); const mentionedStore = makeRootIdStore("buzz-thread-mentioned.v1"); const mutedStore = makeRootIdStore("buzz-thread-muted.v1"); -function parseTimestamp(value: string | null | undefined) { - if (!value) { - return null; - } - - const timestamp = Date.parse(value); - return Number.isNaN(timestamp) ? null : timestamp; -} - -function toUnixSeconds(isoOrMs: string | null | undefined): number | null { - const ms = parseTimestamp(isoOrMs); - return ms === null ? null : Math.floor(ms / 1_000); -} - -// Resolve where the read marker should land when a channel is marked read. -// Folds the caller's timeline position together with the newest event this -// client has observed live (`observedLatest`), so an explicit "mark read" still -// covers messages that arrived faster than channel metadata — this fold is -// load-bearing for the Esc shortcut, sidebar mark-read, and empty-channel open, -// all of which pass a null/stale caller value. `clearObserved` reports whether -// the resulting marker covers the observed timestamp, signalling the caller to -// drop its observed refs so the unread memo sees `latest === undefined` until a -// genuinely newer event arrives. -export function resolveChannelReadMarker( - callerReadAt: string | null | undefined, - observedLatest: number | undefined, -): { markAt: number | null; clearObserved: boolean } { - const callerUnix = toUnixSeconds(callerReadAt); - const markAt = Math.max(callerUnix ?? 0, observedLatest ?? 0) || null; - return { - markAt, - clearObserved: - markAt !== null && - observedLatest !== undefined && - observedLatest <= markAt, - }; -} - -export function resolveObservedUnreadRootId(tags: string[][]): string | null { - return isBroadcastReply(tags) ? null : getThreadReference(tags).rootId; -} - function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { if (a.size !== b.size) return false; for (const item of a) { @@ -830,6 +784,7 @@ export function useUnreadChannels( if (!isReadStateReady) { return { unreadChannelIds: new Set(), + topLevelUnreadChannelIds: new Set(), highPriorityUnreadChannelIds: new Set(), unreadChannelCounts: new Map(), unreadChannelNotificationCount: 0, @@ -837,6 +792,7 @@ export function useUnreadChannels( } const unread = new Set(); + const topLevelUnread = new Set(); const highPriority = new Set(); const counts = new Map(); let unreadChannelNotificationCount = 0; @@ -847,6 +803,7 @@ export function useUnreadChannels( if (Object.hasOwn(forcedUnreadRef.current, channel.id)) { // Forced-unread is dot tier only — not high-priority. unread.add(channel.id); + topLevelUnread.add(channel.id); counts.set(channel.id, 1); unreadChannelNotificationCount += 1; continue; @@ -873,6 +830,17 @@ export function useUnreadChannels( if (unreadCount === 0) continue; unread.add(channel.id); + // Channels whose unread includes a channel-level post — surfaces + // that clear by opening the channel (vs thread replies, which clear + // through thread read markers) distinguish the two via this set. + if ( + countUnreadTopLevelObservedEvents( + observedEvents, + readAtForObservedEvent, + ) > 0 + ) { + topLevelUnread.add(channel.id); + } const badgeCount = countUnreadBadgeObservedEvents( observedEvents, readAtForObservedEvent, @@ -900,6 +868,7 @@ export function useUnreadChannels( return { unreadChannelIds: unread, + topLevelUnreadChannelIds: topLevelUnread, highPriorityUnreadChannelIds: highPriority, unreadChannelCounts: counts, unreadChannelNotificationCount, @@ -917,6 +886,7 @@ export function useUnreadChannels( // Stabilize Set references: only replace when contents actually change, // so downstream memos don't re-run on every render when sets are equal. const prevUnreadRef = React.useRef>(new Set()); + const prevTopLevelUnreadRef = React.useRef>(new Set()); const prevHighPriorityRef = React.useRef>(new Set()); const prevUnreadCountsRef = React.useRef>( new Map(), @@ -930,6 +900,14 @@ export function useUnreadChannels( : rawUnread.unreadChannelIds; prevUnreadRef.current = unreadChannelIds; + const topLevelUnreadChannelIds = setsEqual( + rawUnread.topLevelUnreadChannelIds, + prevTopLevelUnreadRef.current, + ) + ? prevTopLevelUnreadRef.current + : rawUnread.topLevelUnreadChannelIds; + prevTopLevelUnreadRef.current = topLevelUnreadChannelIds; + const highPriorityUnreadChannelIds = setsEqual( rawUnread.highPriorityUnreadChannelIds, prevHighPriorityRef.current, @@ -992,6 +970,7 @@ export function useUnreadChannels( return { unreadChannelIds, + topLevelUnreadChannelIds, unreadChannelCounts, highPriorityUnreadChannelIds, unreadChannelNotificationCount, diff --git a/desktop/src/features/dev-mode/lib/authorColors.ts b/desktop/src/features/dev-mode/lib/authorColors.ts new file mode 100644 index 0000000000..36ff4056dc --- /dev/null +++ b/desktop/src/features/dev-mode/lib/authorColors.ts @@ -0,0 +1,124 @@ +import * as React from "react"; + +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * Deterministic per-author name colors for developer mode, with optional + * per-pubkey hex overrides persisted in localStorage (set via the command + * palette). A synced profile color field would need schema/event changes, so + * overrides are device-local for now. + */ + +/** Mid-lightness hues that stay readable on both dark and light themes. */ +export const AUTHOR_COLOR_PALETTE = [ + "#e5484d", // red + "#f76b15", // orange + "#ffb224", // amber + "#46a758", // green + "#12a594", // teal + "#0091ff", // blue + "#3e63dd", // indigo + "#6e56cf", // violet + "#8e4ec6", // purple + "#e93d82", // pink + "#05a2c2", // cyan + "#978365", // bronze +] as const; + +const STORAGE_KEY = "buzz.devMode.nameColors"; +const HEX_COLOR_RE = /^#[0-9a-f]{6}$/; + +const listeners = new Set<() => void>(); + +let overrides = readStoredOverrides(); + +function readStoredOverrides(): Record { + try { + const raw = globalThis.localStorage?.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null) return {}; + const valid: Record = {}; + for (const [pubkey, color] of Object.entries(parsed)) { + if (typeof color === "string" && HEX_COLOR_RE.test(color)) { + valid[pubkey] = color; + } + } + return valid; + } catch { + return {}; + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): Record { + return overrides; +} + +export function normalizeHexColor(value: string): string | null { + const trimmed = value.trim().toLowerCase(); + const withHash = trimmed.startsWith("#") ? trimmed : `#${trimmed}`; + const expanded = /^#[0-9a-f]{3}$/.test(withHash) + ? `#${withHash[1]}${withHash[1]}${withHash[2]}${withHash[2]}${withHash[3]}${withHash[3]}` + : withHash; + return HEX_COLOR_RE.test(expanded) ? expanded : null; +} + +export function setNameColorOverride( + pubkey: string, + color: string | null, +): void { + const key = normalizePubkey(pubkey); + const next = { ...overrides }; + if (color === null) { + delete next[key]; + } else { + const normalized = normalizeHexColor(color); + if (!normalized) return; + next[key] = normalized; + } + overrides = next; + try { + globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // Persistence is best-effort; the in-memory value still applies. + } + for (const listener of listeners) { + listener(); + } +} + +/** FNV-1a — stable across sessions, unlike per-run hashing. */ +function hashPubkey(pubkey: string): number { + let hash = 0x811c9dc5; + for (let index = 0; index < pubkey.length; index += 1) { + hash ^= pubkey.charCodeAt(index); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +export function defaultAuthorColor(pubkey: string): string { + const key = normalizePubkey(pubkey); + return AUTHOR_COLOR_PALETTE[hashPubkey(key) % AUTHOR_COLOR_PALETTE.length]; +} + +export type AuthorColorResolver = (pubkey: string) => string; + +export function useAuthorColorResolver(): AuthorColorResolver { + const current = React.useSyncExternalStore( + subscribe, + getSnapshot, + getSnapshot, + ); + return React.useCallback( + (pubkey) => current[normalizePubkey(pubkey)] ?? defaultAuthorColor(pubkey), + [current], + ); +} diff --git a/desktop/src/features/dev-mode/lib/channelNaming.ts b/desktop/src/features/dev-mode/lib/channelNaming.ts new file mode 100644 index 0000000000..10dc5bf4a8 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/channelNaming.ts @@ -0,0 +1,103 @@ +import { generateAgentCompletion } from "@/shared/api/agentCompletion"; +import { sanitizeChannelName } from "@/features/dev-mode/lib/sessionNaming"; +import { meshNodeStatus } from "@/shared/api/tauriMesh"; + +/** + * Ask an LLM to title a session channel from its first prompt. + * + * Prefers a one-shot completion through a managed agent (the agent tagged in + * the composer, or any configured managed agent) via `buzz-acp complete` — + * a private subprocess exchange that never appears in a channel. Falls back + * to the mesh LLM node's OpenAI-compatible endpoint when one is running. + * Returns null (caller keeps its placeholder name) when neither produces a + * usable title — callers must not block channel creation on this. + */ + +const TITLE_TIMEOUT_MS = 8_000; + +const TITLE_INSTRUCTION = + "Reply with only a 2-5 word kebab-case channel name summarizing the " + + "following chat prompt. No punctuation besides hyphens, no quotes, no " + + "explanation — just the name."; + +/** Reduce raw LLM output to a valid channel name, or null if unusable. */ +function toChannelName(raw: string): string | null { + // Keep only the last non-empty line — chatty agents sometimes preface the + // answer even when told not to. + const lastLine = raw + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .at(-1); + if (!lastLine) return null; + const name = sanitizeChannelName(lastLine.replaceAll("-", " ")); + return name.length >= 3 ? name : null; +} + +async function agentTitle( + prompt: string, + agentPubkey: string, +): Promise { + try { + const text = await generateAgentCompletion({ + pubkey: agentPubkey, + prompt: `${TITLE_INSTRUCTION}\n\nPrompt:\n${prompt.slice(0, 500)}`, + systemPrompt: TITLE_INSTRUCTION, + }); + return toChannelName(text); + } catch { + return null; + } +} + +async function meshTitle(prompt: string): Promise { + let apiBaseUrl: string; + let modelId: string; + try { + const status = await meshNodeStatus(); + if (status.state !== "running" || !status.apiBaseUrl || !status.modelId) { + return null; + } + apiBaseUrl = status.apiBaseUrl; + modelId = status.modelId; + } catch { + return null; + } + + try { + const response = await fetch(`${apiBaseUrl}/chat/completions`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: modelId, + messages: [ + { role: "system", content: TITLE_INSTRUCTION }, + { role: "user", content: prompt.slice(0, 500) }, + ], + max_tokens: 24, + temperature: 0.2, + }), + signal: AbortSignal.timeout(TITLE_TIMEOUT_MS), + }); + if (!response.ok) return null; + const payload: unknown = await response.json(); + const content = ( + payload as { choices?: { message?: { content?: unknown } }[] } + ).choices?.[0]?.message?.content; + if (typeof content !== "string") return null; + return toChannelName(content); + } catch { + return null; + } +} + +export async function generateChannelTitle( + prompt: string, + agentPubkey?: string | null, +): Promise { + if (agentPubkey) { + const title = await agentTitle(prompt, agentPubkey); + if (title) return title; + } + return meshTitle(prompt); +} diff --git a/desktop/src/features/dev-mode/lib/channelRefs.tsx b/desktop/src/features/dev-mode/lib/channelRefs.tsx new file mode 100644 index 0000000000..a770a53ef1 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/channelRefs.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; + +/** + * Channel references for developer mode: `#channel-name` in message text is + * the wire format the standard UI and mobile already render as channel links, + * so dev mode reads and writes the same plain text. The shell provides the + * known channels and its open handler once; composers use them for `#` + * autocomplete and message rows use them to render clickable references. + */ + +export type ChannelRef = { id: string; name: string }; + +type ChannelRefsValue = { + channels: ChannelRef[]; + openChannel: (channelId: string) => void; +}; + +const ChannelRefsContext = React.createContext({ + channels: [], + openChannel: () => {}, +}); + +export function DevChannelRefsProvider({ + channels, + openChannel, + children, +}: ChannelRefsValue & { children: React.ReactNode }) { + const value = React.useMemo( + () => ({ channels, openChannel }), + [channels, openChannel], + ); + return ( + + {children} + + ); +} + +export function useChannelRefs(): ChannelRefsValue { + return React.useContext(ChannelRefsContext); +} diff --git a/desktop/src/features/dev-mode/lib/composerModePreference.ts b/desktop/src/features/dev-mode/lib/composerModePreference.ts new file mode 100644 index 0000000000..20789dbf0f --- /dev/null +++ b/desktop/src/features/dev-mode/lib/composerModePreference.ts @@ -0,0 +1,24 @@ +/** + * Remembers the composer's last-used target (an agent's pubkey, or "chat") + * across sessions, so the composer keeps pointing at the agent the user last + * talked to instead of resetting to the alphabetical default. Device-level + * preference, persisted in localStorage. + */ + +const STORAGE_KEY = "buzz.devMode.lastComposerMode"; + +export function loadLastComposerModeKey(): string | null { + try { + return globalThis.localStorage?.getItem(STORAGE_KEY) ?? null; + } catch { + return null; + } +} + +export function storeLastComposerModeKey(key: string): void { + try { + globalThis.localStorage?.setItem(STORAGE_KEY, key); + } catch { + // Persistence is best-effort; the in-memory selection still applies. + } +} diff --git a/desktop/src/features/dev-mode/lib/copyLinkUrls.ts b/desktop/src/features/dev-mode/lib/copyLinkUrls.ts new file mode 100644 index 0000000000..1f244eda93 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/copyLinkUrls.ts @@ -0,0 +1,43 @@ +/** + * Transcript links render a fetched title ("joah in #general") instead of + * their URL, so a plain copy would lose the destination. This handler + * rewrites the plain-text clipboard so every DevLink serializes as its + * href, while text/html keeps the pretty labels for rich paste targets. + * + * Returns true when it handled the event (selection contained a DevLink). + */ +export function copySelectionWithLinkUrls(event: ClipboardEvent): boolean { + const selection = window.getSelection(); + if (!selection || selection.isCollapsed || selection.rangeCount === 0) { + return false; + } + + const container = document.createElement("div"); + for (let i = 0; i < selection.rangeCount; i += 1) { + container.appendChild(selection.getRangeAt(i).cloneContents()); + } + if (!container.querySelector("a[data-dev-link]")) { + return false; + } + + const html = container.innerHTML; + + for (const anchor of container.querySelectorAll("a[data-dev-link]")) { + const href = anchor.getAttribute("href"); + if (href) anchor.replaceWith(document.createTextNode(href)); + } + + // innerText needs layout to turn block boundaries into newlines, so the + // clone briefly joins the document off-screen. + container.style.position = "fixed"; + container.style.left = "-99999px"; + container.style.top = "0"; + document.body.appendChild(container); + const text = container.innerText; + container.remove(); + + event.clipboardData?.setData("text/plain", text); + event.clipboardData?.setData("text/html", html); + event.preventDefault(); + return true; +} diff --git a/desktop/src/features/dev-mode/lib/devMarkdown.test.mjs b/desktop/src/features/dev-mode/lib/devMarkdown.test.mjs new file mode 100644 index 0000000000..b7ecd36cfc --- /dev/null +++ b/desktop/src/features/dev-mode/lib/devMarkdown.test.mjs @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { renderDevMarkdown } from "./devMarkdown.tsx"; + +function elements(nodes, type) { + return nodes.filter( + (node) => typeof node === "object" && node !== null && node.type === type, + ); +} + +function textOf(node) { + if (node === null || node === undefined) return ""; + if (typeof node === "string") return node; + if (Array.isArray(node)) return node.map(textOf).join(""); + if (typeof node === "object") return textOf(node.props?.children); + return String(node); +} + +test("plainText_staysOnePreWrapParagraph", () => { + const nodes = renderDevMarkdown("hello\nworld"); + assert.equal(nodes.length, 1); + assert.equal(nodes[0].type, "p"); + assert.equal(textOf(nodes[0]), "hello\nworld"); +}); + +test("blankLine_splitsParagraphs", () => { + const nodes = renderDevMarkdown("one\n\ntwo"); + assert.equal(elements(nodes, "p").length, 2); +}); + +test("fencedCode_rendersPreWithoutFenceLines", () => { + const nodes = renderDevMarkdown("before\n```ts\nconst a = 1;\n```\nafter"); + const [pre] = elements(nodes, "pre"); + assert.ok(pre); + assert.equal(textOf(pre), "const a = 1;"); + assert.equal(elements(nodes, "p").length, 2); +}); + +test("unterminatedFence_capturesRestOfMessage", () => { + const nodes = renderDevMarkdown("```\nline1\nline2"); + const [pre] = elements(nodes, "pre"); + assert.equal(textOf(pre), "line1\nline2"); +}); + +test("heading_rendersBoldWithoutHashes", () => { + const nodes = renderDevMarkdown("## Findings"); + assert.equal(nodes.length, 1); + assert.equal(textOf(nodes[0]), "Findings"); + assert.ok(nodes[0].props.className.includes("font-bold")); +}); + +test("bulletList_rendersMarkerAndInlineContent", () => { + const nodes = renderDevMarkdown("- **Saved rule state** — conforms"); + assert.equal(nodes.length, 1); + const [marker, body] = nodes[0].props.children; + assert.equal(textOf(marker), "•"); + assert.equal(textOf(body), "Saved rule state — conforms"); +}); + +test("numberedList_keepsNumbers", () => { + const nodes = renderDevMarkdown("1. first\n2. second"); + assert.equal(nodes.length, 2); + assert.equal(textOf(nodes[0].props.children[0]), "1."); + assert.equal(textOf(nodes[1].props.children[0]), "2."); +}); + +test("nestedListItem_indents", () => { + const nodes = renderDevMarkdown("- top\n - nested"); + assert.equal(nodes[1].props.style.paddingLeft, "2ch"); +}); + +test("blockquote_groupsConsecutiveLines", () => { + const nodes = renderDevMarkdown("> one\n> two\nplain"); + const [quote] = elements(nodes, "blockquote"); + assert.equal(textOf(quote), "one\ntwo"); + assert.equal(elements(nodes, "p").length, 1); +}); + +test("horizontalRule_rendersDivider", () => { + const nodes = renderDevMarkdown("above\n---\nbelow"); + const divider = nodes.find( + (node) => + typeof node === "object" && node.props?.className?.includes("border-t"), + ); + assert.ok(divider); +}); + +test("hyphenListItem_isNotAHorizontalRule", () => { + const nodes = renderDevMarkdown("- item"); + assert.equal(textOf(nodes[0].props.children[0]), "•"); +}); diff --git a/desktop/src/features/dev-mode/lib/devMarkdown.tsx b/desktop/src/features/dev-mode/lib/devMarkdown.tsx new file mode 100644 index 0000000000..c45986c8a4 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/devMarkdown.tsx @@ -0,0 +1,199 @@ +import type * as React from "react"; + +import { + renderHighlightedContent, + type ChannelRefOptions, + type MentionStyle, +} from "@/features/dev-mode/lib/highlightContent"; +import { cn } from "@/shared/lib/cn"; +import { Markdown } from "@/shared/ui/markdown"; +import type { ImetaLookup } from "@/shared/ui/markdown/types"; + +/** + * Block-level markdown for developer-mode transcripts, layered over the + * span-based inline highlighter: fenced code blocks, headings, bullet and + * numbered lists, blockquotes, and horizontal rules. Everything renders as + * React nodes — never HTML — and keeps the terminal aesthetic (monospace, + * square corners). Anything unrecognized stays a pre-wrap paragraph, so + * plain human chat renders exactly as typed. + */ + +const FENCE_RE = /^\s{0,3}```/; +const HEADING_RE = /^(#{1,6})\s+(.+)$/; +const LIST_ITEM_RE = /^(\s*)(?:([-*+])|(\d{1,3})[.)])\s+(.+)$/; +const HR_RE = /^ {0,3}([-*_])\s*(?:\1\s*){2,}$/; +const QUOTE_RE = /^ {0,3}>\s?(.*)$/; +/** A standalone `![alt](url)` line — the shape `buildOutgoingMessage` emits + * for image/video attachments (URLs are paren- and space-free). */ +const MEDIA_LINE_RE = /^!\[([^\]]*)\]\((\S+)\)\s*$/; + +/** + * One attached image or video in a dev-mode transcript, rendered through the + * standard `Markdown` component so developer mode inherits the lightbox, + * context menus, video controls, and relay URL handling. + */ +function DevMediaBlock({ + line, + imetaByUrl, +}: { + line: string; + imetaByUrl: ImetaLookup | undefined; +}) { + return ( +
+ +
+ ); +} + +export function renderDevMarkdown( + content: string, + mentions: MentionStyle[] = [], + channelRefs?: ChannelRefOptions, + imetaByUrl?: ImetaLookup, +): React.ReactNode[] { + const inline = (text: string) => + renderHighlightedContent(text, mentions, channelRefs); + + const lines = content.split("\n"); + const nodes: React.ReactNode[] = []; + let paragraph: string[] = []; + let quote: string[] = []; + + const flushParagraph = () => { + if (paragraph.length === 0) return; + nodes.push( +

+ {inline(paragraph.join("\n"))} +

, + ); + paragraph = []; + }; + + const flushQuote = () => { + if (quote.length === 0) return; + nodes.push( +
+ {inline(quote.join("\n"))} +
, + ); + quote = []; + }; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + + if (FENCE_RE.test(line)) { + flushParagraph(); + flushQuote(); + const code: string[] = []; + i += 1; + while (i < lines.length && !FENCE_RE.test(lines[i])) { + code.push(lines[i]); + i += 1; + } + i += 1; // Closing fence (or end of message on an unterminated fence). + nodes.push( +
+          {code.join("\n")}
+        
, + ); + continue; + } + + const quoted = QUOTE_RE.exec(line); + if (quoted) { + flushParagraph(); + quote.push(quoted[1]); + i += 1; + continue; + } + flushQuote(); + + if (line.trim() === "") { + flushParagraph(); + i += 1; + continue; + } + + if (HR_RE.test(line)) { + flushParagraph(); + nodes.push( +
, + ); + i += 1; + continue; + } + + const heading = HEADING_RE.exec(line); + if (heading) { + flushParagraph(); + const level = heading[1].length; + nodes.push( +
+ {inline(heading[2])} +
, + ); + i += 1; + continue; + } + + const media = MEDIA_LINE_RE.exec(line.trim()); + if (media) { + flushParagraph(); + nodes.push( + , + ); + i += 1; + continue; + } + + const item = LIST_ITEM_RE.exec(line); + if (item) { + flushParagraph(); + const [, indent, bullet, number, rest] = item; + nodes.push( +
+ + {bullet ? "•" : `${number}.`} + + + {inline(rest)} + +
, + ); + i += 1; + continue; + } + + paragraph.push(line); + i += 1; + } + flushParagraph(); + flushQuote(); + return nodes; +} diff --git a/desktop/src/features/dev-mode/lib/displayStylePreference.ts b/desktop/src/features/dev-mode/lib/displayStylePreference.ts new file mode 100644 index 0000000000..23bf303d84 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/displayStylePreference.ts @@ -0,0 +1,78 @@ +import * as React from "react"; + +/** + * App-wide display style. + * + * - `standard` — the default sidebar + channel pane layout. + * - `developer` — a prompt-first, terminal-style surface: one composer that + * spawns a channel per prompt with an agent tagged (Tab cycles the target + * agent), plus keyboard-driven session navigation. + * + * Persisted in localStorage. Device-level UI preference, not community-scoped. + */ +export type DisplayStyle = "standard" | "developer"; + +const STORAGE_KEY = "buzz.displayStyle"; + +const DEFAULT_DISPLAY_STYLE: DisplayStyle = "standard"; + +const listeners = new Set<() => void>(); + +let displayStyle = readStoredDisplayStyle(); + +function parseDisplayStyle(value: string | null | undefined): DisplayStyle { + return value === "standard" || value === "developer" + ? value + : DEFAULT_DISPLAY_STYLE; +} + +function readStoredDisplayStyle(): DisplayStyle { + try { + return parseDisplayStyle(globalThis.localStorage?.getItem(STORAGE_KEY)); + } catch { + return DEFAULT_DISPLAY_STYLE; + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): DisplayStyle { + return displayStyle; +} + +function getServerSnapshot(): DisplayStyle { + return DEFAULT_DISPLAY_STYLE; +} + +/** Read the persisted display style outside of React. */ +export function getDisplayStyle(): DisplayStyle { + return displayStyle; +} + +/** Update the display style and notify all subscribed components. */ +export function setDisplayStyle(style: DisplayStyle): void { + displayStyle = style; + + try { + globalThis.localStorage?.setItem(STORAGE_KEY, style); + } catch { + // Persistence is best-effort; the in-memory value still applies. + } + + for (const listener of listeners) { + listener(); + } +} + +export function toggleDisplayStyle(): void { + setDisplayStyle(displayStyle === "developer" ? "standard" : "developer"); +} + +export function useDisplayStyle(): DisplayStyle { + return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); +} diff --git a/desktop/src/features/dev-mode/lib/highlightContent.test.mjs b/desktop/src/features/dev-mode/lib/highlightContent.test.mjs new file mode 100644 index 0000000000..6460cf88a7 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/highlightContent.test.mjs @@ -0,0 +1,180 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + matchLeadingMention, + renderHighlightedContent, +} from "./highlightContent.tsx"; + +const channels = [ + { id: "c1", name: "fix login bug" }, + { id: "c2", name: "fix login bug in prod" }, + { id: "c3", name: "deploys" }, +]; + +function channelRefOpts(onOpen = () => {}) { + return { channels, onOpen }; +} + +function buttons(nodes) { + return nodes.filter( + (node) => + typeof node === "object" && node !== null && node.type === "button", + ); +} + +test("knownChannelRef_rendersClickableButton", () => { + let opened = null; + const nodes = renderHighlightedContent( + "see #deploys for status", + [], + channelRefOpts((id) => { + opened = id; + }), + ); + const [button] = buttons(nodes); + assert.ok(button, "expected a button node for the channel ref"); + assert.equal(button.props.children, "#deploys"); + button.props.onClick(); + assert.equal(opened, "c3"); +}); + +test("unknownChannelRef_staysPlainText", () => { + const nodes = renderHighlightedContent( + "priority #1 for today", + [], + channelRefOpts(), + ); + assert.equal(buttons(nodes).length, 0); + assert.ok(nodes.join("").includes("#1")); +}); + +test("channelRef_withoutOptions_staysPlainText", () => { + const nodes = renderHighlightedContent("see #deploys"); + assert.equal(buttons(nodes).length, 0); +}); + +test("channelRef_matchesLongestNameWithSpaces", () => { + const nodes = renderHighlightedContent( + "tracking #fix login bug in prod now", + [], + channelRefOpts(), + ); + const [button] = buttons(nodes); + assert.ok(button); + assert.equal(button.props.children, "#fix login bug in prod"); + // Remaining prose after the ref is preserved. + const tail = nodes[nodes.indexOf(button) + 1]; + assert.equal(tail, " now"); +}); + +test("channelRef_matchIsCaseInsensitive", () => { + const nodes = renderHighlightedContent("see #Deploys", [], channelRefOpts()); + const [button] = buttons(nodes); + assert.ok(button); + assert.equal(button.props.children, "#Deploys"); +}); + +test("channelRef_requiresBoundaryAfterName", () => { + // "#deploysX" is not the known channel "deploys". + const nodes = renderHighlightedContent("see #deploysX", [], channelRefOpts()); + assert.equal(buttons(nodes).length, 0); +}); + +function findByType(nodes, type) { + return nodes.find( + (node) => typeof node === "object" && node !== null && node.type === type, + ); +} + +test("boldMarkdown_rendersStrongWithoutMarkers", () => { + const nodes = renderHighlightedContent("**Verified (independently)** — ok"); + const strong = findByType(nodes, "strong"); + assert.ok(strong); + assert.deepEqual(strong.props.children, ["Verified (independently)"]); + assert.ok(nodes.includes(" — ok")); +}); + +test("italicMarkdown_rendersEm", () => { + const star = findByType( + renderHighlightedContent("really *important* here"), + "em", + ); + assert.deepEqual(star.props.children, ["important"]); + const underscore = findByType( + renderHighlightedContent("really _important_ here"), + "em", + ); + assert.deepEqual(underscore.props.children, ["important"]); +}); + +test("strikeMarkdown_rendersDel", () => { + const del = findByType( + renderHighlightedContent("~~old plan~~ new plan"), + "del", + ); + assert.deepEqual(del.props.children, ["old plan"]); +}); + +test("boldContainingInlineCode_nestsHighlighting", () => { + const strong = findByType( + renderHighlightedContent("**uses `flag`**"), + "strong", + ); + assert.ok(strong); + const [prefix, code] = strong.props.children; + assert.equal(prefix, "uses "); + assert.equal(code.props.children, "flag"); +}); + +test("snakeCaseAndArithmetic_stayPlainProse", () => { + for (const text of ["field_for_json stays", "2*3*4 = 24", "a ** b"]) { + const nodes = renderHighlightedContent(text); + assert.equal(findByType(nodes, "em"), undefined, text); + assert.equal(findByType(nodes, "strong"), undefined, text); + } +}); + +test("markdownLink_rendersDevLinkWithLabel", () => { + const nodes = renderHighlightedContent( + "see [the PR](https://github.com/x/y/pull/1).", + ); + const link = nodes.find( + (node) => typeof node === "object" && node !== null && node.props?.href, + ); + assert.ok(link); + assert.equal(link.props.href, "https://github.com/x/y/pull/1"); + assert.equal(link.props.label, "the PR"); + assert.equal(nodes.at(-1), "."); +}); + +const ampMention = { name: "amp (local)", color: "#00bcd4" }; + +test("leadingMention_knownName_matchesPastTrailingSpace", () => { + const directed = matchLeadingMention("@amp (local) open a PR", [ampMention]); + assert.ok(directed); + assert.equal(directed.mention.name, "amp (local)"); + assert.equal("@amp (local) open a PR".slice(directed.end), "open a PR"); +}); + +test("leadingMention_matchesEntireMessage", () => { + const directed = matchLeadingMention("@amp (local)", [ampMention]); + assert.ok(directed); + assert.equal("@amp (local)".slice(directed.end), ""); +}); + +test("leadingMention_midMessageMention_doesNotMatch", () => { + assert.equal( + matchLeadingMention("ask @amp (local) later", [ampMention]), + null, + ); +}); + +test("leadingMention_unknownName_doesNotMatch", () => { + assert.equal(matchLeadingMention("@stranger hello", [ampMention]), null); +}); + +test("leadingMention_requiresBoundaryAfterName", () => { + // "@amp (local)x" is not the known mention "amp (local)". + assert.equal(matchLeadingMention("@amp (local)x hi", [ampMention]), null); +}); diff --git a/desktop/src/features/dev-mode/lib/highlightContent.tsx b/desktop/src/features/dev-mode/lib/highlightContent.tsx new file mode 100644 index 0000000000..33f16a89b9 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/highlightContent.tsx @@ -0,0 +1,220 @@ +import type * as React from "react"; + +import type { ChannelRef } from "@/features/dev-mode/lib/channelRefs"; +import { DevLink } from "@/features/dev-mode/ui/DevLink"; + +/** + * Conservative keyword highlighting for developer-mode transcripts. Message + * text is tokenized into React spans — never HTML — so arbitrary content + * stays inert. Highlighted tokens: `inline code`, markdown emphasis + * (**bold**, *italic*, _italic_, ~~strike~~), [label](url) links, bare URLs, + * @mentions, and #channel references to known channels. + * + * Emphasis delimiters are deliberately strict — no space just inside the + * markers, and `*`/`_`/`~` openers can't start mid-word — so snake_case and + * arithmetic like `2*3*4` stay plain prose. + */ + +const TOKEN_RE = + /(`[^`\n]+`|\[[^\]\n]+\]\(https?:\/\/[^\s)]+\)|\*\*(?!\s)[^\n]+?(?<=\S)\*\*|(?"')\]]+|@[\w./-]+|#[\w./-]+)/g; + +const MD_LINK_RE = /^\[([^\]\n]+)\]\((https?:\/\/[^\s)]+)\)$/; + +/** Same boundary the composer's mention-prefix check uses. */ +const MENTION_BOUNDARY_RE = /[\s,.;:!?)\]]/; + +/** A name the message explicitly mentions (from its p tags) and its chat color. */ +export type MentionStyle = { name: string; color: string }; + +/** Click handling for `#channel-name` references to known channels. */ +export type ChannelRefOptions = { + channels: ChannelRef[]; + onOpen: (channelId: string) => void; +}; + +/** + * Longest known channel name (case-insensitive) starting right after the `#` + * at `hashIndex`, with the same word-boundary rule mentions use. + */ +function matchKnownChannel( + content: string, + hashIndex: number, + channels: ChannelRef[], +): ChannelRef | null { + let best: ChannelRef | null = null; + for (const channel of channels) { + if (best && channel.name.length <= best.name.length) continue; + const end = hashIndex + 1 + channel.name.length; + const candidate = content.slice(hashIndex + 1, end); + if (candidate.toLowerCase() !== channel.name.toLowerCase()) continue; + const after = content[end]; + if (after !== undefined && !MENTION_BOUNDARY_RE.test(after)) continue; + best = channel; + } + return best; +} + +/** + * Longest known name (case-insensitive) starting right after the `@` at + * `atIndex`. Names may contain spaces/parens — e.g. "amp (local)" — which the + * generic token regex cannot capture. + */ +function matchKnownMention( + content: string, + atIndex: number, + mentions: MentionStyle[], +): MentionStyle | null { + let best: MentionStyle | null = null; + for (const mention of mentions) { + if (best && mention.name.length <= best.name.length) continue; + const end = atIndex + 1 + mention.name.length; + const candidate = content.slice(atIndex + 1, end); + if (candidate.toLowerCase() !== mention.name.toLowerCase()) continue; + const after = content[end]; + if (after !== undefined && !MENTION_BOUNDARY_RE.test(after)) continue; + best = mention; + } + return best; +} + +/** + * A known mention the message text opens with (`@Name …`). Dev mode lifts it + * out of the body and renders it as a "to Name" line under the author name; + * `end` is the content offset just past the mention and its trailing space. + */ +export function matchLeadingMention( + content: string, + mentions: MentionStyle[], +): { mention: MentionStyle; end: number } | null { + if (!content.startsWith("@")) return null; + const known = matchKnownMention(content, 0, mentions); + if (!known) return null; + let end = 1 + known.name.length; + while (end < content.length && /\s/.test(content[end])) end += 1; + return { mention: known, end }; +} + +export function renderHighlightedContent( + content: string, + mentions: MentionStyle[] = [], + channelRefs?: ChannelRefOptions, +): React.ReactNode[] { + const nodes: React.ReactNode[] = []; + const re = new RegExp(TOKEN_RE.source, "g"); + let lastIndex = 0; + let match = re.exec(content); + while (match !== null) { + const index = match.index; + if (index > lastIndex) { + nodes.push(content.slice(lastIndex, index)); + } + const token = match[0]; + if (token.startsWith("`")) { + nodes.push( + + {token.slice(1, -1)} + , + ); + lastIndex = index + token.length; + } else if (token.startsWith("[")) { + const link = MD_LINK_RE.exec(token); + if (link) { + nodes.push( + , + ); + } else { + nodes.push(token); + } + lastIndex = index + token.length; + } else if (token.startsWith("**")) { + nodes.push( + + {renderHighlightedContent(token.slice(2, -2), mentions, channelRefs)} + , + ); + lastIndex = index + token.length; + } else if (token.startsWith("~~")) { + nodes.push( + + {renderHighlightedContent(token.slice(2, -2), mentions, channelRefs)} + , + ); + lastIndex = index + token.length; + } else if (token.startsWith("*") || token.startsWith("_")) { + nodes.push( + + {renderHighlightedContent(token.slice(1, -1), mentions, channelRefs)} + , + ); + lastIndex = index + token.length; + } else if (token.startsWith("@")) { + const known = matchKnownMention(content, index, mentions); + if (known) { + const end = index + 1 + known.name.length; + // Colored text only — the composer's mode pill is the one boxed + // mention; in-message mentions render unboxed. + nodes.push( + + {content.slice(index, end)} + , + ); + lastIndex = end; + re.lastIndex = end; + } else { + nodes.push( + + {token} + , + ); + lastIndex = index + token.length; + } + } else if (token.startsWith("#")) { + const known = channelRefs + ? matchKnownChannel(content, index, channelRefs.channels) + : null; + if (known && channelRefs) { + const end = index + 1 + known.name.length; + const onOpen = channelRefs.onOpen; + // mousedown is prevented so a click never pulls focus off the + // composer — the same rule message clicks follow. + nodes.push( + , + ); + lastIndex = end; + re.lastIndex = end; + } else { + // `#word` that is not a known channel is ordinary prose. + nodes.push(token); + lastIndex = index + token.length; + } + } else { + // Sentence punctuation after a URL belongs to the prose, not the href. + const href = token.replace(/[.,;:!?]+$/, ""); + nodes.push(); + if (href.length < token.length) { + nodes.push(token.slice(href.length)); + } + lastIndex = index + token.length; + } + match = re.exec(content); + } + if (lastIndex < content.length) { + nodes.push(content.slice(lastIndex)); + } + return nodes; +} diff --git a/desktop/src/features/dev-mode/lib/linkDisplay.test.mjs b/desktop/src/features/dev-mode/lib/linkDisplay.test.mjs new file mode 100644 index 0000000000..6fb051da1d --- /dev/null +++ b/desktop/src/features/dev-mode/lib/linkDisplay.test.mjs @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { linkDisplayText } from "./linkDisplay.ts"; + +test("linkDisplayText_stripsSchemeAndTrailingSlash", () => { + assert.equal(linkDisplayText("https://example.com/"), "example.com"); + assert.equal(linkDisplayText("http://example.com/docs"), "example.com/docs"); +}); + +test("linkDisplayText_keepsPathQueryAndFragment", () => { + assert.equal( + linkDisplayText("https://mail.google.com/mail/u/0/#drafts?compose=abc"), + "mail.google.com/mail/u/0/#drafts?compose=abc", + ); +}); + +test("linkDisplayText_truncatesLongUrlsWithEllipsis", () => { + const display = linkDisplayText(`https://example.com/${"a".repeat(100)}`); + assert.equal(display.length, 64); + assert.ok(display.endsWith("…")); + assert.ok(display.startsWith("example.com/")); +}); diff --git a/desktop/src/features/dev-mode/lib/linkDisplay.ts b/desktop/src/features/dev-mode/lib/linkDisplay.ts new file mode 100644 index 0000000000..56aade8753 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/linkDisplay.ts @@ -0,0 +1,9 @@ +const MAX_DISPLAY_CHARS = 64; + +/** Slack-style link text: no scheme, no trailing slash, tail-truncated. */ +export function linkDisplayText(href: string): string { + const stripped = href.replace(/^https?:\/\//, "").replace(/\/$/, ""); + return stripped.length > MAX_DISPLAY_CHARS + ? `${stripped.slice(0, MAX_DISPLAY_CHARS - 1)}…` + : stripped; +} diff --git a/desktop/src/features/dev-mode/lib/membershipEvents.test.mjs b/desktop/src/features/dev-mode/lib/membershipEvents.test.mjs new file mode 100644 index 0000000000..5aa60359a2 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/membershipEvents.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseMembershipEvent, + selectMembershipEvents, +} from "./membershipEvents.ts"; + +const KIND_SYSTEM_MESSAGE = 40099; +const ALICE = "a".repeat(64); +const BOB = "b".repeat(64); + +let nextId = 0; +function systemEvent(payload, createdAt = 1_000) { + nextId += 1; + return { + id: `sys-${nextId}`, + kind: KIND_SYSTEM_MESSAGE, + pubkey: "relay", + created_at: createdAt, + content: JSON.stringify(payload), + tags: [], + }; +} + +test("self-join parses as joined", () => { + const change = parseMembershipEvent( + systemEvent({ type: "member_joined", actor: ALICE, target: ALICE }), + ); + assert.equal(change.change, "joined"); + assert.equal(change.member, ALICE); + assert.equal(change.actor, null); +}); + +test("join by another member parses as added with actor", () => { + const change = parseMembershipEvent( + systemEvent({ type: "member_joined", actor: ALICE, target: BOB }), + ); + assert.equal(change.change, "added"); + assert.equal(change.member, BOB); + assert.equal(change.actor, ALICE); +}); + +test("member_left names the actor as the departing member", () => { + const change = parseMembershipEvent( + systemEvent({ type: "member_left", actor: BOB }), + ); + assert.equal(change.change, "left"); + assert.equal(change.member, BOB); + assert.equal(change.actor, null); +}); + +test("member_removed carries the removing actor", () => { + const change = parseMembershipEvent( + systemEvent({ type: "member_removed", actor: ALICE, target: BOB }), + ); + assert.equal(change.change, "removed"); + assert.equal(change.member, BOB); + assert.equal(change.actor, ALICE); +}); + +test("non-membership system messages and other kinds are ignored", () => { + assert.equal( + parseMembershipEvent( + systemEvent({ type: "topic_changed", actor: ALICE, topic: "x" }), + ), + null, + ); + assert.equal( + parseMembershipEvent({ + id: "m1", + kind: 40002, + pubkey: ALICE, + created_at: 1, + content: "hello", + tags: [], + }), + null, + ); + assert.equal( + parseMembershipEvent({ + id: "bad", + kind: KIND_SYSTEM_MESSAGE, + pubkey: "relay", + created_at: 1, + content: "not json", + tags: [], + }), + null, + ); +}); + +test("selectMembershipEvents filters and sorts oldest first", () => { + const events = [ + systemEvent({ type: "member_left", actor: BOB }, 300), + { + id: "m1", + kind: 40002, + pubkey: ALICE, + created_at: 150, + content: "hi", + tags: [], + }, + systemEvent({ type: "member_joined", actor: BOB, target: BOB }, 100), + systemEvent({ type: "channel_created", actor: ALICE }, 200), + ]; + const changes = selectMembershipEvents(events); + assert.deepEqual( + changes.map((change) => change.change), + ["joined", "left"], + ); + assert.ok(changes[0].event.created_at < changes[1].event.created_at); +}); diff --git a/desktop/src/features/dev-mode/lib/membershipEvents.ts b/desktop/src/features/dev-mode/lib/membershipEvents.ts new file mode 100644 index 0000000000..8605d3c889 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/membershipEvents.ts @@ -0,0 +1,70 @@ +import { byCreatedAscending } from "@/features/dev-mode/lib/transcriptRoots"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +/** + * A member entering or leaving the channel, parsed from a relay-signed + * kind:40099 system message. Other system message types (topic changes, + * channel_created, …) are ignored — dev mode's transcript only narrates + * membership. + */ +export type MembershipChange = { + event: RelayEvent; + change: "joined" | "added" | "left" | "removed"; + /** The member who entered or left. */ + member: string; + /** Who added/removed them — present only when different from `member`. */ + actor: string | null; +}; + +export function parseMembershipEvent( + event: RelayEvent, +): MembershipChange | null { + if (event.kind !== KIND_SYSTEM_MESSAGE) return null; + + let payload: { type?: string; actor?: string; target?: string }; + try { + payload = JSON.parse(event.content) as typeof payload; + } catch { + return null; + } + + const actor = payload.actor ? normalizePubkey(payload.actor) : null; + const target = payload.target ? normalizePubkey(payload.target) : null; + + switch (payload.type) { + case "member_joined": { + if (!target) return null; + if (actor && actor !== target) { + return { event, change: "added", member: target, actor }; + } + return { event, change: "joined", member: target, actor: null }; + } + case "member_left": { + if (!actor) return null; + return { event, change: "left", member: actor, actor: null }; + } + case "member_removed": { + if (!target) return null; + return { + event, + change: "removed", + member: target, + actor: actor && actor !== target ? actor : null, + }; + } + default: + return null; + } +} + +/** Membership changes in a channel timeline, oldest first. */ +export function selectMembershipEvents( + events: RelayEvent[] | undefined, +): MembershipChange[] { + return (events ?? []) + .map(parseMembershipEvent) + .filter((change): change is MembershipChange => change !== null) + .sort((left, right) => byCreatedAscending(left.event, right.event)); +} diff --git a/desktop/src/features/dev-mode/lib/mentionRecords.test.mjs b/desktop/src/features/dev-mode/lib/mentionRecords.test.mjs new file mode 100644 index 0000000000..b735241b32 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/mentionRecords.test.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { extractMentions } from "./mentionRecords.ts"; + +const joah = { name: "Joah", pubkey: "AA11", isAgent: false }; +const amp = { name: "amp", pubkey: "bb22", isAgent: true }; +const ampLocal = { name: "amp (local)", pubkey: "cc33", isAgent: true }; + +test("finds a mention at the start of the text", () => { + const found = extractMentions("@Joah take a look", [joah]); + assert.deepEqual( + found.map((record) => record.pubkey), + ["AA11"], + ); +}); + +test("finds a mid-sentence mention after whitespace", () => { + const found = extractMentions("ping @Joah about this", [joah]); + assert.equal(found.length, 1); +}); + +test("matching is case-insensitive", () => { + const found = extractMentions("@joah hello", [joah]); + assert.equal(found.length, 1); +}); + +test("trailing punctuation does not break the match", () => { + const found = extractMentions("@Joah, thoughts?", [joah]); + assert.equal(found.length, 1); +}); + +test("an email address is not a mention", () => { + const found = extractMentions("mail joah@example.com please", [ + joah, + { name: "example.com", pubkey: "dd44", isAgent: false }, + ]); + assert.equal(found.length, 0); +}); + +test("a record whose name is absent from the text is not extracted", () => { + const found = extractMentions("no mentions here", [joah, amp]); + assert.equal(found.length, 0); +}); + +test("longest name wins: @amp (local) never counts as @amp", () => { + const found = extractMentions("@amp (local) run the tests", [amp, ampLocal]); + assert.deepEqual( + found.map((record) => record.pubkey), + ["cc33"], + ); +}); + +test("both names extract when both are genuinely mentioned", () => { + const found = extractMentions("@amp (local) and @amp should sync", [ + amp, + ampLocal, + ]); + assert.deepEqual(found.map((record) => record.pubkey).sort(), [ + "bb22", + "cc33", + ]); +}); + +test("duplicate pubkeys collapse to one record", () => { + const alias = { name: "Joah G", pubkey: "aa11", isAgent: false }; + const found = extractMentions("@Joah and @Joah G", [joah, alias]); + assert.equal(found.length, 1); +}); + +test("a mention inside a word does not match", () => { + const found = extractMentions("email@Joah is not a mention", [joah]); + assert.equal(found.length, 0); +}); + +test("bracketed mentions match", () => { + const found = extractMentions("(@Joah) and [@amp]", [joah, amp]); + assert.equal(found.length, 2); +}); diff --git a/desktop/src/features/dev-mode/lib/mentionRecords.ts b/desktop/src/features/dev-mode/lib/mentionRecords.ts new file mode 100644 index 0000000000..39ff2b75f4 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/mentionRecords.ts @@ -0,0 +1,42 @@ +/** A name the composer can map back to a pubkey when `@Name` is sent. */ +export type MentionRecord = { + name: string; + pubkey: string; + isAgent: boolean; +}; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Records whose `@Name` still appears in the text at send time — accepting + * a suggestion only counts if the mention survived editing. Longest names + * match first and consume their span, so `@amp` never claims a mention of + * `@amp (local)`. Boundaries mirror `withAgentMention`: the `@` must open a + * word (so `email@example.com` is not a mention). + */ +export function extractMentions( + text: string, + records: readonly MentionRecord[], +): MentionRecord[] { + const found: MentionRecord[] = []; + const seenPubkeys = new Set(); + const ordered = [...records] + .filter((record) => record.name) + .sort((left, right) => right.name.length - left.name.length); + let remaining = text; + for (const record of ordered) { + const pattern = new RegExp( + `(^|[\\s([{])@${escapeRegExp(record.name)}(?=$|[\\s,.;:!?)\\]}])`, + "gi", + ); + if (!pattern.test(remaining)) continue; + remaining = remaining.replace(pattern, "$1"); + const pubkey = record.pubkey.toLowerCase(); + if (seenPubkeys.has(pubkey)) continue; + seenPubkeys.add(pubkey); + found.push(record); + } + return found; +} diff --git a/desktop/src/features/dev-mode/lib/messageReactions.ts b/desktop/src/features/dev-mode/lib/messageReactions.ts new file mode 100644 index 0000000000..8411034a09 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/messageReactions.ts @@ -0,0 +1,50 @@ +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_REACTION } from "@/shared/constants/kinds"; + +const HEX_RE = /^[0-9a-f]+$/i; + +function reactionTargetId(tags: string[][]): string | null { + for (let index = tags.length - 1; index >= 0; index -= 1) { + const tag = tags[index]; + if ( + tag?.[0] === "e" && + typeof tag[1] === "string" && + tag[1].length === 64 && + HEX_RE.test(tag[1]) + ) { + return tag[1]; + } + } + return null; +} + +export type MessageReaction = { + emoji: string; + /** Who reacted — names the reactor in the chip's hover tooltip. */ + pubkey: string; +}; + +/** + * Aggregate kind-7 reaction events by target message. Agents react to a + * prompt while working on it, so these chips double as the loading state. + */ +export function collectReactions( + events: RelayEvent[] | undefined, +): Map { + const byTarget = new Map(); + for (const event of events ?? []) { + if (event.kind !== KIND_REACTION) continue; + const targetId = reactionTargetId(event.tags); + if (!targetId) continue; + const raw = event.content.trim(); + const emoji = raw === "" || raw === "+" ? "👍" : raw; + const reaction = { emoji, pubkey: event.pubkey }; + const bucket = byTarget.get(targetId); + if (bucket) { + bucket.push(reaction); + } else { + byTarget.set(targetId, [reaction]); + } + } + return byTarget; +} diff --git a/desktop/src/features/dev-mode/lib/pinnedChannels.ts b/desktop/src/features/dev-mode/lib/pinnedChannels.ts new file mode 100644 index 0000000000..a9d2b977e4 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/pinnedChannels.ts @@ -0,0 +1,99 @@ +import * as React from "react"; + +import type { Channel } from "@/shared/api/types"; + +/** + * Device-local pinned channels for the developer-mode channel list. Channels + * have no pin field in the protocol, so the set lives in localStorage. + * Pinned channels render in their own section above everything else; both + * sections order by last activity, most recent first. + */ + +const STORAGE_KEY = "buzz.devMode.pinnedChannels"; + +const listeners = new Set<() => void>(); + +let pinned = readStoredPinned(); + +function readStoredPinned(): ReadonlySet { + try { + const raw = globalThis.localStorage?.getItem(STORAGE_KEY); + if (!raw) return new Set(); + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((id): id is string => typeof id === "string")); + } catch { + return new Set(); + } +} + +function writePinned(next: ReadonlySet) { + pinned = next; + try { + globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify([...next])); + } catch { + // Persistence is best-effort; the in-memory value still applies. + } + for (const listener of listeners) { + listener(); + } +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +function getSnapshot(): ReadonlySet { + return pinned; +} + +export function usePinnedChannels(): ReadonlySet { + return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot); +} + +export function toggleChannelPinned(channelId: string): void { + const next = new Set(pinned); + if (!next.delete(channelId)) { + next.add(channelId); + } + writePinned(next); +} + +export type ChannelGroup = { + pinned: boolean; + channels: Channel[]; +}; + +function byLastActivityDesc(left: Channel, right: Channel): number { + return (right.lastMessageAt ?? "").localeCompare(left.lastMessageAt ?? ""); +} + +/** + * Split channels into a pinned section on top and the rest beneath, each + * ordered by last activity (most recent first). `flat` matches render order + * for keyboard navigation. + */ +export function groupSessionChannels( + channels: Channel[], + pinnedIds: ReadonlySet, +): { groups: ChannelGroup[]; flat: Channel[] } { + const pinnedChannels: Channel[] = []; + const rest: Channel[] = []; + for (const channel of channels) { + (pinnedIds.has(channel.id) ? pinnedChannels : rest).push(channel); + } + pinnedChannels.sort(byLastActivityDesc); + rest.sort(byLastActivityDesc); + + const groups: ChannelGroup[] = []; + if (pinnedChannels.length > 0) { + groups.push({ pinned: true, channels: pinnedChannels }); + } + if (rest.length > 0 || groups.length === 0) { + groups.push({ pinned: false, channels: rest }); + } + return { groups, flat: groups.flatMap((group) => group.channels) }; +} diff --git a/desktop/src/features/dev-mode/lib/sessionNaming.test.mjs b/desktop/src/features/dev-mode/lib/sessionNaming.test.mjs new file mode 100644 index 0000000000..25c04434e0 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/sessionNaming.test.mjs @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { sanitizeChannelName, uniqueChannelName } from "./sessionNaming.ts"; + +test("sanitizeChannelName_buildsHyphenatedNameFromWords", () => { + assert.equal( + sanitizeChannelName("Fix the login race condition"), + "fix-the-login-race-condition", + ); +}); + +test("sanitizeChannelName_stripsPunctuationAndNormalizesCase", () => { + assert.equal( + sanitizeChannelName("Why is CI broken?! (again)"), + "why-is-ci-broken-again", + ); +}); + +test("sanitizeChannelName_capsLengthAtWordBoundary", () => { + const name = sanitizeChannelName( + "one two three four five six seven eight nine ten eleven twelve", + ); + assert.ok(name.length <= 40, `expected <= 40 chars, got ${name.length}`); + assert.ok(!name.endsWith("-"), "must not end mid-separator"); +}); + +test("sanitizeChannelName_truncatesSingleOversizedWord", () => { + assert.equal(sanitizeChannelName("a".repeat(100)), "a".repeat(40)); +}); + +test("sanitizeChannelName_neverEmitsDoubleHyphen", () => { + assert.equal(sanitizeChannelName("foo--bar"), "foo-bar"); + assert.equal(sanitizeChannelName("foo - bar"), "foo-bar"); + assert.equal(sanitizeChannelName("a -- b -- c"), "a-b-c"); +}); + +test("sanitizeChannelName_emptyForSymbolOnlyInput", () => { + assert.equal(sanitizeChannelName(""), ""); + assert.equal(sanitizeChannelName("!!! ???"), ""); +}); + +test("uniqueChannelName_returnsBaseWhenFree", () => { + assert.equal(uniqueChannelName("fix-login", new Set()), "fix-login"); +}); + +test("uniqueChannelName_suffixesUntilNameIsUnique", () => { + const existing = new Set(["fix-login", "fix-login-2"]); + assert.equal(uniqueChannelName("fix-login", existing), "fix-login-3"); +}); diff --git a/desktop/src/features/dev-mode/lib/sessionNaming.ts b/desktop/src/features/dev-mode/lib/sessionNaming.ts new file mode 100644 index 0000000000..867686ebee --- /dev/null +++ b/desktop/src/features/dev-mode/lib/sessionNaming.ts @@ -0,0 +1,33 @@ +const MAX_SLUG_LENGTH = 40; + +/** + * Reduce arbitrary text to channel-name form: lowercase kebab-case. + * Splitting on hyphens as well as whitespace guarantees the result never + * contains `--`, which would make it parse as a sub-channel name + * (see subChannels.ts). + */ +export function sanitizeChannelName(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9\s-]/g, "") + .trim() + .split(/[\s-]+/) + .reduce((slug, word) => { + if (word.length === 0) return slug; + if (slug.length === 0) return word.slice(0, MAX_SLUG_LENGTH); + const next = `${slug}-${word}`; + return next.length > MAX_SLUG_LENGTH ? slug : next; + }, ""); +} + +/** Suffix a base name until it does not collide with an existing channel. */ +export function uniqueChannelName( + base: string, + existingNames: ReadonlySet, +): string { + if (!existingNames.has(base)) return base; + for (let suffix = 2; ; suffix += 1) { + const candidate = `${base}-${suffix}`; + if (!existingNames.has(candidate)) return candidate; + } +} diff --git a/desktop/src/features/dev-mode/lib/subChannels.test.mjs b/desktop/src/features/dev-mode/lib/subChannels.test.mjs new file mode 100644 index 0000000000..60403feab7 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/subChannels.test.mjs @@ -0,0 +1,196 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + aggregateLastActivity, + aggregateUnreadMains, + appendSubChannelToParentCanvas, + applySubChannelRenames, + indexSubChannels, + parseSubChannelName, + planSubChannelRenames, + subChannelAnnouncement, + subChannelCanvasDoc, + subChannelName, +} from "./subChannels.ts"; + +function channel(id, name, lastMessageAt = null) { + return { id, name, lastMessageAt }; +} + +test("parseSubChannelName_splitsOnFirstDoubleHyphen", () => { + assert.deepEqual(parseSubChannelName("deploy-fixes--flaky-ci"), { + parentName: "deploy-fixes", + subSlug: "flaky-ci", + }); +}); + +test("parseSubChannelName_rejectsPlainNames", () => { + assert.equal(parseSubChannelName("deploy-fixes"), null); + assert.equal(parseSubChannelName("deploy-fixes-ci"), null); +}); + +test("parseSubChannelName_rejectsEmptyComponents", () => { + assert.equal(parseSubChannelName("--sub"), null); + assert.equal(parseSubChannelName("parent--"), null); +}); + +test("parseSubChannelName_nestedNameParsesAsChildOfFirstParent", () => { + // `a--b--c` splits at the first separator; `a--b` is not a valid parent + // name for indexing purposes, so it stays an orphan unless `a` exists. + assert.deepEqual(parseSubChannelName("a--b--c"), { + parentName: "a", + subSlug: "b--c", + }); +}); + +test("subChannelName_roundTripsWithParse", () => { + const name = subChannelName("deploy-fixes", "rollback-plan"); + assert.equal(name, "deploy-fixes--rollback-plan"); + assert.deepEqual(parseSubChannelName(name), { + parentName: "deploy-fixes", + subSlug: "rollback-plan", + }); +}); + +test("indexSubChannels_pairsSubsWithParents", () => { + const parent = channel("p1", "deploy-fixes"); + const subA = channel("c1", "deploy-fixes--flaky-ci"); + const subB = channel("c2", "deploy-fixes--rollback"); + const other = channel("p2", "general"); + const index = indexSubChannels([subB, parent, other, subA]); + + assert.deepEqual( + index.mains.map((c) => c.id), + ["p1", "p2"], + ); + assert.deepEqual( + index.subsByParentId.get("p1").map((c) => c.name), + ["deploy-fixes--flaky-ci", "deploy-fixes--rollback"], + ); + assert.equal(index.parentIdByChildId.get("c1"), "p1"); + assert.equal(index.parentIdByChildId.get("c2"), "p1"); +}); + +test("indexSubChannels_orphanSubStaysVisibleAsMain", () => { + const index = indexSubChannels([channel("c1", "ghost--task")]); + assert.deepEqual( + index.mains.map((c) => c.id), + ["c1"], + ); + assert.equal(index.subsByParentId.size, 0); +}); + +test("indexSubChannels_handlesHundredsOfSubsInOnePass", () => { + const channels = [channel("p", "work")]; + for (let i = 0; i < 500; i += 1) { + channels.push(channel(`c${i}`, `work--task-${String(i).padStart(3, "0")}`)); + } + const index = indexSubChannels(channels); + assert.equal(index.mains.length, 1); + assert.equal(index.subsByParentId.get("p").length, 500); + assert.equal(index.subsByParentId.get("p")[0].name, "work--task-000"); +}); + +test("aggregateUnreadMains_bubblesSubUnreadToParent", () => { + const index = indexSubChannels([ + channel("p1", "work"), + channel("c1", "work--api"), + channel("p2", "general"), + ]); + const unread = aggregateUnreadMains(index, new Set(["c1"])); + assert.deepEqual([...unread], ["p1"]); +}); + +test("aggregateLastActivity_usesLatestSubTimestamp", () => { + const index = indexSubChannels([ + channel("p1", "work", "2026-07-01T00:00:00Z"), + channel("c1", "work--api", "2026-07-20T00:00:00Z"), + channel("c2", "work--ui", "2026-07-10T00:00:00Z"), + ]); + const overrides = aggregateLastActivity(index); + assert.equal(overrides.get("p1"), "2026-07-20T00:00:00Z"); +}); + +test("subChannelAnnouncement_matchesCliFormat", () => { + assert.equal(subChannelAnnouncement("work--api"), "→ spawned #work--api"); +}); + +test("subChannelCanvasDoc_recordsParentAndReportBackContract", () => { + const doc = subChannelCanvasDoc({ + parentName: "work", + parentId: "uuid-1", + announcementEventId: "event-1", + task: "Build the API", + }); + assert.match(doc, /# Sub-channel of #work/); + assert.match(doc, /- parent: #work \(uuid-1\)/); + assert.match(doc, /- spawned-from: event-1/); + assert.match(doc, /- task: Build the API/); + assert.match(doc, /thread reply to the spawn announcement/); +}); + +test("appendSubChannelToParentCanvas_createsSectionWhenMissing", () => { + assert.equal( + appendSubChannelToParentCanvas(null, "work--api", "Build API"), + "## Sub-channels\n- #work--api — Build API\n", + ); + assert.equal( + appendSubChannelToParentCanvas("# Work", "work--api", "Build API"), + "# Work\n\n## Sub-channels\n- #work--api — Build API\n", + ); +}); + +test("appendSubChannelToParentCanvas_appendsAfterExistingBullets", () => { + const canvas = + "# Work\n\n## Sub-channels\n- #work--one — One\n\nNotes\n\n## Links\n- link"; + assert.equal( + appendSubChannelToParentCanvas(canvas, "work--two", "Two"), + "# Work\n\n## Sub-channels\n- #work--one — One\n- #work--two — Two\n\nNotes\n\n## Links\n- link", + ); +}); + +test("planSubChannelRenames_rewritesEveryChildPrefix", () => { + const channels = [ + channel("p", "work"), + channel("c1", "work--api"), + channel("c2", "work--ui"), + channel("x", "workshop--misc"), + ]; + assert.deepEqual(planSubChannelRenames(channels, "work", "platform"), [ + { channelId: "c1", newName: "platform--api" }, + { channelId: "c2", newName: "platform--ui" }, + ]); +}); + +test("planSubChannelRenames_noOpWhenNameUnchanged", () => { + assert.deepEqual( + planSubChannelRenames([channel("c1", "work--api")], "work", "work"), + [], + ); +}); + +test("applySubChannelRenames_boundsConcurrencyAndCollectsFailures", async () => { + const renames = Array.from({ length: 40 }, (_, i) => ({ + channelId: `c${i}`, + newName: `next--task-${i}`, + })); + let inFlight = 0; + let peak = 0; + const { failed } = await applySubChannelRenames( + renames, + async (channelId) => { + inFlight += 1; + peak = Math.max(peak, inFlight); + await new Promise((resolve) => setTimeout(resolve, 1)); + inFlight -= 1; + if (channelId === "c7") throw new Error("boom"); + }, + 4, + ); + assert.ok(peak <= 4, `expected concurrency <= 4, saw ${peak}`); + assert.deepEqual( + failed.map((f) => f.channelId), + ["c7"], + ); +}); diff --git a/desktop/src/features/dev-mode/lib/subChannels.ts b/desktop/src/features/dev-mode/lib/subChannels.ts new file mode 100644 index 0000000000..e2983910c2 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/subChannels.ts @@ -0,0 +1,241 @@ +import type { Channel } from "@/shared/api/types"; + +/** + * Sub-channels are a pure naming convention — no relay/schema support: a + * channel named `parent--sub` is a sub-channel of the channel named + * `parent`. One nesting level only. The separator can never appear in + * generated names (sanitizeChannelName collapses hyphen runs), so ordinary + * channels cannot accidentally become subs. + */ +export const SUB_CHANNEL_SEPARATOR = "--"; + +export type SubChannelRef = { + parentName: string; + subSlug: string; +}; + +/** Parse `parent--sub`; null when the name is not sub-channel-shaped. */ +export function parseSubChannelName(name: string): SubChannelRef | null { + const index = name.indexOf(SUB_CHANNEL_SEPARATOR); + if (index <= 0) return null; + const subSlug = name.slice(index + SUB_CHANNEL_SEPARATOR.length); + if (subSlug.length === 0) return null; + return { parentName: name.slice(0, index), subSlug }; +} + +export function subChannelName(parentName: string, subSlug: string): string { + return `${parentName}${SUB_CHANNEL_SEPARATOR}${subSlug}`; +} + +export type SubChannelIndex = { + /** Channels that render in the left list: everything except paired subs. */ + mains: Channel[]; + /** Sub-channels per parent id, sorted by name for stable tab order. */ + subsByParentId: ReadonlyMap; + parentIdByChildId: ReadonlyMap; +}; + +/** + * Pair subs with their parents in one O(n) pass — parents can have hundreds + * of subs, so no per-channel scans. A `--` name whose parent is not in the + * list stays a main (nothing silently disappears). + */ +export function indexSubChannels(channels: Channel[]): SubChannelIndex { + const byName = new Map(); + for (const channel of channels) { + byName.set(channel.name, channel); + } + + const mains: Channel[] = []; + const subsByParentId = new Map(); + const parentIdByChildId = new Map(); + for (const channel of channels) { + const parsed = parseSubChannelName(channel.name); + const parent = parsed ? byName.get(parsed.parentName) : undefined; + if (!parsed || !parent || parent.id === channel.id) { + mains.push(channel); + continue; + } + parentIdByChildId.set(channel.id, parent.id); + const siblings = subsByParentId.get(parent.id); + if (siblings) { + siblings.push(channel); + } else { + subsByParentId.set(parent.id, [channel]); + } + } + for (const siblings of subsByParentId.values()) { + siblings.sort((left, right) => left.name.localeCompare(right.name)); + } + return { mains, subsByParentId, parentIdByChildId }; +} + +/** + * Unread mains for the left list: a main is unread when it or any of its + * subs is unread — subs have no row of their own to carry the dot. + */ +export function aggregateUnreadMains( + index: SubChannelIndex, + unreadChannelIds: ReadonlySet, +): ReadonlySet { + const unread = new Set(); + for (const main of index.mains) { + if (unreadChannelIds.has(main.id)) { + unread.add(main.id); + continue; + } + const subs = index.subsByParentId.get(main.id); + if (subs?.some((sub) => unreadChannelIds.has(sub.id))) { + unread.add(main.id); + } + } + return unread; +} + +/** + * Last-activity overrides for list ordering: a main's activity is the max + * of its own and all its subs', so working sub-channels float their parent. + */ +export function aggregateLastActivity( + index: SubChannelIndex, +): ReadonlyMap { + const overrides = new Map(); + for (const [parentId, subs] of index.subsByParentId) { + let latest = ""; + for (const sub of subs) { + if ((sub.lastMessageAt ?? "") > latest) { + latest = sub.lastMessageAt ?? ""; + } + } + if (latest.length > 0) { + overrides.set(parentId, latest); + } + } + return overrides; +} + +/** First line of the prompt, bounded — used in announcements and canvases. */ +export function subChannelTaskLine(task: string): string { + const firstLine = task.split("\n", 1)[0].trim(); + return firstLine.length > 140 ? `${firstLine.slice(0, 139)}…` : firstLine; +} + +export function subChannelAnnouncement(subName: string): string { + return `→ spawned #${subName}`; +} + +/** + * The sub-channel's canvas records its relationship and the report-back + * contract. Format is shared with `buzz channels create --parent` — keep + * the two in sync (crates/buzz-cli/src/commands/channels.rs). + */ +export function subChannelCanvasDoc(input: { + parentName: string; + parentId: string; + announcementEventId: string; + task: string; +}): string { + const task = subChannelTaskLine(input.task); + return [ + `# Sub-channel of #${input.parentName}`, + "", + `- parent: #${input.parentName} (${input.parentId})`, + `- spawned-from: ${input.announcementEventId}`, + `- task: ${task}`, + "", + `When the work here is complete, post a summary to #${input.parentName} ` + + `as a thread reply to the spawn announcement (event ${input.announcementEventId}). ` + + `Every member of this sub-channel must be a member of #${input.parentName}.`, + ].join("\n"); +} + +const SUB_CHANNELS_HEADING = "## Sub-channels"; + +/** + * Append a sub entry to the parent canvas's `## Sub-channels` section, + * creating the section (and canvas) when absent. Single read + single write + * even with hundreds of entries. Same format as the CLI's --parent path. + */ +export function appendSubChannelToParentCanvas( + existing: string | null, + subName: string, + task: string, +): string { + const bullet = `- #${subName} — ${subChannelTaskLine(task)}`; + const canvas = existing ?? ""; + const lines = canvas.split("\n"); + const headingAt = lines.findIndex( + (line) => line.trim() === SUB_CHANNELS_HEADING, + ); + if (headingAt === -1) { + const base = canvas.trimEnd(); + return base.length === 0 + ? `${SUB_CHANNELS_HEADING}\n${bullet}\n` + : `${base}\n\n${SUB_CHANNELS_HEADING}\n${bullet}\n`; + } + let insertAt = headingAt + 1; + for (let index = headingAt + 1; index < lines.length; index += 1) { + const line = lines[index]; + if (line.trim().length === 0) continue; + if (!line.startsWith("- ")) break; + insertAt = index + 1; + } + lines.splice(insertAt, 0, bullet); + return lines.join("\n"); +} + +export type SubChannelRename = { + channelId: string; + newName: string; +}; + +/** + * Renaming a parent must rename every sub (the name IS the link). Pure + * planner: single pass over the channel list; the caller applies the + * updates with bounded concurrency. + */ +export function planSubChannelRenames( + channels: readonly Pick[], + oldParentName: string, + newParentName: string, +): SubChannelRename[] { + if (oldParentName === newParentName) return []; + const prefix = `${oldParentName}${SUB_CHANNEL_SEPARATOR}`; + const renames: SubChannelRename[] = []; + for (const channel of channels) { + if (!channel.name.startsWith(prefix)) continue; + const subSlug = channel.name.slice(prefix.length); + if (subSlug.length === 0) continue; + renames.push({ + channelId: channel.id, + newName: subChannelName(newParentName, subSlug), + }); + } + return renames; +} + +/** Apply rename plans with bounded concurrency; failures are collected. */ +export async function applySubChannelRenames( + renames: readonly SubChannelRename[], + rename: (channelId: string, newName: string) => Promise, + concurrency = 8, +): Promise<{ failed: SubChannelRename[] }> { + const failed: SubChannelRename[] = []; + let next = 0; + const workers = Array.from( + { length: Math.min(concurrency, renames.length) }, + async () => { + while (next < renames.length) { + const plan = renames[next]; + next += 1; + try { + await rename(plan.channelId, plan.newName); + } catch { + failed.push(plan); + } + } + }, + ); + await Promise.all(workers); + return { failed }; +} diff --git a/desktop/src/features/dev-mode/lib/transcriptRoots.test.mjs b/desktop/src/features/dev-mode/lib/transcriptRoots.test.mjs new file mode 100644 index 0000000000..4b0ace79bc --- /dev/null +++ b/desktop/src/features/dev-mode/lib/transcriptRoots.test.mjs @@ -0,0 +1,32 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { selectInlineVisibleCount } from "./transcriptRoots.ts"; + +const AGENT = "agent-pubkey"; +const HUMAN = "human-pubkey"; + +function reply(pubkey) { + return { pubkey }; +} + +const isAgent = (pubkey) => pubkey === AGENT; + +test("selectInlineVisibleCount_showsTheWholeLeadingAgentRun", () => { + const replies = [reply(AGENT), reply(AGENT), reply(AGENT)]; + assert.equal(selectInlineVisibleCount(replies, isAgent), 3); +}); + +test("selectInlineVisibleCount_collapsesFromTheFirstHumanReply", () => { + const replies = [reply(AGENT), reply(AGENT), reply(HUMAN), reply(AGENT)]; + assert.equal(selectInlineVisibleCount(replies, isAgent), 2); +}); + +test("selectInlineVisibleCount_humanFirstThreadStillShowsItsFirstReply", () => { + const replies = [reply(HUMAN), reply(AGENT)]; + assert.equal(selectInlineVisibleCount(replies, isAgent), 1); +}); + +test("selectInlineVisibleCount_emptyThreadShowsNothing", () => { + assert.equal(selectInlineVisibleCount([], isAgent), 0); +}); diff --git a/desktop/src/features/dev-mode/lib/transcriptRoots.ts b/desktop/src/features/dev-mode/lib/transcriptRoots.ts new file mode 100644 index 0000000000..53d1f60343 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/transcriptRoots.ts @@ -0,0 +1,50 @@ +import { isThreadReply } from "@/features/messages/lib/threading"; +import type { RelayEvent } from "@/shared/api/types"; +import { + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, +} from "@/shared/constants/kinds"; + +export const DEV_MESSAGE_KINDS = new Set([ + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, +]); + +export function byCreatedAscending(left: RelayEvent, right: RelayEvent) { + return left.created_at !== right.created_at + ? left.created_at - right.created_at + : left.id < right.id + ? -1 + : 1; +} + +/** + * How many of a thread's ordered replies render inline in the main chat + * view: the whole leading run of agent replies — collapsing starts at the + * first human response. A human-first thread still shows its first reply + * inline. Shared by the transcript renderer and unread routing so "would + * this reply be visible without opening the thread" has one answer. + */ +export function selectInlineVisibleCount( + orderedReplies: readonly RelayEvent[], + isAgent: (pubkey: string) => boolean, +): number { + let end = 0; + while (end < orderedReplies.length && isAgent(orderedReplies[end].pubkey)) { + end += 1; + } + if (end === 0 && orderedReplies.length > 0) end = 1; + return end; +} + +/** Top-level prompt messages of a channel, oldest first. */ +export function selectRootEvents( + events: RelayEvent[] | undefined, +): RelayEvent[] { + return (events ?? []) + .filter( + (event) => + DEV_MESSAGE_KINDS.has(event.kind) && !isThreadReply(event.tags), + ) + .sort(byCreatedAscending); +} diff --git a/desktop/src/features/dev-mode/lib/unreadThreads.test.mjs b/desktop/src/features/dev-mode/lib/unreadThreads.test.mjs new file mode 100644 index 0000000000..33576d3079 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/unreadThreads.test.mjs @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + findUnreadFamilyTarget, + selectUnreadThreadRoots, +} from "./unreadThreads.ts"; + +function summary(lastReplyAt) { + return { + replyCount: 1, + descendantCount: 1, + lastReplyAt, + participantPubkeys: [], + }; +} + +function replyItem(channelId, rootId, createdAt) { + return { + channelId, + createdAt, + tags: [ + ["e", rootId, "", "root"], + ["e", rootId, "", "reply"], + ], + }; +} + +test("selectUnreadThreadRoots_flagsRepliesPastTheReadFrontier", () => { + const summaries = new Map([ + ["seen", summary(100)], + ["unseen", summary(200)], + ["never-read", summary(50)], + ["no-replies", summary(null)], + ]); + const readAts = new Map([ + ["seen", 100], + ["unseen", 150], + ]); + const unread = selectUnreadThreadRoots( + summaries, + (rootId) => readAts.get(rootId) ?? null, + ); + assert.deepEqual([...unread].sort(), ["never-read", "unseen"]); +}); + +test("findUnreadFamilyTarget_picksTheNewestUnreadThreadAcrossTheFamily", () => { + const target = findUnreadFamilyTarget({ + familyChannelIds: ["main", "sub-a", "sub-b"], + unreadChannelIds: new Set(["sub-a", "sub-b"]), + topLevelUnreadChannelIds: new Set(), + threadActivity: [ + replyItem("sub-a", "root-1", 100), + replyItem("sub-b", "root-2", 300), + replyItem("elsewhere", "root-3", 900), + ], + getThreadReadAt: () => null, + }); + assert.deepEqual(target, { channelId: "sub-b", rootId: "root-2" }); +}); + +test("findUnreadFamilyTarget_skipsRepliesAlreadyRead", () => { + const target = findUnreadFamilyTarget({ + familyChannelIds: ["main"], + unreadChannelIds: new Set(["main"]), + topLevelUnreadChannelIds: new Set(), + threadActivity: [replyItem("main", "root-1", 100)], + getThreadReadAt: () => 100, + }); + assert.equal(target, null); +}); + +test("findUnreadFamilyTarget_skipsChannelsTheSharedModelConsidersRead", () => { + const target = findUnreadFamilyTarget({ + familyChannelIds: ["main", "sub-a"], + unreadChannelIds: new Set(), + topLevelUnreadChannelIds: new Set(), + threadActivity: [replyItem("sub-a", "root-1", 100)], + getThreadReadAt: () => null, + }); + assert.equal(target, null); +}); + +test("findUnreadFamilyTarget_fallsBackToTopLevelUnreadMainFirst", () => { + const target = findUnreadFamilyTarget({ + familyChannelIds: ["main", "sub-a", "sub-b"], + unreadChannelIds: new Set(["main", "sub-b"]), + topLevelUnreadChannelIds: new Set(["sub-b", "main"]), + threadActivity: [], + getThreadReadAt: () => null, + }); + assert.deepEqual(target, { channelId: "main", rootId: null }); +}); + +test("findUnreadFamilyTarget_threadReplyOutranksTopLevelUnread", () => { + const target = findUnreadFamilyTarget({ + familyChannelIds: ["main", "sub-a"], + unreadChannelIds: new Set(["main", "sub-a"]), + topLevelUnreadChannelIds: new Set(["main"]), + threadActivity: [replyItem("sub-a", "root-1", 100)], + getThreadReadAt: () => null, + }); + assert.deepEqual(target, { channelId: "sub-a", rootId: "root-1" }); +}); + +test("findUnreadFamilyTarget_returnsNullWhenNothingIsUnread", () => { + const target = findUnreadFamilyTarget({ + familyChannelIds: ["main"], + unreadChannelIds: new Set(), + topLevelUnreadChannelIds: new Set(), + threadActivity: [], + getThreadReadAt: () => null, + }); + assert.equal(target, null); +}); diff --git a/desktop/src/features/dev-mode/lib/unreadThreads.ts b/desktop/src/features/dev-mode/lib/unreadThreads.ts new file mode 100644 index 0000000000..cf5c798336 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/unreadThreads.ts @@ -0,0 +1,83 @@ +import type { ChannelWindowThreadSummary } from "@/features/messages/lib/channelWindowStore"; +import { getThreadReference } from "@/features/messages/lib/threading"; + +/** + * Roots whose thread has replies past the shared read frontier. Built from + * the same window summaries the transcript renders reply counts from, so + * the per-card unread dot always agrees with what is on screen. + */ +export function selectUnreadThreadRoots( + summaries: ReadonlyMap, + getThreadReadAt: (rootId: string) => number | null, +): ReadonlySet { + const unread = new Set(); + for (const [rootId, summary] of summaries) { + if (!summary.lastReplyAt) continue; + const readAt = getThreadReadAt(rootId); + if (readAt === null || summary.lastReplyAt > readAt) { + unread.add(rootId); + } + } + return unread; +} + +export type UnreadFamilyTarget = { + channelId: string; + /** + * The unread thread needing attention, or null for a top-level unread. + * Routing opens its side chat only when the unread replies are collapsed + * in the main view (see useUnreadRouting) — inline unread replies are + * read just by looking at the channel. + */ + rootId: string | null; +}; + +/** + * Where opening an unread channel family should land: the newest unread + * thread reply anywhere in the family (main + tabs) wins — that is the + * agent chat needing attention — falling back to the first family channel + * (main first) with an unread top-level post. Null means nothing unread, + * so the caller opens the channel it was asked for. + * + * Thread activity is the shared observed-events feed (bounded, newest + * ~100), already gated to threads relevant to the user; family membership + * is checked via a Set so hundreds of tabs stay cheap. + */ +export function findUnreadFamilyTarget(input: { + /** Main channel id first, then its sub-channel (tab) ids. */ + familyChannelIds: readonly string[]; + unreadChannelIds: ReadonlySet; + topLevelUnreadChannelIds: ReadonlySet; + threadActivity: readonly { + channelId: string; + createdAt: number; + tags: string[][]; + }[]; + getThreadReadAt: (rootId: string, channelId: string) => number | null; +}): UnreadFamilyTarget | null { + const family = new Set(input.familyChannelIds); + let best: { channelId: string; rootId: string; createdAt: number } | null = + null; + for (const item of input.threadActivity) { + if (!family.has(item.channelId)) continue; + // Channel-level gate keeps routing consistent with the visible dot — + // the shared model has already folded read markers for cleared channels. + if (!input.unreadChannelIds.has(item.channelId)) continue; + const rootId = getThreadReference(item.tags).rootId; + if (rootId === null) continue; + const readAt = input.getThreadReadAt(rootId, item.channelId); + if (readAt !== null && item.createdAt <= readAt) continue; + if (!best || item.createdAt > best.createdAt) { + best = { channelId: item.channelId, rootId, createdAt: item.createdAt }; + } + } + if (best) { + return { channelId: best.channelId, rootId: best.rootId }; + } + for (const channelId of input.familyChannelIds) { + if (input.topLevelUnreadChannelIds.has(channelId)) { + return { channelId, rootId: null }; + } + } + return null; +} diff --git a/desktop/src/features/dev-mode/lib/useChannelRefAutocomplete.ts b/desktop/src/features/dev-mode/lib/useChannelRefAutocomplete.ts new file mode 100644 index 0000000000..2a7da1cd81 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/useChannelRefAutocomplete.ts @@ -0,0 +1,134 @@ +import * as React from "react"; + +import { + type ChannelRef, + useChannelRefs, +} from "@/features/dev-mode/lib/channelRefs"; +import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; + +const MAX_SUGGESTIONS = 6; + +/** + * `#channel` autocomplete for a dev-mode composer textarea. Typing `#` plus + * a prefix at the caret opens suggestions; ↑/↓ move, Tab/Enter accept + * (inserting `#channel-name `), Escape dismisses. `handleKeyDown` returns + * true when it consumed the key, so callers try it before their own + * bindings (Tab normally cycles modes, Enter normally sends). + */ +export function useChannelRefAutocomplete({ + value, + onChange, + textareaRef, +}: { + value: string; + onChange: (value: string) => void; + textareaRef: React.RefObject; +}) { + const { channels } = useChannelRefs(); + const [cursor, setCursor] = React.useState(0); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const [dismissed, setDismissed] = React.useState(false); + + const knownNamesLower = React.useMemo( + () => channels.map((channel) => channel.name.toLowerCase()), + [channels], + ); + + const detection = React.useMemo( + () => detectPrefixQuery("#", value, cursor, knownNamesLower), + [cursor, knownNamesLower, value], + ); + + const suggestions = React.useMemo(() => { + if (!detection) return []; + const needle = detection.query.toLowerCase(); + const starts: ChannelRef[] = []; + const contains: ChannelRef[] = []; + for (const channel of channels) { + const name = channel.name.toLowerCase(); + if (name.startsWith(needle)) { + starts.push(channel); + } else if (name.includes(needle)) { + contains.push(channel); + } + } + return [...starts, ...contains].slice(0, MAX_SUGGESTIONS); + }, [channels, detection]); + + const open = !dismissed && suggestions.length > 0; + + // A fresh query (typing/caret movement) clears the selection and any + // Escape dismissal from the previous query. + const queryKey = detection + ? `${detection.startIndex}:${detection.query}` + : null; + const previousQueryKeyRef = React.useRef(queryKey); + if (previousQueryKeyRef.current !== queryKey) { + previousQueryKeyRef.current = queryKey; + if (selectedIndex !== 0) setSelectedIndex(0); + if (dismissed) setDismissed(false); + } + + const syncCursor = React.useCallback((target: HTMLTextAreaElement) => { + setCursor(target.selectionStart ?? target.value.length); + }, []); + + const accept = React.useCallback( + (suggestion: ChannelRef) => { + if (!detection) return; + const before = value.slice(0, detection.startIndex); + const inserted = `#${suggestion.name} `; + const next = before + inserted + value.slice(cursor); + onChange(next); + const caret = before.length + inserted.length; + setCursor(caret); + requestAnimationFrame(() => { + const textarea = textareaRef.current; + // Skip if the user already typed past the accept — repositioning + // now would drop their keystrokes into the middle of the text. + if (!textarea || textarea.value !== next) return; + textarea.setSelectionRange(caret, caret); + }); + }, + [cursor, detection, onChange, textareaRef, value], + ); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent): boolean => { + if (!open) return false; + // Modified keys belong to composer shortcuts (e.g. ⌥↑ channel step). + if (event.altKey || event.metaKey || event.ctrlKey) return false; + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + event.preventDefault(); + const delta = event.key === "ArrowUp" ? -1 : 1; + setSelectedIndex( + (current) => + (current + delta + suggestions.length) % suggestions.length, + ); + return true; + } + if (event.key === "Tab" || event.key === "Enter") { + event.preventDefault(); + accept(suggestions[selectedIndex] ?? suggestions[0]); + return true; + } + if (event.key === "Escape") { + event.preventDefault(); + setDismissed(true); + return true; + } + return false; + }, + [accept, open, selectedIndex, suggestions], + ); + + return { + open, + suggestions, + selectedIndex, + query: detection?.query ?? "", + accept, + handleKeyDown, + syncCursor, + }; +} diff --git a/desktop/src/features/dev-mode/lib/useComposerAutoGrow.ts b/desktop/src/features/dev-mode/lib/useComposerAutoGrow.ts new file mode 100644 index 0000000000..f9e3cb7b33 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/useComposerAutoGrow.ts @@ -0,0 +1,127 @@ +import * as React from "react"; + +/** One text line at `leading-6` — the composer's minimum height. */ +const LINE_PX = 24; + +/** Cap auto-growth so the transcript always stays visible. */ +function maxHeightPx(): number { + return Math.max(LINE_PX * 4, Math.floor(globalThis.innerHeight * 0.4)); +} + +function readStoredFloor(storageKey: string): number | null { + try { + const raw = globalThis.localStorage?.getItem(storageKey); + const value = raw === null || raw === undefined ? Number.NaN : Number(raw); + if (Number.isFinite(value) && value > LINE_PX) { + return value; + } + } catch { + // Fall through to content-sized. + } + return null; +} + +/** + * Auto-grow a composer textarea upward with its content (wrapped lines + * included, unlike a newline-counting `rows`), capped at 40% of the window. + * + * A drag on the returned handle sets a persisted manual *floor*: the box + * never rests shorter than the floor, while content taller than it still + * grows the box up to the cap (then scrolls internally). + */ +export function useComposerAutoGrow(value: string, storageKey: string) { + const textareaRef = React.useRef(null); + const [floor, setFloor] = React.useState(() => + readStoredFloor(storageKey), + ); + const [dragging, setDragging] = React.useState(false); + const dragStart = React.useRef<{ y: number; floor: number } | null>(null); + + // biome-ignore lint/correctness/useExhaustiveDependencies: value is the intentional resize trigger; the effect measures the DOM, not the string + React.useLayoutEffect(() => { + const el = textareaRef.current; + if (!el) return; + // Freeze the wrapper during the 0px measurement: shrinking the textarea + // momentarily reflows the flex siblings, and that reflow clamps the + // transcript's scrollTop — every keystroke would scroll the chat up. + const wrapper = el.parentElement; + const wrapperHeight = wrapper?.style.height ?? ""; + if (wrapper) wrapper.style.height = `${wrapper.offsetHeight}px`; + el.style.height = "0px"; + const max = maxHeightPx(); + const target = Math.min( + Math.max(el.scrollHeight, floor ?? 0, LINE_PX), + max, + ); + el.style.height = `${target}px`; + el.style.overflowY = el.scrollHeight > target ? "auto" : "hidden"; + if (wrapper) wrapper.style.height = wrapperHeight; + }, [value, floor]); + + const persistFloor = (next: number | null) => { + try { + if (next === null) { + globalThis.localStorage?.removeItem(storageKey); + } else { + globalThis.localStorage?.setItem(storageKey, String(next)); + } + } catch { + // Persistence is best-effort. + } + }; + + const clampFloor = (raw: number): number => + Math.min(Math.max(raw, LINE_PX), maxHeightPx()); + + const handlePointerDown = (event: React.PointerEvent) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + dragStart.current = { + y: event.clientY, + floor: floor ?? textareaRef.current?.offsetHeight ?? LINE_PX, + }; + setDragging(true); + }; + + const handlePointerMove = (event: React.PointerEvent) => { + const start = dragStart.current; + if (!dragging || !start) return; + // The composer sits at the bottom, so dragging up (smaller Y) grows it. + setFloor(clampFloor(start.floor + (start.y - event.clientY))); + }; + + const handlePointerUp = (event: React.PointerEvent) => { + if (!dragging) return; + event.currentTarget.releasePointerCapture(event.pointerId); + setDragging(false); + dragStart.current = null; + setFloor((current) => { + persistFloor(current); + return current; + }); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; + event.preventDefault(); + const delta = event.key === "ArrowUp" ? LINE_PX : -LINE_PX; + setFloor((current) => { + const base = current ?? textareaRef.current?.offsetHeight ?? LINE_PX; + const next = clampFloor(base + delta); + persistFloor(next); + return next; + }); + }; + + return { + textareaRef, + dragging, + resizeHandleProps: { + "aria-valuenow": Math.round(floor ?? LINE_PX), + onPointerDown: handlePointerDown, + onPointerMove: handlePointerMove, + onPointerUp: handlePointerUp, + onKeyDown: handleKeyDown, + }, + }; +} diff --git a/desktop/src/features/dev-mode/lib/useDevComposerModes.ts b/desktop/src/features/dev-mode/lib/useDevComposerModes.ts new file mode 100644 index 0000000000..b2975379b9 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/useDevComposerModes.ts @@ -0,0 +1,91 @@ +import * as React from "react"; + +import { + getSharedChannelIds, + relayAgentIsSharedWithUser, +} from "@/features/agents/lib/agentAutocompleteEligibility"; +import { + useManagedAgentsQuery, + useRelayAgentsQuery, +} from "@/features/agents/hooks"; +import { useChannelsQuery } from "@/features/channels/hooks"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import type { ManagedAgent } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +export type DevAgentTarget = { + pubkey: string; + name: string; + source: "managed" | "relay"; + /** Present for managed agents; needed to attach + start them. */ + managedAgent?: ManagedAgent; +}; + +/** + * The composer target the user cycles through with Tab: plain human chat, or + * one of the agents that can be tagged into a new session channel. + */ +export type DevComposerMode = + | { kind: "chat" } + | { kind: "agent"; target: DevAgentTarget }; + +export function devComposerModeLabel(mode: DevComposerMode): string { + return mode.kind === "chat" ? "chat" : mode.target.name; +} + +/** + * Global (channel-independent) list of composer modes. Managed agents are + * always available — attaching one to a fresh channel is part of the send + * flow. Relay agents qualify under the same rules as mention autocomplete, + * minus the channel-membership requirement. + */ +export function useDevComposerModes(): DevComposerMode[] { + const identityQuery = useIdentityQuery(); + const channelsQuery = useChannelsQuery(); + const managedAgentsQuery = useManagedAgentsQuery(); + const relayAgentsQuery = useRelayAgentsQuery(); + + const currentPubkey = identityQuery.data?.pubkey ?? null; + const channels = channelsQuery.data; + const managedAgents = managedAgentsQuery.data; + const relayAgents = relayAgentsQuery.data; + + return React.useMemo(() => { + const targets = new Map(); + + for (const agent of managedAgents ?? []) { + const pubkey = normalizePubkey(agent.pubkey); + targets.set(pubkey, { + pubkey: agent.pubkey, + name: agent.name, + source: "managed", + managedAgent: agent, + }); + } + + const sharedChannelIds = getSharedChannelIds(channels); + for (const agent of relayAgents ?? []) { + const pubkey = normalizePubkey(agent.pubkey); + if (targets.has(pubkey)) continue; + if (!relayAgentIsSharedWithUser(agent, sharedChannelIds, currentPubkey)) { + continue; + } + targets.set(pubkey, { + pubkey: agent.pubkey, + name: agent.name, + source: "relay", + }); + } + + const agentModes = [...targets.values()] + .sort((left, right) => left.name.localeCompare(right.name)) + .map( + (target): DevComposerMode => ({ + kind: "agent", + target, + }), + ); + + return [{ kind: "chat" } satisfies DevComposerMode, ...agentModes]; + }, [channels, currentPubkey, managedAgents, relayAgents]); +} diff --git a/desktop/src/features/dev-mode/lib/useDevModeShortcuts.ts b/desktop/src/features/dev-mode/lib/useDevModeShortcuts.ts new file mode 100644 index 0000000000..b2a58952fc --- /dev/null +++ b/desktop/src/features/dev-mode/lib/useDevModeShortcuts.ts @@ -0,0 +1,100 @@ +import * as React from "react"; + +import type { Channel } from "@/shared/api/types"; + +/** + * Window-level developer mode shortcuts: + * + * - ⌘K toggles the command palette + * - ⌘N jumps to the fresh composer (new channel) + * - ⌘T drafts a side chat in the open channel + * - ⌘⇧T drafts a new tab (sub-channel) of the open main + * - ⌘[/⌘] cycle through the open channel's tabs, wrapping at the ends + */ +export function useDevModeShortcuts({ + view, + activeChannel, + activeMainChannel, + activeSubChannels, + onTogglePalette, + onNewSession, + onDraftSideChat, + onDraftTab, + onOpenChannel, +}: { + view: "fresh" | "navigator" | "channel"; + activeChannel: Channel | null; + activeMainChannel: Channel | null; + activeSubChannels: Channel[]; + onTogglePalette: () => void; + onNewSession: () => void; + /** Null when the current view has no open channel to side-chat in. */ + onDraftSideChat: (() => void) | null; + /** Null when the current view has no main channel to spawn a tab of. */ + onDraftTab: (() => void) | null; + onOpenChannel: (channelId: string) => void; +}) { + React.useEffect(() => { + // ⌘K must beat the standard UI's global ⌘K search binding (a window + // bubble listener that yields when `event.defaultPrevented`), so the + // palette toggle listens in the capture phase. + const handlePaletteKeyDown = (event: KeyboardEvent) => { + if ( + event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey && + event.key.toLowerCase() === "k" + ) { + event.preventDefault(); + onTogglePalette(); + } + }; + const handleWindowKeyDown = (event: KeyboardEvent) => { + if (!event.metaKey || event.ctrlKey || event.altKey) return; + const key = event.key.toLowerCase(); + if (event.shiftKey) { + if (key === "t" && onDraftTab) { + event.preventDefault(); + onDraftTab(); + } + return; + } + if (key === "n") { + event.preventDefault(); + onNewSession(); + } else if (key === "t" && onDraftSideChat) { + event.preventDefault(); + onDraftSideChat(); + } else if ( + (event.key === "[" || event.key === "]") && + view === "channel" && + activeChannel && + activeMainChannel + ) { + event.preventDefault(); + const tabs = [activeMainChannel, ...activeSubChannels]; + if (tabs.length < 2) return; + const index = tabs.findIndex((tab) => tab.id === activeChannel.id); + const direction = event.key === "]" ? 1 : -1; + onOpenChannel(tabs[(index + direction + tabs.length) % tabs.length].id); + } + }; + window.addEventListener("keydown", handlePaletteKeyDown, true); + window.addEventListener("keydown", handleWindowKeyDown); + return () => { + window.removeEventListener("keydown", handlePaletteKeyDown, true); + window.removeEventListener("keydown", handleWindowKeyDown); + }; + }, [ + activeChannel, + activeMainChannel, + activeSubChannels, + onDraftSideChat, + onDraftTab, + onNewSession, + onOpenChannel, + onTogglePalette, + view, + ]); +} diff --git a/desktop/src/features/dev-mode/lib/useDevSessionActions.ts b/desktop/src/features/dev-mode/lib/useDevSessionActions.ts new file mode 100644 index 0000000000..2be293028e --- /dev/null +++ b/desktop/src/features/dev-mode/lib/useDevSessionActions.ts @@ -0,0 +1,395 @@ +import * as React from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { attachManagedAgentToChannel } from "@/features/agents/channelAgents"; +import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { + channelsQueryKey, + useCreateChannelMutation, +} from "@/features/channels/hooks"; +import { useSendMessageMutation } from "@/features/messages/hooks"; +import { + buildOutgoingMessage, + type ImetaMedia, +} from "@/features/messages/lib/imetaMediaMarkdown"; +import { addChannelMembers, getCanvas, setCanvas } from "@/shared/api/tauri"; +import { updateChannel } from "@/shared/api/tauriChannels"; +import type { Channel, Identity } from "@/shared/api/types"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { generateChannelTitle } from "@/features/dev-mode/lib/channelNaming"; +import type { MentionRecord } from "@/features/dev-mode/lib/mentionRecords"; +import { uniqueChannelName } from "@/features/dev-mode/lib/sessionNaming"; +import { + appendSubChannelToParentCanvas, + parseSubChannelName, + subChannelAnnouncement, + subChannelCanvasDoc, + subChannelName, +} from "@/features/dev-mode/lib/subChannels"; +import type { + DevAgentTarget, + DevComposerMode, +} from "@/features/dev-mode/lib/useDevComposerModes"; + +/** + * Everyone in the channel sees which agent a prompt is directed at: the + * message text carries a `@Name` prefix (matching the standard composer's + * mention-text convention) unless the user already typed one. Dev mode lifts + * the prefix out of the body at render time and shows it as a "to Name" line + * under the author; the standard UI still shows the literal mention text. + */ +export function withAgentMention(prompt: string, name: string): string { + const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const alreadyMentioned = new RegExp( + `(^|\\s)@${escaped}(?=$|[\\s,.;:!?)\\]])`, + "i", + ).test(prompt); + return alreadyMentioned ? prompt : `@${name} ${prompt}`; +} + +async function ensureAgentInChannel(channelId: string, target: DevAgentTarget) { + if (target.source === "managed" && target.managedAgent) { + await attachManagedAgentToChannel(channelId, { + agent: target.managedAgent, + }); + return; + } + + const result = await addChannelMembers({ + channelId, + pubkeys: [target.pubkey], + role: "bot", + }); + const failure = result.errors.find( + (error) => normalizePubkey(error.pubkey) === normalizePubkey(target.pubkey), + ); + // "Already a member" satisfies the goal — the membership snapshot this + // check ran against can be stale right after a prior send added the agent. + if (failure && !/already a member/i.test(failure.error)) { + throw new Error(failure.error); + } +} + +export function useDevSessionActions(identity: Identity | undefined) { + const queryClient = useQueryClient(); + const createChannelMutation = useCreateChannelMutation(); + const sendMessageMutation = useSendMessageMutation(null, identity); + const managedAgentsQuery = useManagedAgentsQuery(); + const managedAgents = managedAgentsQuery.data; + + /** + * The managed agent whose harness runs the one-shot naming completion: + * the agent tagged in the composer when it is a managed one, otherwise any + * configured managed agent. Relay agents and plain chat can't run local + * completions, so they borrow the first managed agent's harness. + */ + const namingAgentPubkey = React.useCallback( + (mode: DevComposerMode | undefined): string | null => { + if (mode?.kind === "agent" && mode.target.source === "managed") { + return mode.target.pubkey; + } + return managedAgents?.[0]?.pubkey ?? null; + }, + [managedAgents], + ); + + /** + * Create the channel for a new session, named and described from the + * prompt. Creation is separate from the first send so a failure after this + * point leaves an open, recoverable session instead of a duplicate channel + * on retry. + */ + const createSessionChannel = React.useCallback( + async (prompt: string, mode?: DevComposerMode): Promise => { + const existingNames = new Set( + (queryClient.getQueryData(channelsQueryKey) ?? []).map( + (channel) => channel.name, + ), + ); + + // Neutral placeholder, never a prompt slug: the name is replaced by an + // agent-generated title, and a lingering "new-session" makes a naming + // failure visible instead of masquerading as a generated title. + const channel = await createChannelMutation.mutateAsync({ + name: uniqueChannelName("new-session", existingNames), + channelType: "stream", + visibility: "open", + description: prompt.length > 140 ? `${prompt.slice(0, 139)}…` : prompt, + }); + + // LLM naming is best-effort and never blocks the session: the channel + // opens under its placeholder name and is renamed when a title arrives. + void (async () => { + const title = await generateChannelTitle( + prompt, + namingAgentPubkey(mode), + ); + if (!title) { + console.warn( + `dev-mode: channel naming failed for ${channel.id}; keeping placeholder`, + ); + return; + } + if (title === channel.name) return; + const currentNames = new Set( + (queryClient.getQueryData(channelsQueryKey) ?? []) + .filter((candidate) => candidate.id !== channel.id) + .map((candidate) => candidate.name), + ); + try { + await updateChannel({ + channelId: channel.id, + name: uniqueChannelName(title, currentNames), + }); + await queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + } catch (error) { + // Rename failing leaves the placeholder name, which is still valid. + console.warn( + `dev-mode: channel rename failed for ${channel.id}`, + error, + ); + } + })(); + + return channel; + }, + [createChannelMutation, namingAgentPubkey, queryClient], + ); + + const cachedChannels = React.useCallback( + () => queryClient.getQueryData(channelsQueryKey) ?? [], + [queryClient], + ); + + /** The parent channel when `channel` is a `parent--sub`, else null. */ + const findParentChannel = React.useCallback( + (channel: Channel): Channel | null => { + const parsed = parseSubChannelName(channel.name); + if (!parsed) return null; + return ( + cachedChannels().find( + (candidate) => + candidate.name === parsed.parentName && candidate.id !== channel.id, + ) ?? null + ); + }, + [cachedChannels], + ); + + /** + * Spawn a sub-channel of `parent` for a task (mirrors `buzz channels + * create --parent`): the child inherits the parent's type/visibility and + * is named `parent--`. The channel opens immediately under a + * placeholder slug; naming, the parent announcement, and the + * relationship canvases follow best-effort in the background — + * announcement and canvases wait for the rename so they reference the + * final name. + */ + const createSubChannel = React.useCallback( + async ( + parent: Channel, + prompt: string, + mode?: DevComposerMode, + ): Promise => { + const existingNames = new Set( + cachedChannels().map((channel) => channel.name), + ); + const channel = await createChannelMutation.mutateAsync({ + name: uniqueChannelName( + subChannelName(parent.name, "new-sub"), + existingNames, + ), + // Inherit the parent's type/visibility; DMs can't have subs, so + // anything non-forum becomes a stream (dev sessions are streams). + channelType: parent.channelType === "forum" ? "forum" : "stream", + visibility: parent.visibility, + description: prompt.length > 140 ? `${prompt.slice(0, 139)}…` : prompt, + }); + + void (async () => { + let finalName = channel.name; + const title = await generateChannelTitle( + prompt, + namingAgentPubkey(mode), + ); + if (title) { + const currentNames = new Set( + cachedChannels() + .filter((candidate) => candidate.id !== channel.id) + .map((candidate) => candidate.name), + ); + const candidate = uniqueChannelName( + subChannelName(parent.name, title), + currentNames, + ); + try { + await updateChannel({ channelId: channel.id, name: candidate }); + finalName = candidate; + await queryClient.invalidateQueries({ queryKey: channelsQueryKey }); + } catch (error) { + console.warn( + `dev-mode: sub-channel rename failed for ${channel.id}`, + error, + ); + } + } + + try { + const announcement = await sendMessageMutation.mutateAsync({ + targetChannel: parent, + content: subChannelAnnouncement(finalName), + }); + await setCanvas({ + channelId: channel.id, + content: subChannelCanvasDoc({ + parentName: parent.name, + parentId: parent.id, + announcementEventId: announcement.id, + task: prompt, + }), + }); + const parentCanvas = await getCanvas(parent.id); + await setCanvas({ + channelId: parent.id, + content: appendSubChannelToParentCanvas( + parentCanvas.content, + finalName, + prompt, + ), + }); + } catch (error) { + console.warn( + `dev-mode: sub-channel announcement/canvas setup failed for ${channel.id}`, + error, + ); + } + })(); + + return channel; + }, + [ + cachedChannels, + createChannelMutation, + namingAgentPubkey, + queryClient, + sendMessageMutation, + ], + ); + + /** + * Send a prompt into a session, optionally as a reply inside an existing + * thread (`parentEventId`). In an agent mode, the agent is attached first + * when it is not yet a member (membership must land before the mention or + * the harness filter drops it) — agents are not limited to a single + * channel. + */ + const sendToSession = React.useCallback( + async ( + channel: Channel, + prompt: string, + mode: DevComposerMode, + parentEventId?: string, + mentions: MentionRecord[] = [], + media: ImetaMedia[] = [], + ) => { + // Sub-channel invariant (client-side; the relay knows nothing about + // sub-channels): everyone in `parent--sub` must belong to `parent`. + const parentChannel = findParentChannel(channel); + const parentMemberPubkeys = parentChannel + ? new Set( + parentChannel.memberPubkeys.map((pubkey) => + normalizePubkey(pubkey), + ), + ) + : null; + + if (mode.kind === "agent") { + const agentPubkey = normalizePubkey(mode.target.pubkey); + if (parentChannel && !parentMemberPubkeys?.has(agentPubkey)) { + await ensureAgentInChannel(parentChannel.id, mode.target); + parentMemberPubkeys?.add(agentPubkey); + } + const isMember = channel.memberPubkeys.some( + (pubkey) => normalizePubkey(pubkey) === agentPubkey, + ); + if (!isMember) { + await ensureAgentInChannel(channel.id, mode.target); + } + } + + // Tagging someone pulls them into the channel (mirroring how the + // targeted agent is auto-attached); best-effort so a failed add never + // blocks the send — the p tag still goes out. + const memberPubkeys = new Set( + channel.memberPubkeys.map((pubkey) => normalizePubkey(pubkey)), + ); + if (mode.kind === "agent") { + memberPubkeys.add(normalizePubkey(mode.target.pubkey)); + } + let nonMembers = mentions.filter( + (mention) => !memberPubkeys.has(normalizePubkey(mention.pubkey)), + ); + if (parentMemberPubkeys) { + const outsiders = nonMembers.filter( + (mention) => + !parentMemberPubkeys.has(normalizePubkey(mention.pubkey)), + ); + if (outsiders.length > 0) { + console.warn( + `dev-mode: skipping auto-add of ${outsiders.length} mention(s) not in the parent channel of ${channel.name}`, + ); + nonMembers = nonMembers.filter((mention) => + parentMemberPubkeys.has(normalizePubkey(mention.pubkey)), + ); + } + } + for (const role of ["member", "bot"] as const) { + const pubkeys = nonMembers + .filter((mention) => (role === "bot") === mention.isAgent) + .map((mention) => mention.pubkey); + if (pubkeys.length === 0) continue; + try { + await addChannelMembers({ channelId: channel.id, pubkeys, role }); + } catch (addError) { + console.warn( + `dev-mode: failed to add mentioned ${role}s to ${channel.id}:`, + addError, + ); + } + } + + const mentionPubkeys = [ + ...new Set([ + ...(mode.kind === "agent" ? [mode.target.pubkey] : []), + ...mentions.map((mention) => mention.pubkey), + ]), + ]; + + // Attachments append `![image|video](url)` lines to the body (the + // renderer keys on URLs literally present in the content) and ride as + // NIP-92 imeta tags — the same wire shape the standard composer sends. + const { content, mediaTags } = buildOutgoingMessage( + mode.kind === "agent" + ? withAgentMention(prompt, mode.target.name) + : prompt, + media, + ); + + return await sendMessageMutation.mutateAsync({ + targetChannel: channel, + content, + mentionPubkeys: mentionPubkeys.length > 0 ? mentionPubkeys : undefined, + parentEventId: parentEventId ?? null, + mediaTags, + }); + }, + [findParentChannel, sendMessageMutation], + ); + + return { + createSessionChannel, + createSubChannel, + sendToSession, + isCreatingChannel: createChannelMutation.isPending, + }; +} diff --git a/desktop/src/features/dev-mode/lib/useMemberNameResolver.ts b/desktop/src/features/dev-mode/lib/useMemberNameResolver.ts new file mode 100644 index 0000000000..effb0c37cf --- /dev/null +++ b/desktop/src/features/dev-mode/lib/useMemberNameResolver.ts @@ -0,0 +1,55 @@ +import * as React from "react"; + +import { useChannelMembersQuery } from "@/features/channels/hooks"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { truncatePubkey } from "@/shared/lib/pubkey"; + +export type NameResolver = (pubkey: string) => string; + +export type AgentResolver = (pubkey: string) => boolean; + +/** Whether a pubkey belongs to an agent member of the channel. */ +export function useMemberAgentResolver(channelId: string): AgentResolver { + const membersQuery = useChannelMembersQuery(channelId); + return React.useCallback( + (pubkey) => + membersQuery.data?.some( + (candidate) => + candidate.pubkey === pubkey && + (candidate.isAgent || candidate.role === "bot"), + ) ?? false, + [membersQuery.data], + ); +} + +/** + * Resolves names from the channel's member list, with a batch profile + * lookup as fallback for `extraPubkeys` that are no longer members — + * membership rows name people who already left the channel. + */ +export function useMemberNameResolver( + channelId: string, + extraPubkeys: readonly string[] = [], +): NameResolver { + const membersQuery = useChannelMembersQuery(channelId); + const members = membersQuery.data; + + const unknownPubkeys = React.useMemo( + () => + extraPubkeys.filter( + (pubkey) => !members?.some((candidate) => candidate.pubkey === pubkey), + ), + [extraPubkeys, members], + ); + const profilesQuery = useUsersBatchQuery(unknownPubkeys); + + return React.useCallback( + (pubkey) => { + const member = members?.find((candidate) => candidate.pubkey === pubkey); + if (member?.displayName) return member.displayName; + const profile = profilesQuery.data?.profiles[pubkey.toLowerCase()]; + return profile?.displayName || profile?.name || truncatePubkey(pubkey); + }, + [members, profilesQuery.data], + ); +} diff --git a/desktop/src/features/dev-mode/lib/useMentionAutocomplete.ts b/desktop/src/features/dev-mode/lib/useMentionAutocomplete.ts new file mode 100644 index 0000000000..246e902e13 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/useMentionAutocomplete.ts @@ -0,0 +1,245 @@ +import * as React from "react"; + +import { useChannelMembersQuery } from "@/features/channels/hooks"; +import { + extractMentions, + type MentionRecord, +} from "@/features/dev-mode/lib/mentionRecords"; +import { useUserSearchQuery } from "@/features/profile/hooks"; +import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery"; +import { normalizePubkey } from "@/shared/lib/pubkey"; + +const MAX_SUGGESTIONS = 6; + +export type MentionSuggestion = MentionRecord & { + isMember: boolean; +}; + +/** + * `@user` autocomplete for a dev-mode composer textarea, mirroring the + * `#channel` autocomplete's keyboard contract: typing `@` plus a prefix at + * the caret opens suggestions; ↑/↓ move, Tab/Enter accept (inserting + * `@Display Name `), Escape dismisses. Channel members rank first, then + * relay-wide `search_users` results. `extract` maps `@Name`s still present + * in the text back to pubkeys at send time. + */ +export function useMentionAutocomplete({ + channelId, + selfPubkey, + value, + onChange, + textareaRef, +}: { + channelId: string | null; + selfPubkey: string | null; + value: string; + onChange: (value: string) => void; + textareaRef: React.RefObject; +}) { + const membersQuery = useChannelMembersQuery(channelId); + const [cursor, setCursor] = React.useState(0); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const [dismissed, setDismissed] = React.useState(false); + + const memberSuggestions = React.useMemo( + () => + (membersQuery.data ?? []).flatMap((member) => + member.displayName + ? [ + { + pubkey: member.pubkey, + name: member.displayName, + isAgent: member.isAgent || member.role === "bot", + isMember: true, + }, + ] + : [], + ), + [membersQuery.data], + ); + + // Every relay user seen this composer session, so multi-word names keep + // the query open across spaces (and suggestions survive the moment a new + // search key is still loading). + const [seenRelayUsers, setSeenRelayUsers] = React.useState< + ReadonlyMap + >(new Map()); + + const knownNamesLower = React.useMemo( + () => [ + ...memberSuggestions.map((suggestion) => suggestion.name.toLowerCase()), + ...[...seenRelayUsers.values()].map((suggestion) => + suggestion.name.toLowerCase(), + ), + ], + [memberSuggestions, seenRelayUsers], + ); + + const detection = React.useMemo( + () => detectPrefixQuery("@", value, cursor, knownNamesLower), + [cursor, knownNamesLower, value], + ); + + const searchQuery = useUserSearchQuery(detection?.query ?? "", { + enabled: detection !== null, + limit: 8, + }); + + const searchResults = searchQuery.data; + React.useEffect(() => { + if (!searchResults?.length) return; + setSeenRelayUsers((current) => { + let next: Map | null = null; + for (const user of searchResults) { + const name = user.displayName?.trim(); + if (!name) continue; + const key = normalizePubkey(user.pubkey); + if (current.has(key)) continue; + if (!next) next = new Map(current); + next.set(key, { + pubkey: user.pubkey, + name, + isAgent: user.isAgent, + isMember: false, + }); + } + return next ?? current; + }); + }, [searchResults]); + + const suggestions = React.useMemo(() => { + if (!detection) return []; + const candidates: MentionSuggestion[] = [ + ...memberSuggestions, + ...(searchResults ?? []).flatMap((user) => { + const name = user.displayName?.trim(); + return name + ? [ + { + pubkey: user.pubkey, + name, + isAgent: user.isAgent, + isMember: false, + }, + ] + : []; + }), + ...seenRelayUsers.values(), + ]; + const needle = detection.query.toLowerCase(); + const seen = new Set(); + const starts: MentionSuggestion[] = []; + const contains: MentionSuggestion[] = []; + for (const candidate of candidates) { + const key = normalizePubkey(candidate.pubkey); + if (seen.has(key)) continue; + if (selfPubkey && key === normalizePubkey(selfPubkey)) continue; + const name = candidate.name.toLowerCase(); + if (name.startsWith(needle)) { + starts.push(candidate); + } else if (name.includes(needle)) { + contains.push(candidate); + } else { + continue; + } + seen.add(key); + } + return [...starts, ...contains].slice(0, MAX_SUGGESTIONS); + }, [detection, memberSuggestions, searchResults, seenRelayUsers, selfPubkey]); + + const open = !dismissed && suggestions.length > 0; + + // A fresh query (typing/caret movement) clears the selection and any + // Escape dismissal from the previous query. + const queryKey = detection + ? `${detection.startIndex}:${detection.query}` + : null; + const previousQueryKeyRef = React.useRef(queryKey); + if (previousQueryKeyRef.current !== queryKey) { + previousQueryKeyRef.current = queryKey; + if (selectedIndex !== 0) setSelectedIndex(0); + if (dismissed) setDismissed(false); + } + + const syncCursor = React.useCallback((target: HTMLTextAreaElement) => { + setCursor(target.selectionStart ?? target.value.length); + }, []); + + // Accepted suggestions, so `extract` can map typed `@Name`s back to + // pubkeys — relay users aren't channel members yet. + const acceptedRef = React.useRef(new Map()); + + const accept = React.useCallback( + (suggestion: MentionSuggestion) => { + if (!detection) return; + const before = value.slice(0, detection.startIndex); + const inserted = `@${suggestion.name} `; + const next = before + inserted + value.slice(cursor); + onChange(next); + acceptedRef.current.set(normalizePubkey(suggestion.pubkey), { + name: suggestion.name, + pubkey: suggestion.pubkey, + isAgent: suggestion.isAgent, + }); + const caret = before.length + inserted.length; + setCursor(caret); + requestAnimationFrame(() => { + const textarea = textareaRef.current; + // Skip if the user already typed past the accept — repositioning + // now would drop their keystrokes into the middle of the text. + if (!textarea || textarea.value !== next) return; + textarea.setSelectionRange(caret, caret); + }); + }, + [cursor, detection, onChange, textareaRef, value], + ); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent): boolean => { + if (!open) return false; + // Modified keys belong to composer shortcuts (e.g. ⌥↑ channel step). + if (event.altKey || event.metaKey || event.ctrlKey) return false; + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + event.preventDefault(); + const delta = event.key === "ArrowUp" ? -1 : 1; + setSelectedIndex( + (current) => + (current + delta + suggestions.length) % suggestions.length, + ); + return true; + } + if (event.key === "Tab" || event.key === "Enter") { + event.preventDefault(); + accept(suggestions[selectedIndex] ?? suggestions[0]); + return true; + } + if (event.key === "Escape") { + event.preventDefault(); + setDismissed(true); + return true; + } + return false; + }, + [accept, open, selectedIndex, suggestions], + ); + + /** Mentions still present in the text: accepted users + channel members. */ + const extract = React.useCallback( + (text: string): MentionRecord[] => + extractMentions(text, [ + ...acceptedRef.current.values(), + ...memberSuggestions, + ]), + [memberSuggestions], + ); + + return { + open, + suggestions, + selectedIndex, + accept, + handleKeyDown, + syncCursor, + extract, + }; +} diff --git a/desktop/src/features/dev-mode/lib/useNavigatorWidth.ts b/desktop/src/features/dev-mode/lib/useNavigatorWidth.ts new file mode 100644 index 0000000000..df68e2529d --- /dev/null +++ b/desktop/src/features/dev-mode/lib/useNavigatorWidth.ts @@ -0,0 +1,90 @@ +import * as React from "react"; + +const STORAGE_KEY = "buzz.devMode.channelNavigatorWidth"; +const MIN_PX = 200; +const MAX_PX = 480; +const DEFAULT_PX = 288; + +function clamp(raw: number): number { + return Math.min(MAX_PX, Math.max(MIN_PX, raw)); +} + +function readStoredWidth(): number { + try { + const raw = globalThis.localStorage?.getItem(STORAGE_KEY); + const value = raw === null || raw === undefined ? Number.NaN : Number(raw); + if (Number.isFinite(value)) { + return clamp(value); + } + } catch { + // Fall through to the default width. + } + return DEFAULT_PX; +} + +function persistWidth(width: number) { + try { + globalThis.localStorage?.setItem(STORAGE_KEY, String(width)); + } catch { + // Persistence is best-effort. + } +} + +export type NavigatorWidthControls = ReturnType; + +/** + * Persisted, pointer-draggable width for the channel navigator (clamped + * 200–480px). Spread `dividerProps` on a vertical separator at the + * navigator's right edge; Left/Right arrows nudge the width when focused. + */ +export function useNavigatorWidth() { + const [width, setWidth] = React.useState(readStoredWidth); + const [dragging, setDragging] = React.useState(false); + const dragStart = React.useRef<{ x: number; width: number } | null>(null); + + const handlePointerDown = (event: React.PointerEvent) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + dragStart.current = { x: event.clientX, width }; + setDragging(true); + }; + + const handlePointerMove = (event: React.PointerEvent) => { + const start = dragStart.current; + if (!dragging || !start) return; + setWidth(clamp(start.width + (event.clientX - start.x))); + }; + + const handlePointerUp = (event: React.PointerEvent) => { + if (!dragging) return; + event.currentTarget.releasePointerCapture(event.pointerId); + setDragging(false); + dragStart.current = null; + setWidth((current) => { + persistWidth(current); + return current; + }); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return; + event.preventDefault(); + const delta = event.key === "ArrowLeft" ? -16 : 16; + setWidth((current) => { + const next = clamp(current + delta); + persistWidth(next); + return next; + }); + }; + + return { + width, + dragging, + dividerProps: { + onPointerDown: handlePointerDown, + onPointerMove: handlePointerMove, + onPointerUp: handlePointerUp, + onKeyDown: handleKeyDown, + }, + }; +} diff --git a/desktop/src/features/dev-mode/lib/usePageTitle.ts b/desktop/src/features/dev-mode/lib/usePageTitle.ts new file mode 100644 index 0000000000..4ed403d231 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/usePageTitle.ts @@ -0,0 +1,60 @@ +import * as React from "react"; + +import { invokeTauri } from "@/shared/api/tauri"; + +/** + * Module-level cache so each URL is fetched once per app session no matter + * how many messages mention it (same pattern as `useResolvedLinkPreviews`). + */ +const titleCache = new Map | string | null>(); + +function cacheTitle(href: string): Promise { + const cached = titleCache.get(href); + if (cached instanceof Promise) return cached; + if (cached !== undefined) return Promise.resolve(cached); + + // Authenticated sources first: Slack permalinks and private Google files + // resolve through `sq agent-tools` into labels like "joah in #general" or + // the doc's real title. Anything unresolved falls back to the generic + // HTML page-title fetch. + const promise = invokeTauri("fetch_agent_link_label", { href }) + .catch(() => null) + .then( + (label) => + label ?? + invokeTauri("fetch_page_title", { href }).catch( + () => null, + ), + ) + .then((title) => { + titleCache.set(href, title); + return title; + }); + titleCache.set(href, promise); + return promise; +} + +/** + * Best-effort page title for a URL, or null while loading / when the page + * can't be fetched (auth walls, non-HTML, timeouts). A null href skips the + * fetch entirely (the caller already has display text). + */ +export function usePageTitle(href: string | null): string | null { + const cached = href === null ? undefined : titleCache.get(href); + const [title, setTitle] = React.useState( + typeof cached === "string" ? cached : null, + ); + + React.useEffect(() => { + if (href === null) return; + let cancelled = false; + void cacheTitle(href).then((resolved) => { + if (!cancelled && resolved) setTitle(resolved); + }); + return () => { + cancelled = true; + }; + }, [href]); + + return title; +} diff --git a/desktop/src/features/dev-mode/lib/usePinnedScroll.ts b/desktop/src/features/dev-mode/lib/usePinnedScroll.ts new file mode 100644 index 0000000000..239e4f6b16 --- /dev/null +++ b/desktop/src/features/dev-mode/lib/usePinnedScroll.ts @@ -0,0 +1,48 @@ +import * as React from "react"; + +/** Distance from the bottom (px) within which the view stays pinned. */ +const PIN_THRESHOLD = 48; + +/** + * Keep a scroll container pinned to the bottom while its content grows + * (live agent output, replies loading in), unless the user scrolled up. + * A resetKey change (channel or thread switch) re-pins the view. + */ +export function usePinnedScroll(resetKey: string) { + const scrollRef = React.useRef(null); + const contentRef = React.useRef(null); + const pinnedRef = React.useRef(true); + + const handleScroll = React.useCallback(() => { + const node = scrollRef.current; + if (!node) return; + pinnedRef.current = + node.scrollHeight - node.scrollTop - node.clientHeight < PIN_THRESHOLD; + }, []); + + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — a resetKey change re-pins the view to the bottom + React.useLayoutEffect(() => { + const node = scrollRef.current; + if (!node) return; + node.scrollTop = node.scrollHeight; + pinnedRef.current = true; + }, [resetKey]); + + React.useEffect(() => { + const content = contentRef.current; + const scroller = scrollRef.current; + if (!content || !scroller) return; + const observer = new ResizeObserver(() => { + if (pinnedRef.current) { + scroller.scrollTop = scroller.scrollHeight; + } + }); + observer.observe(content); + // The scroller itself resizes when the composer grows (newlines, drag) + // or the split moves — a pinned view must stay glued to the bottom. + observer.observe(scroller); + return () => observer.disconnect(); + }, []); + + return { scrollRef, contentRef, handleScroll }; +} diff --git a/desktop/src/features/dev-mode/lib/useShellFocusGuards.ts b/desktop/src/features/dev-mode/lib/useShellFocusGuards.ts new file mode 100644 index 0000000000..74be647b4a --- /dev/null +++ b/desktop/src/features/dev-mode/lib/useShellFocusGuards.ts @@ -0,0 +1,96 @@ +import * as React from "react"; + +/** + * Keeps the keyboard anchored to a text input across window refocus and + * shell clicks: + * + * - Refocusing the window restores whichever text input last had the + * keyboard (main composer, side-chat composer, palette search), falling + * back to the composer if it unmounted meanwhile. + * - Clicks on non-interactive chrome must not blur the active input — this + * also covers the click that refocuses the window landing on dead space. + * Transcript areas opt out (data-allow-text-selection) so message text + * stays drag-selectable; the mouseup handler restores focus afterwards + * when the click ended without selecting text. + * + * While card selection owns the keyboard, nothing puts the caret back in a + * box. + */ +export function useShellFocusGuards({ + cardSelectionActive, + focusComposer, +}: { + cardSelectionActive: boolean; + focusComposer: () => void; +}) { + const lastFocusedRef = React.useRef(null); + + const handleFocusCapture = React.useCallback( + (event: React.FocusEvent) => { + const target = event.target; + if ( + target instanceof HTMLElement && + target.matches("textarea, input, [contenteditable='true']") + ) { + lastFocusedRef.current = target; + } + }, + [], + ); + + React.useEffect(() => { + const handleWindowFocus = () => { + if (cardSelectionActive) return; + const last = lastFocusedRef.current; + if (last?.isConnected) { + last.focus(); + } else { + focusComposer(); + } + }; + window.addEventListener("focus", handleWindowFocus); + return () => window.removeEventListener("focus", handleWindowFocus); + }, [cardSelectionActive, focusComposer]); + + const handleShellMouseDown = React.useCallback( + (event: React.MouseEvent) => { + const target = event.target; + if ( + target instanceof HTMLElement && + !target.closest( + "button, a, input, textarea, select, [role='button'], [role='separator'], [contenteditable='true'], [data-allow-text-selection]", + ) + ) { + event.preventDefault(); + } + }, + [], + ); + + const handleShellMouseUp = React.useCallback( + (event: React.MouseEvent) => { + const target = event.target; + if ( + !(target instanceof HTMLElement) || + !target.closest("[data-allow-text-selection]") || + target.closest("button, a, input, textarea, [contenteditable='true']") + ) { + return; + } + if (cardSelectionActive) return; + requestAnimationFrame(() => { + const selection = window.getSelection(); + if (selection && !selection.isCollapsed) return; + const last = lastFocusedRef.current; + if (last?.isConnected) { + last.focus(); + } else { + focusComposer(); + } + }); + }, + [cardSelectionActive, focusComposer], + ); + + return { handleFocusCapture, handleShellMouseDown, handleShellMouseUp }; +} diff --git a/desktop/src/features/dev-mode/lib/useUnreadRouting.ts b/desktop/src/features/dev-mode/lib/useUnreadRouting.ts new file mode 100644 index 0000000000..3df68f4c7d --- /dev/null +++ b/desktop/src/features/dev-mode/lib/useUnreadRouting.ts @@ -0,0 +1,149 @@ +import * as React from "react"; + +import { useAppShell } from "@/app/AppShellContext"; +import { useChannelMembersQuery } from "@/features/channels/hooks"; +import type { SubChannelIndex } from "@/features/dev-mode/lib/subChannels"; +import { + byCreatedAscending, + DEV_MESSAGE_KINDS, + selectInlineVisibleCount, +} from "@/features/dev-mode/lib/transcriptRoots"; +import { findUnreadFamilyTarget } from "@/features/dev-mode/lib/unreadThreads"; +import { useThreadReplies } from "@/features/messages/useThreadReplies"; +import type { Channel, RelayEvent } from "@/shared/api/types"; + +/** + * Opening a channel from its navigator row routes to what needs attention: + * the family's unread tab. The unread thread's side chat opens only when + * its unread replies are collapsed — hidden behind the "… N more replies" + * affordance because a human already responded in the thread. Unread + * replies that render inline in the main chat view (the leading agent run) + * are marked read just by looking at the channel, so no side chat is + * forced open for them. + * + * The decision needs the thread's subtree and the channel's member roles, + * so it is deferred until both load; any navigation elsewhere makes the + * deferred decision stale. + * + * Returns the routed open. Explicit destinations (tab clicks, palette, + * ⌘[/⌘], ⌥↑↓) keep using the plain open and land exactly where asked. + */ +export function useUnreadRouting({ + subIndex, + unreadChannelIds, + topLevelUnreadChannelIds, + activeChannel, + roots, + openChannel, + openThread, +}: { + subIndex: SubChannelIndex; + unreadChannelIds: ReadonlySet; + topLevelUnreadChannelIds: ReadonlySet; + /** The channel whose transcript (roots) is currently loaded. */ + activeChannel: Channel | null; + roots: readonly RelayEvent[]; + openChannel: (channelId: string) => void; + /** Select the root's card and open its side chat. */ + openThread: (rootId: string) => void; +}): (channelId: string) => void { + const { getThreadReadAt, threadActivityItems } = useAppShell(); + const activeChannelId = activeChannel?.id ?? null; + + const [pending, setPending] = React.useState<{ + channelId: string; + rootId: string; + } | null>(null); + + React.useEffect(() => { + if (pending && pending.channelId !== activeChannelId) { + setPending(null); + } + }, [activeChannelId, pending]); + + // Shares the transcript's thread-subtree and member query caches, so the + // routed channel is fetching these anyway. + const pendingChannel = + pending && activeChannelId === pending.channelId ? activeChannel : null; + const repliesQuery = useThreadReplies( + pendingChannel, + pending?.rootId ?? null, + ); + const membersQuery = useChannelMembersQuery(pendingChannel?.id ?? null); + + // Complete the routing once the target root, its subtree, and the member + // roles are loaded. Declared after the shell's selection-reset effect + // runs for the channel switch, so the reset cannot clobber the opened + // side chat. + React.useEffect(() => { + if (!pending || pending.channelId !== activeChannelId) return; + if (!roots.some((root) => root.id === pending.rootId)) return; + const subtree = repliesQuery.data; + const members = membersQuery.data; + if (subtree === undefined || members === undefined) return; + + const replies = subtree + .filter((event) => DEV_MESSAGE_KINDS.has(event.kind)) + .sort(byCreatedAscending); + const isAgent = (pubkey: string) => + members.some( + (member) => + member.pubkey === pubkey && (member.isAgent || member.role === "bot"), + ); + const visibleCount = selectInlineVisibleCount(replies, isAgent); + const readAt = getThreadReadAt(pending.rootId, pending.channelId); + const hasCollapsedUnread = replies + .slice(visibleCount) + .some((reply) => readAt === null || reply.created_at > readAt); + + setPending(null); + if (hasCollapsedUnread) { + openThread(pending.rootId); + } + }, [ + activeChannelId, + getThreadReadAt, + membersQuery.data, + openThread, + pending, + repliesQuery.data, + roots, + ]); + + return React.useCallback( + (channelId: string) => { + const mainId = subIndex.parentIdByChildId.get(channelId) ?? channelId; + const target = findUnreadFamilyTarget({ + familyChannelIds: [ + mainId, + ...(subIndex.subsByParentId.get(mainId) ?? []).map((sub) => sub.id), + ], + unreadChannelIds, + topLevelUnreadChannelIds, + threadActivity: threadActivityItems, + getThreadReadAt, + }); + if (!target) { + openChannel(channelId); + return; + } + if (target.channelId !== activeChannelId) { + openChannel(target.channelId); + } + setPending( + target.rootId + ? { channelId: target.channelId, rootId: target.rootId } + : null, + ); + }, + [ + activeChannelId, + getThreadReadAt, + openChannel, + subIndex, + threadActivityItems, + topLevelUnreadChannelIds, + unreadChannelIds, + ], + ); +} diff --git a/desktop/src/features/dev-mode/ui/DevChannelMembers.tsx b/desktop/src/features/dev-mode/ui/DevChannelMembers.tsx new file mode 100644 index 0000000000..f307c21fe9 --- /dev/null +++ b/desktop/src/features/dev-mode/ui/DevChannelMembers.tsx @@ -0,0 +1,54 @@ +import { useChannelMembersQuery } from "@/features/channels/hooks"; +import { useAuthorColorResolver } from "@/features/dev-mode/lib/authorColors"; +import type { Channel } from "@/shared/api/types"; +import { truncatePubkey } from "@/shared/lib/pubkey"; + +/** + * Above this, the top bar collapses to a member count — channels can have + * 1000+ members, and a name list neither fits nor deserves a roster fetch. + */ +export const MAX_INLINE_MEMBERS = 4; + +/** Top-bar member summary; click opens the palette's member browser. */ +export function DevChannelMembers({ + channel, + onShowMembers, +}: { + channel: Channel; + onShowMembers: () => void; +}) { + const showNames = + channel.memberCount > 0 && channel.memberCount <= MAX_INLINE_MEMBERS; + const membersQuery = useChannelMembersQuery(channel.id, showNames); + const resolveColor = useAuthorColorResolver(); + + if (channel.memberCount === 0) return null; + + const members = showNames ? (membersQuery.data ?? []) : []; + + return ( + + ); +} diff --git a/desktop/src/features/dev-mode/ui/DevChannelNavigator.tsx b/desktop/src/features/dev-mode/ui/DevChannelNavigator.tsx new file mode 100644 index 0000000000..df61e29a80 --- /dev/null +++ b/desktop/src/features/dev-mode/ui/DevChannelNavigator.tsx @@ -0,0 +1,184 @@ +import * as React from "react"; + +import { + type ChannelGroup, + toggleChannelPinned, +} from "@/features/dev-mode/lib/pinnedChannels"; +import type { NavigatorWidthControls } from "@/features/dev-mode/lib/useNavigatorWidth"; +import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; + +function formatRelativeTime(iso: string | null) { + if (!iso) return ""; + const deltaSeconds = Math.max( + 0, + Math.floor((Date.now() - new Date(iso).getTime()) / 1_000), + ); + if (deltaSeconds < 60) return "now"; + if (deltaSeconds < 3_600) return `${Math.floor(deltaSeconds / 60)}m`; + if (deltaSeconds < 86_400) return `${Math.floor(deltaSeconds / 3_600)}h`; + return `${Math.floor(deltaSeconds / 86_400)}d`; +} + +function ChannelRow({ + channel, + isHighlighted, + isPinned, + isUnread, + onOpen, +}: { + channel: Channel; + isHighlighted: boolean; + isPinned: boolean; + isUnread: boolean; + onOpen: (channelId: string) => void; +}) { + const scrollHighlightedIntoView = React.useCallback( + (node: HTMLDivElement | null) => { + node?.scrollIntoView({ block: "nearest" }); + }, + [], + ); + + return ( +
+ + +
+ ); +} + +/** + * Always-visible left channel list. The shell owns which channel is + * highlighted (↑/↓), what Enter/Escape do, and the draggable width (shared + * with the top bar so the header columns stay aligned); this renders a + * pinned section on top and all other chats beneath — both ordered by last + * activity, most recent first — with unread indicators and per-row pin + * toggles. Clicking a row opens the channel immediately. + */ +export function DevChannelNavigator({ + groups, + unreadChannelIds, + highlightedId, + dimmed, + widthControls, + onOpen, +}: { + /** Render-ordered groups; within each, most recent activity renders first. */ + groups: ChannelGroup[]; + unreadChannelIds: ReadonlySet; + highlightedId: string | null; + /** True while a channel is focused — the list stays visible but recedes. */ + dimmed: boolean; + /** Lifted to the shell so the top bar can mirror the navigator width. */ + widthControls: NavigatorWidthControls; + onOpen: (channelId: string) => void; +}) { + const isEmpty = groups.every((group) => group.channels.length === 0); + const { width, dragging, dividerProps } = widthControls; + + return ( +
+
+
+ {isEmpty ? ( +
+ no sessions yet +
+ ) : null} + {groups.map((group) => ( +
+ {group.pinned ? ( +
+ pinned +
+ ) : groups.length > 1 ? ( +
+ ) : null} + {group.channels.map((channel) => ( + + ))} +
+ ))} +
+
+ {/* biome-ignore lint/a11y/useSemanticElements:
cannot host the drag/keyboard resize handlers of a movable separator */} +
+
+ ); +} diff --git a/desktop/src/features/dev-mode/ui/DevChannelSuggestions.tsx b/desktop/src/features/dev-mode/ui/DevChannelSuggestions.tsx new file mode 100644 index 0000000000..ae8ecab8f6 --- /dev/null +++ b/desktop/src/features/dev-mode/ui/DevChannelSuggestions.tsx @@ -0,0 +1,45 @@ +import type { ChannelRef } from "@/features/dev-mode/lib/channelRefs"; +import { cn } from "@/shared/lib/cn"; + +/** + * `#channel` autocomplete popup anchored above a composer textarea. Mouse + * clicks accept without stealing focus from the textarea (mousedown is + * prevented), matching the keyboard-first composer conventions. + */ +export function DevChannelSuggestions({ + suggestions, + selectedIndex, + onAccept, +}: { + suggestions: ChannelRef[]; + selectedIndex: number; + onAccept: (suggestion: ChannelRef) => void; +}) { + return ( +
+
+ link a channel · tab/enter: insert · esc: dismiss +
+ {suggestions.map((suggestion, index) => ( + + ))} +
+ ); +} diff --git a/desktop/src/features/dev-mode/ui/DevChannelTabs.tsx b/desktop/src/features/dev-mode/ui/DevChannelTabs.tsx new file mode 100644 index 0000000000..798a353c69 --- /dev/null +++ b/desktop/src/features/dev-mode/ui/DevChannelTabs.tsx @@ -0,0 +1,94 @@ +import * as React from "react"; + +import { parseSubChannelName } from "@/features/dev-mode/lib/subChannels"; +import type { Channel } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; + +/** + * Tab strip across the top of an open channel: `main` plus one tab per + * sub-channel the user can see (surfaced to users as "tabs"). Parents can + * carry hundreds, so the strip scrolls horizontally instead of wrapping; + * the active tab scrolls itself into view. ⌘[/⌘] cycle through tabs. + */ +export function DevChannelTabs({ + main, + subs, + activeId, + unreadChannelIds, + onSelect, + onNewSubChannel, +}: { + main: Channel; + subs: Channel[]; + activeId: string; + unreadChannelIds: ReadonlySet; + onSelect: (channelId: string) => void; + onNewSubChannel: () => void; +}) { + const scrollActiveIntoView = React.useCallback( + (node: HTMLButtonElement | null) => { + node?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, + [], + ); + + const tab = (channel: Channel, label: string) => { + const isActive = channel.id === activeId; + // An active tab keeps its dot while an unread thread remains inside it — + // viewing the tab clears top-level posts, not collapsed thread replies. + const isUnread = unreadChannelIds.has(channel.id); + return ( + + ); + }; + + return ( +
+
+ {tab(main, "main")} + {subs.map((sub) => + tab(sub, parseSubChannelName(sub.name)?.subSlug ?? sub.name), + )} +
+ +
+ ); +} diff --git a/desktop/src/features/dev-mode/ui/DevCommandPalette.tsx b/desktop/src/features/dev-mode/ui/DevCommandPalette.tsx new file mode 100644 index 0000000000..d48f944cdc --- /dev/null +++ b/desktop/src/features/dev-mode/ui/DevCommandPalette.tsx @@ -0,0 +1,772 @@ +import * as React from "react"; +import { useNavigate } from "@tanstack/react-router"; + +import { useQueryClient } from "@tanstack/react-query"; + +import { attachManagedAgentToChannel } from "@/features/agents/channelAgents"; +import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { + invalidateChannelState, + useAddChannelMembersMutation, + useArchiveChannelMutation, + useChannelMembersQuery, + useJoinChannelMutation, + useLeaveChannelMutation, + useUpdateChannelMutation, +} from "@/features/channels/hooks"; +import { + AUTHOR_COLOR_PALETTE, + defaultAuthorColor, + normalizeHexColor, + setNameColorOverride, + useAuthorColorResolver, +} from "@/features/dev-mode/lib/authorColors"; +import { setDisplayStyle } from "@/features/dev-mode/lib/displayStylePreference"; +import { + toggleChannelPinned, + usePinnedChannels, +} from "@/features/dev-mode/lib/pinnedChannels"; +import { + sanitizeChannelName, + uniqueChannelName, +} from "@/features/dev-mode/lib/sessionNaming"; +import { + parseSubChannelName, + subChannelName, +} from "@/features/dev-mode/lib/subChannels"; +import { + useFlattenedUserSearchResults, + useInfiniteUserSearchQuery, +} from "@/features/profile/hooks"; +import type { SettingsSection } from "@/features/settings/ui/SettingsPanels"; +import type { Channel, UserSearchResult } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; + +type PaletteEntry = { + id: string; + label: string; + detail?: string; + /** Swatch color for color-picker entries. */ + swatch?: string; + run: () => void; +}; + +type PaletteMode = "root" | "color" | "add-member" | "members" | "rename"; + +/** + * Channels can have 1000+ members; the browser renders at most this many + * rows and tells the user to narrow by typing instead of scrolling. + */ +const MEMBERS_RENDER_CAP = 40; + +function formatCandidateName(user: UserSearchResult) { + return ( + user.displayName?.trim() || + user.nip05Handle?.trim() || + truncatePubkey(user.pubkey) + ); +} + +const SETTINGS_ENTRIES: { section: SettingsSection; label: string }[] = [ + { section: "agents", label: "configure agents" }, + { section: "appearance", label: "appearance settings" }, + { section: "profile", label: "profile settings" }, + { section: "notifications", label: "notification settings" }, + { section: "experimental", label: "experimental features" }, + { section: "channel-templates", label: "channel templates" }, + { section: "compute", label: "compute settings" }, + { section: "updates", label: "check for updates" }, +]; + +/** + * Amp-style command palette for developer mode: channel search across every + * session plus management/configuration actions. Opened with ⌘K anywhere + * in the shell, or `/` in an empty composer. + */ +export function DevCommandPalette({ + channels, + discoverableChannels, + activeChannel, + parentOfActive, + myPubkey, + initialMode = "root", + onOpenChannel, + onNewSession, + onNewSubChannel, + onChannelLeft, + onShowShortcuts, + onClose, +}: { + /** All session channels, newest first. */ + channels: Channel[]; + /** Open channels the user has not joined; searchable, enter joins. */ + discoverableChannels: Channel[]; + /** Channel that add-member/leave actions apply to (focused or previewed). */ + activeChannel: Channel | null; + /** Set when the active channel is a `parent--sub` of an existing parent. */ + parentOfActive: Channel | null; + myPubkey: string | null; + /** Open directly into a sub-view, e.g. the top bar's member count. */ + initialMode?: "root" | "members"; + onOpenChannel: (channelId: string) => void; + onNewSession: () => void; + /** Starts a sub-channel draft in the open channel; null when unavailable. */ + onNewSubChannel: (() => void) | null; + onChannelLeft: (channelId: string) => void; + /** Opens the dev-mode keyboard-shortcuts pane. */ + onShowShortcuts: () => void; + onClose: () => void; +}) { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const [query, setQuery] = React.useState(""); + const [mode, setMode] = React.useState(initialMode); + const [selectedIndex, setSelectedIndex] = React.useState(0); + const [actionError, setActionError] = React.useState(null); + const inputRef = React.useRef(null); + + React.useEffect(() => { + inputRef.current?.focus(); + }, []); + + const activeChannelId = activeChannel?.id ?? null; + const pinnedIds = usePinnedChannels(); + const addMembersMutation = useAddChannelMembersMutation(activeChannelId); + const leaveMutation = useLeaveChannelMutation(activeChannelId); + const joinMutation = useJoinChannelMutation(null); + const archiveMutation = useArchiveChannelMutation(activeChannelId); + const updateChannelMutation = useUpdateChannelMutation(activeChannelId); + const membersQuery = useChannelMembersQuery(activeChannelId); + const resolveColor = useAuthorColorResolver(); + + // The relay refuses to remove a channel's last owner, so when no other + // human would remain, "leave" archives the channel instead. + const isLastHumanMember = React.useMemo(() => { + const members = membersQuery.data; + if (!members || !myPubkey) return false; + const self = normalizePubkey(myPubkey); + return !members.some( + (member) => + normalizePubkey(member.pubkey) !== self && + !member.isAgent && + member.role !== "bot", + ); + }, [membersQuery.data, myPubkey]); + const managedAgentsQuery = useManagedAgentsQuery({ + enabled: mode === "add-member", + }); + const userSearchQuery = useInfiniteUserSearchQuery(query, { + enabled: mode === "add-member" && query.trim().length >= 2, + limit: 20, + }); + const userSearchResults = useFlattenedUserSearchResults(userSearchQuery.data); + + const openSettings = React.useCallback( + (section: SettingsSection) => { + onClose(); + void navigate({ to: "/settings", search: { section } }); + }, + [navigate, onClose], + ); + + // Close immediately and let the mutations run in the background — the + // channel hooks apply optimistic cache updates and roll back on failure, + // so the palette never blocks on a relay round trip. + const addUserToChannel = React.useCallback( + (user: UserSearchResult) => { + if (!activeChannelId) return; + setActionError(null); + // Local managed agents need a running harness pair, not just channel + // membership (see MembersSidebar) — route them through attach. + const managedAgent = (managedAgentsQuery.data ?? []).find( + (agent) => + normalizePubkey(agent.pubkey) === normalizePubkey(user.pubkey), + ); + if (managedAgent?.backend.type === "local") { + void attachManagedAgentToChannel(activeChannelId, { + agent: managedAgent, + ensureRunning: true, + }) + .catch((error) => { + console.error("Failed to attach managed agent:", error); + }) + .finally(() => { + void invalidateChannelState(queryClient, activeChannelId); + }); + } else { + addMembersMutation.mutate( + { + pubkeys: [user.pubkey], + role: user.isAgent ? "bot" : "member", + }, + { + onSuccess: (result) => { + if (result.errors.length > 0) { + console.error("Failed to add member:", result.errors[0]?.error); + } + }, + }, + ); + } + onClose(); + }, + [ + activeChannelId, + addMembersMutation, + managedAgentsQuery.data, + onClose, + queryClient, + ], + ); + + const leaveChannel = React.useCallback(() => { + if (!activeChannelId) return; + setActionError(null); + if (isLastHumanMember) { + archiveMutation.mutate(); + } else { + leaveMutation.mutate(); + } + onClose(); + onChannelLeft(activeChannelId); + }, [ + activeChannelId, + archiveMutation, + isLastHumanMember, + leaveMutation, + onChannelLeft, + onClose, + ]); + + // Renaming a sub-channel only edits its suffix (the `parent--` prefix is + // the parent link); renaming a main cascades to its subs inside + // useUpdateChannelMutation. + const renameChannel = React.useCallback( + async (rawName: string) => { + if (!activeChannel) return; + const slug = sanitizeChannelName(rawName); + if (!slug) return; + const base = parentOfActive + ? subChannelName(parentOfActive.name, slug) + : slug; + const otherNames = new Set( + channels + .filter((channel) => channel.id !== activeChannel.id) + .map((channel) => channel.name), + ); + const newName = uniqueChannelName(base, otherNames); + if (newName === activeChannel.name) { + onClose(); + return; + } + setActionError(null); + try { + await updateChannelMutation.mutateAsync({ name: newName }); + // The mutation invalidates with refetchType "none"; the mounted + // channel list must refetch now or cascaded sub renames stay stale + // and the family's tabs fall apart. + await invalidateChannelState(queryClient, activeChannel.id); + onClose(); + } catch (error) { + setActionError( + error instanceof Error ? error.message : "Failed to rename channel.", + ); + } + }, + [ + activeChannel, + channels, + onClose, + parentOfActive, + queryClient, + updateChannelMutation, + ], + ); + + const joinAndOpenChannel = React.useCallback( + (channelId: string) => { + setActionError(null); + joinMutation.mutate({ channelId }); + onOpenChannel(channelId); + onClose(); + }, + [joinMutation, onClose, onOpenChannel], + ); + + const archiveChannel = React.useCallback(() => { + if (!activeChannelId) return; + setActionError(null); + archiveMutation.mutate(); + onClose(); + onChannelLeft(activeChannelId); + }, [activeChannelId, archiveMutation, onChannelLeft, onClose]); + + const entries = React.useMemo(() => { + const needle = query.trim().toLowerCase(); + + if (mode === "color") { + const colorEntries: PaletteEntry[] = AUTHOR_COLOR_PALETTE.map( + (color) => ({ + id: `color-${color}`, + label: color, + swatch: color, + run: () => { + if (myPubkey) setNameColorOverride(myPubkey, color); + onClose(); + }, + }), + ); + const typed = normalizeHexColor(needle); + if (typed) { + colorEntries.unshift({ + id: "color-custom", + label: `use ${typed}`, + swatch: typed, + run: () => { + if (myPubkey) setNameColorOverride(myPubkey, typed); + onClose(); + }, + }); + } + colorEntries.push({ + id: "color-reset", + label: "reset to default", + swatch: myPubkey ? defaultAuthorColor(myPubkey) : undefined, + run: () => { + if (myPubkey) setNameColorOverride(myPubkey, null); + onClose(); + }, + }); + return typed + ? colorEntries + : colorEntries.filter((entry) => + entry.label.toLowerCase().includes(needle), + ); + } + + if (mode === "rename") { + if (!activeChannel) return []; + const slug = sanitizeChannelName(needle); + if (!slug) { + return [ + { + id: "rename-hint", + label: "type a new name…", + detail: `renaming # ${activeChannel.name}`, + run: () => {}, + }, + ]; + } + const preview = parentOfActive + ? subChannelName(parentOfActive.name, slug) + : slug; + return [ + { + id: "rename-apply", + label: `rename to # ${preview}`, + detail: `was # ${activeChannel.name}`, + run: () => void renameChannel(needle), + }, + ]; + } + + if (mode === "members") { + const members = membersQuery.data ?? []; + const matched = needle + ? members.filter((member) => + `${member.displayName ?? ""} ${member.pubkey}` + .toLowerCase() + .includes(needle), + ) + : members; + const memberEntries: PaletteEntry[] = matched + .slice(0, MEMBERS_RENDER_CAP) + .map((member) => ({ + id: `member-${member.pubkey}`, + label: member.displayName || truncatePubkey(member.pubkey), + detail: + member.isAgent || member.role === "bot" + ? "agent" + : member.role !== "member" + ? member.role + : truncatePubkey(member.pubkey), + swatch: resolveColor(member.pubkey), + run: () => {}, + })); + if (matched.length > MEMBERS_RENDER_CAP) { + memberEntries.push({ + id: "members-overflow", + label: `… ${matched.length - MEMBERS_RENDER_CAP} more — type to narrow`, + run: () => {}, + }); + } + return memberEntries; + } + + if (mode === "add-member") { + const memberPubkeys = new Set( + (membersQuery.data ?? []).map((member) => + normalizePubkey(member.pubkey), + ), + ); + // Sub-channel invariant: only parent members may join `parent--sub`. + const parentMemberPubkeys = parentOfActive + ? new Set( + parentOfActive.memberPubkeys.map((pubkey) => + normalizePubkey(pubkey), + ), + ) + : null; + return userSearchResults + .filter( + (user) => + !memberPubkeys.has(normalizePubkey(user.pubkey)) && + normalizePubkey(user.pubkey) !== normalizePubkey(myPubkey ?? "") && + (parentMemberPubkeys?.has(normalizePubkey(user.pubkey)) ?? true), + ) + .map((user) => ({ + id: `add-${user.pubkey}`, + label: formatCandidateName(user), + detail: user.isAgent + ? "agent" + : (user.nip05Handle ?? truncatePubkey(user.pubkey)), + run: () => addUserToChannel(user), + })); + } + + const channelActions: PaletteEntry[] = activeChannel + ? [ + { + id: "view-members", + label: `view members of # ${activeChannel.name}`, + detail: `${activeChannel.memberCount} ${ + activeChannel.memberCount === 1 ? "member" : "members" + }`, + run: () => { + setMode("members"); + setQuery(""); + setSelectedIndex(0); + }, + }, + { + id: "add-member", + label: `add someone to # ${activeChannel.name}`, + detail: parentOfActive + ? `members of # ${parentOfActive.name} only` + : "people & agents", + run: () => { + setMode("add-member"); + setQuery(""); + setSelectedIndex(0); + }, + }, + ...(onNewSubChannel + ? [ + { + id: "new-sub-channel", + label: `new tab in # ${parentOfActive?.name ?? activeChannel.name}`, + detail: "spawn a focused agent session · ⌘⇧T", + run: () => { + onNewSubChannel(); + onClose(); + }, + } satisfies PaletteEntry, + ] + : []), + { + id: "rename-channel", + label: `rename # ${activeChannel.name}`, + detail: parentOfActive + ? "rename this tab" + : "its tabs are renamed with it", + run: () => { + setMode("rename"); + setQuery(""); + setSelectedIndex(0); + }, + }, + // Subs never appear in the left list, so pinning one is meaningless. + ...(parentOfActive + ? [] + : [ + { + id: "pin-channel", + label: pinnedIds.has(activeChannel.id) + ? `unpin # ${activeChannel.name}` + : `pin # ${activeChannel.name}`, + detail: pinnedIds.has(activeChannel.id) + ? "remove from pinned section" + : "keep at the top of the channel list", + run: () => { + toggleChannelPinned(activeChannel.id); + onClose(); + }, + } satisfies PaletteEntry, + ]), + { + id: "leave-channel", + label: `leave # ${activeChannel.name}`, + detail: isLastHumanMember + ? "archives — you're the last member" + : "remove yourself", + run: () => leaveChannel(), + }, + { + id: "archive-channel", + label: `archive # ${activeChannel.name}`, + detail: "hide from the channel list", + run: () => archiveChannel(), + }, + ] + : []; + + const actions: PaletteEntry[] = [ + ...channelActions, + { + id: "new-session", + label: "new session", + detail: "fresh prompt", + run: () => { + onNewSession(); + onClose(); + }, + }, + { + id: "standard-ui", + label: "switch to standard ui", + detail: "⌘⇧D", + run: () => { + onClose(); + setDisplayStyle("standard"); + }, + }, + { + id: "name-color", + label: "set my name color", + detail: "hex or preset", + run: () => { + setMode("color"); + setQuery(""); + setSelectedIndex(0); + }, + }, + { + id: "shortcuts", + label: "keyboard shortcuts", + run: () => { + onClose(); + onShowShortcuts(); + }, + }, + ...SETTINGS_ENTRIES.map( + (entry): PaletteEntry => ({ + id: `settings-${entry.section}`, + label: entry.label, + detail: "settings", + run: () => openSettings(entry.section), + }), + ), + ]; + + const channelEntries: PaletteEntry[] = channels.map((channel) => ({ + id: `channel-${channel.id}`, + label: `# ${channel.name}`, + detail: channel.description ?? undefined, + run: () => { + onOpenChannel(channel.id); + onClose(); + }, + })); + + if (!needle) return [...actions, ...channelEntries]; + + // Open channels the user hasn't joined only surface while searching — + // a relay can have hundreds and they'd drown the root list. Joining a + // `parent--sub` requires parent membership, so foreign subs are hidden. + const joinedNames = new Set(channels.map((channel) => channel.name)); + const joinableEntries: PaletteEntry[] = discoverableChannels + .filter((channel) => { + const parsed = parseSubChannelName(channel.name); + return !parsed || joinedNames.has(parsed.parentName); + }) + .map((channel) => ({ + id: `join-${channel.id}`, + label: `# ${channel.name}`, + detail: "not joined · enter to join", + run: () => joinAndOpenChannel(channel.id), + })); + + const matches = (entry: PaletteEntry) => + `${entry.label} ${entry.detail ?? ""}`.toLowerCase().includes(needle); + const matchesName = (entry: PaletteEntry) => + entry.label.toLowerCase().includes(needle); + // An action verb typed literally ("archive", "leave", "pin"…) beats + // channel-name substring hits; otherwise a query is usually a channel + // lookup, so channels rank above incidental action matches. Joined + // channels rank above joinable ones, which match on name only so + // typing "join" doesn't dump every discoverable channel. + const matchedActions = actions.filter(matches); + const startsWithNeedle = (entry: PaletteEntry) => + entry.label.toLowerCase().startsWith(needle); + return [ + ...matchedActions.filter(startsWithNeedle), + ...channelEntries.filter(matches), + ...joinableEntries.filter(matchesName), + ...matchedActions.filter((entry) => !startsWithNeedle(entry)), + ]; + }, [ + activeChannel, + addUserToChannel, + archiveChannel, + channels, + discoverableChannels, + isLastHumanMember, + joinAndOpenChannel, + leaveChannel, + membersQuery.data, + mode, + myPubkey, + onClose, + onNewSession, + onNewSubChannel, + onOpenChannel, + onShowShortcuts, + openSettings, + parentOfActive, + pinnedIds, + query, + renameChannel, + resolveColor, + userSearchResults, + ]); + + const clampedIndex = Math.min(selectedIndex, Math.max(0, entries.length - 1)); + + const scrollSelectedIntoView = React.useCallback( + (node: HTMLButtonElement | null) => { + node?.scrollIntoView({ block: "nearest" }); + }, + [], + ); + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + if (mode !== "root") { + setMode("root"); + setQuery(""); + setSelectedIndex(0); + setActionError(null); + return; + } + onClose(); + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + setSelectedIndex(Math.min(clampedIndex + 1, entries.length - 1)); + return; + } + if (event.key === "ArrowUp") { + event.preventDefault(); + setSelectedIndex(Math.max(clampedIndex - 1, 0)); + return; + } + if (event.key === "Enter") { + event.preventDefault(); + entries[clampedIndex]?.run(); + } + }; + + return ( +
+ + ); +} diff --git a/desktop/src/features/dev-mode/ui/DevComposerAttachments.tsx b/desktop/src/features/dev-mode/ui/DevComposerAttachments.tsx new file mode 100644 index 0000000000..02b00632a2 --- /dev/null +++ b/desktop/src/features/dev-mode/ui/DevComposerAttachments.tsx @@ -0,0 +1,74 @@ +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import type { UploadingAttachmentPreview } from "@/features/messages/lib/useMediaUpload"; + +/** + * Pending and in-flight attachments for a dev-mode composer: compact, + * square-cornered chips in the terminal aesthetic. Images show a small + * thumbnail; videos and other files show their filename. + */ +export function DevComposerAttachments({ + pendingImeta, + uploadingPreviews, + errorMessage, + onRemove, +}: { + pendingImeta: ImetaMedia[]; + uploadingPreviews: UploadingAttachmentPreview[]; + errorMessage: string | null; + onRemove: (url: string) => void; +}) { + if ( + pendingImeta.length === 0 && + uploadingPreviews.length === 0 && + !errorMessage + ) { + return null; + } + return ( +
+ {pendingImeta.map((media) => ( + + {media.type.startsWith("image/") ? ( + {media.filename + ) : null} + + {media.filename ?? + (media.type.startsWith("video/") ? "video" : "file")} + + + + ))} + {uploadingPreviews.map((preview) => ( + + uploading {preview.filename ?? "file"} + {typeof preview.progress === "number" ? ` ${preview.progress}%` : "…"} + + ))} + {errorMessage ? ( + {errorMessage} + ) : null} +
+ ); +} diff --git a/desktop/src/features/dev-mode/ui/DevComposerModeLine.tsx b/desktop/src/features/dev-mode/ui/DevComposerModeLine.tsx new file mode 100644 index 0000000000..2ea8b33479 --- /dev/null +++ b/desktop/src/features/dev-mode/ui/DevComposerModeLine.tsx @@ -0,0 +1,42 @@ +import type { DevComposerMode } from "@/features/dev-mode/lib/useDevComposerModes"; +import { cn } from "@/shared/lib/cn"; + +/** + * The composer's Tab-cycled target, rendered like a message's "to Name" + * direction line rather than a pill: sits at the top of the chat box, name in + * the agent's chat color. + */ +export function DevComposerModeLine({ + mode, + busy, + agentColor, + className, +}: { + mode: DevComposerMode; + busy: boolean; + agentColor: string | null; + className?: string; +}) { + return ( +
+ {busy ? ( + "working…" + ) : mode.kind === "agent" ? ( + <> + to{" "} + + {mode.target.name} + + + ) : ( + "chat" + )} +
+ ); +} diff --git a/desktop/src/features/dev-mode/ui/DevComposerResizeHandle.tsx b/desktop/src/features/dev-mode/ui/DevComposerResizeHandle.tsx new file mode 100644 index 0000000000..05a55165c1 --- /dev/null +++ b/desktop/src/features/dev-mode/ui/DevComposerResizeHandle.tsx @@ -0,0 +1,38 @@ +import type * as React from "react"; + +import { cn } from "@/shared/lib/cn"; + +/** + * Horizontal drag strip along a composer's top edge. Doubles as the pane + * border; dragging (or ↑/↓ when focused) resizes the composer. + */ +export function DevComposerResizeHandle({ + dragging, + testId, + "aria-valuenow": valueNow, + ...handlers +}: { + dragging: boolean; + testId: string; + "aria-valuenow": number; + onPointerDown: (event: React.PointerEvent) => void; + onPointerMove: (event: React.PointerEvent) => void; + onPointerUp: (event: React.PointerEvent) => void; + onKeyDown: (event: React.KeyboardEvent) => void; +}) { + return ( + // biome-ignore lint/a11y/useSemanticElements:
cannot host the drag/keyboard resize handlers of a movable separator +
+ ); +} diff --git a/desktop/src/features/dev-mode/ui/DevLink.tsx b/desktop/src/features/dev-mode/ui/DevLink.tsx new file mode 100644 index 0000000000..281767dc31 --- /dev/null +++ b/desktop/src/features/dev-mode/ui/DevLink.tsx @@ -0,0 +1,38 @@ +import { Link as LinkIcon } from "lucide-react"; +import { openUrl } from "@tauri-apps/plugin-opener"; + +import { linkDisplayText } from "@/features/dev-mode/lib/linkDisplay"; +import { usePageTitle } from "@/features/dev-mode/lib/usePageTitle"; + +/** + * Clickable transcript link: opens in the system browser and renders + * Slack-style — link icon plus an explicit markdown label when one was + * written, else the fetched page title, else the cleaned URL. + */ +export function DevLink({ href, label }: { href: string; label?: string }) { + const title = usePageTitle(label ? null : href); + + // Plain inline display (not inline-flex): an atomic inline box would be + // skipped by native text selection (double-click-drag, triple-click), so + // the link must flow like ordinary text for selection and copy to include + // it. Long labels wrap with the rest of the message. + return ( + { + event.preventDefault(); + void openUrl(href); + }} + rel="noreferrer" + title={href} + > + + {label ?? title ?? linkDisplayText(href)} + + ); +} diff --git a/desktop/src/features/dev-mode/ui/DevMentionSuggestions.tsx b/desktop/src/features/dev-mode/ui/DevMentionSuggestions.tsx new file mode 100644 index 0000000000..966a2fb56d --- /dev/null +++ b/desktop/src/features/dev-mode/ui/DevMentionSuggestions.tsx @@ -0,0 +1,61 @@ +import { useAuthorColorResolver } from "@/features/dev-mode/lib/authorColors"; +import type { MentionSuggestion } from "@/features/dev-mode/lib/useMentionAutocomplete"; +import { cn } from "@/shared/lib/cn"; + +/** + * `@user` autocomplete popup anchored above a composer textarea. Mouse + * clicks accept without stealing focus from the textarea (mousedown is + * prevented), matching the keyboard-first composer conventions. + */ +export function DevMentionSuggestions({ + suggestions, + selectedIndex, + onAccept, +}: { + suggestions: MentionSuggestion[]; + selectedIndex: number; + onAccept: (suggestion: MentionSuggestion) => void; +}) { + const resolveColor = useAuthorColorResolver(); + return ( +
+
+ tag someone · tab/enter: insert · esc: dismiss +
+ {suggestions.map((suggestion, index) => ( + + ))} +
+ ); +} diff --git a/desktop/src/features/dev-mode/ui/DevMessageRow.tsx b/desktop/src/features/dev-mode/ui/DevMessageRow.tsx new file mode 100644 index 0000000000..04ada08233 --- /dev/null +++ b/desktop/src/features/dev-mode/ui/DevMessageRow.tsx @@ -0,0 +1,148 @@ +import * as React from "react"; + +import type { AuthorColorResolver } from "@/features/dev-mode/lib/authorColors"; +import { useChannelRefs } from "@/features/dev-mode/lib/channelRefs"; +import { renderDevMarkdown } from "@/features/dev-mode/lib/devMarkdown"; +import { + matchLeadingMention, + type MentionStyle, +} from "@/features/dev-mode/lib/highlightContent"; +import type { MessageReaction } from "@/features/dev-mode/lib/messageReactions"; +import type { + AgentResolver, + NameResolver, +} from "@/features/dev-mode/lib/useMemberNameResolver"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; +import { cn } from "@/shared/lib/cn"; +import { parseImetaTags } from "@/shared/ui/markdown/parseImeta"; + +function formatTime(createdAt: number) { + return new Date(createdAt * 1_000).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); +} + +function ReactionChips({ + reactions, + resolveName, +}: { + reactions: MessageReaction[]; + resolveName: NameResolver; +}) { + const byEmoji = new Map(); + for (const { emoji, pubkey } of reactions) { + const bucket = byEmoji.get(emoji); + if (bucket) { + bucket.push(pubkey); + } else { + byEmoji.set(emoji, [pubkey]); + } + } + return ( + + {[...byEmoji.entries()].map(([emoji, pubkeys]) => ( + + {emoji} + {pubkeys.length > 1 ? ` ${pubkeys.length}` : ""} + + ))} + + ); +} + +export function DevMessageRow({ + event, + isSelf, + reactions, + resolveName, + resolveColor, + resolveIsAgent, +}: { + event: RelayEvent; + isSelf: boolean; + /** Emoji reacted onto this message — agents react while working, so this doubles as the loading state. */ + reactions?: MessageReaction[]; + resolveName: NameResolver; + resolveColor: AuthorColorResolver; + resolveIsAgent: AgentResolver; +}) { + const { channels, openChannel } = useChannelRefs(); + // Stable per-event identity so the media renderer's memo holds. + const imetaByUrl = React.useMemo( + () => parseImetaTags(event.tags), + [event.tags], + ); + + if (event.kind === KIND_SYSTEM_MESSAGE) { + return null; + } + + // The pubkeys this message explicitly mentions (its p tags) let known + // `@Name` tokens render as pills in the mentioned author's color. + const mentionStyles: MentionStyle[] = []; + for (const tag of event.tags) { + if (tag[0] !== "p" || !tag[1]) continue; + const name = resolveName(tag[1]); + if (mentionStyles.some((mention) => mention.name === name)) continue; + mentionStyles.push({ name, color: resolveColor(tag[1]) }); + } + + // A leading `@Name` mention on a human message is direction, not prose: + // it renders as a "to Name" line under the author instead of inside the + // message body. Agent replies keep their mentions inline as normal text. + const directed = resolveIsAgent(event.pubkey) + ? null + : matchLeadingMention(event.content, mentionStyles); + const bodyContent = directed + ? event.content.slice(directed.end) + : event.content; + + return ( +
+
+ + {resolveName(event.pubkey)} + + {directed ? ( + + to{" "} + + {directed.mention.name} + + + ) : null} + + {formatTime(event.created_at)} + + {reactions && reactions.length > 0 ? ( + + ) : null} +
+
+ {renderDevMarkdown( + bodyContent, + mentionStyles, + { channels, onOpen: openChannel }, + imetaByUrl, + )} +
+
+ ); +} diff --git a/desktop/src/features/dev-mode/ui/DevModeShell.tsx b/desktop/src/features/dev-mode/ui/DevModeShell.tsx new file mode 100644 index 0000000000..55a152bc48 --- /dev/null +++ b/desktop/src/features/dev-mode/ui/DevModeShell.tsx @@ -0,0 +1,983 @@ +import * as React from "react"; + +import { useAppShell } from "@/app/AppShellContext"; + +import { useChannelsQuery } from "@/features/channels/hooks"; +import { + type ChannelRef, + DevChannelRefsProvider, +} from "@/features/dev-mode/lib/channelRefs"; +import { + groupSessionChannels, + usePinnedChannels, +} from "@/features/dev-mode/lib/pinnedChannels"; +import { + loadLastComposerModeKey, + storeLastComposerModeKey, +} from "@/features/dev-mode/lib/composerModePreference"; +import { copySelectionWithLinkUrls } from "@/features/dev-mode/lib/copyLinkUrls"; +import type { MentionRecord } from "@/features/dev-mode/lib/mentionRecords"; +import { + aggregateLastActivity, + aggregateUnreadMains, + indexSubChannels, +} from "@/features/dev-mode/lib/subChannels"; +import { selectRootEvents } from "@/features/dev-mode/lib/transcriptRoots"; +import { useShellFocusGuards } from "@/features/dev-mode/lib/useShellFocusGuards"; +import { useUnreadRouting } from "@/features/dev-mode/lib/useUnreadRouting"; +import { + devComposerModeLabel, + useDevComposerModes, + type DevComposerMode, +} from "@/features/dev-mode/lib/useDevComposerModes"; +import { useDevSessionActions } from "@/features/dev-mode/lib/useDevSessionActions"; +import { useDevModeShortcuts } from "@/features/dev-mode/lib/useDevModeShortcuts"; +import { useNavigatorWidth } from "@/features/dev-mode/lib/useNavigatorWidth"; +import { DevChannelMembers } from "@/features/dev-mode/ui/DevChannelMembers"; +import { DevChannelNavigator } from "@/features/dev-mode/ui/DevChannelNavigator"; +import { DevChannelTabs } from "@/features/dev-mode/ui/DevChannelTabs"; +import { DevCommandPalette } from "@/features/dev-mode/ui/DevCommandPalette"; +import { DevPromptComposer } from "@/features/dev-mode/ui/DevPromptComposer"; +import { DevShortcutsOverlay } from "@/features/dev-mode/ui/DevShortcutsOverlay"; +import { DevSplitPane } from "@/features/dev-mode/ui/DevSplitPane"; +import { DevThreadPanel } from "@/features/dev-mode/ui/DevThreadPanel"; +import { DevTranscript } from "@/features/dev-mode/ui/DevTranscript"; +import { useChannelMessagesQuery } from "@/features/messages/hooks"; +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import { useIdentityQuery } from "@/shared/api/hooks"; +import { cn } from "@/shared/lib/cn"; +import { isMacPlatform } from "@/shared/lib/platform"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import { useIsFullscreen } from "@/shared/lib/useIsFullscreen"; + +/** + * Stable identity for the cycled mode. Selection is keyed rather than + * indexed so agent list refreshes cannot silently retarget the next prompt + * at a different agent; a vanished agent falls back to the default agent. + */ +function devComposerModeKey(mode: DevComposerMode): string { + return mode.kind === "chat" ? "chat" : normalizePubkey(mode.target.pubkey); +} + +/** + * Keyboard model: + * + * - `fresh` — just the composer. Enter spawns a session channel; ↑ slides + * the channel navigator out from the left. + * - `navigator` — ↑/↓ preview channels (transcript shows behind), Enter + * opens the highlighted channel, Escape returns to fresh. + * - `channel` — Enter sends; empty ↑/↓ walk prompt cards; Enter on a card + * opens the split-screen side chat; Escape unwinds side chat → card → + * navigator. + * + * ⌘K (anywhere) or `/` (empty composer) opens the command palette. + */ +type ShellView = "fresh" | "navigator" | "channel"; + +export function DevModeShell({ + unreadChannelIds, + topLevelUnreadChannelIds, + hasCommunityRail = false, +}: { + /** Channels with anything unread, including relevant thread replies. */ + unreadChannelIds: ReadonlySet; + /** Channels with unread channel-level posts only. */ + topLevelUnreadChannelIds: ReadonlySet; + /** The community rail sits under the macOS traffic lights when present. */ + hasCommunityRail?: boolean; +}) { + const identityQuery = useIdentityQuery(); + const channelsQuery = useChannelsQuery(); + const isFullscreen = useIsFullscreen(); + const modes = useDevComposerModes(); + const { createSessionChannel, createSubChannel, sendToSession } = + useDevSessionActions(identityQuery.data); + + const [view, setView] = React.useState("fresh"); + const [input, setInput] = React.useState(""); + const [modeKey, setModeKey] = React.useState( + loadLastComposerModeKey, + ); + const [activeSessionId, setActiveSessionId] = React.useState( + null, + ); + const [navigatorId, setNavigatorId] = React.useState(null); + const [selectedRootId, setSelectedRootId] = React.useState( + null, + ); + const [threadOpen, setThreadOpen] = React.useState(false); + const [activePane, setActivePane] = React.useState<"main" | "thread">("main"); + // When set, the composer's next Enter spawns a sub-channel of this main + // channel instead of posting to the open channel. + const [subDraftParentId, setSubDraftParentId] = React.useState( + null, + ); + const [paletteOpen, setPaletteOpen] = React.useState(false); + const [paletteInitialMode, setPaletteInitialMode] = React.useState< + "root" | "members" + >("root"); + const [shortcutsOpen, setShortcutsOpen] = React.useState(false); + const [focusSignal, setFocusSignal] = React.useState(0); + const [busy, setBusy] = React.useState(false); + const [error, setError] = React.useState(null); + + const focusComposer = React.useCallback(() => { + setFocusSignal((current) => current + 1); + }, []); + + // While a prompt card is selected the caret leaves the message box — the + // shell owns ↑/↓/Enter/Escape via a window listener until the selection + // clears (Escape, ↓ past the newest card, or a click on the box). + const cardSelectionActive = + view === "channel" && selectedRootId !== null && !threadOpen; + + // The composer remembers the last target the user talked to (persisted + // across launches). Before any selection exists — or when the remembered + // agent vanishes — it falls back to the default: the first managed (local) + // agent, else the first agent; plain chat only when no agents exist. + const defaultModeIndex = React.useMemo(() => { + const managedIndex = modes.findIndex( + (candidate) => + candidate.kind === "agent" && candidate.target.source === "managed", + ); + if (managedIndex !== -1) return managedIndex; + const agentIndex = modes.findIndex( + (candidate) => candidate.kind === "agent", + ); + return agentIndex === -1 ? 0 : agentIndex; + }, [modes]); + + const foundModeIndex = + modeKey === null + ? -1 + : modes.findIndex( + (candidate) => devComposerModeKey(candidate) === modeKey, + ); + const modeIndex = foundModeIndex === -1 ? defaultModeIndex : foundModeIndex; + const mode = modes[modeIndex]; + + const sessions = React.useMemo( + () => + (channelsQuery.data ?? []).filter( + (channel) => + channel.channelType === "stream" && + channel.isMember && + channel.archivedAt === null, + ), + [channelsQuery.data], + ); + + // Open channels the user hasn't joined: the palette searches these and + // joins on enter, but they stay out of the left navigator until joined. + const discoverableChannels = React.useMemo( + () => + (channelsQuery.data ?? []).filter( + (channel) => + channel.channelType === "stream" && + !channel.isMember && + channel.visibility === "open" && + channel.archivedAt === null, + ), + [channelsQuery.data], + ); + + // `#channel` references: composers autocomplete these names and message + // rows render matching tokens as clickable links to the channel. + const channelRefs = React.useMemo( + () => sessions.map((channel) => ({ id: channel.id, name: channel.name })), + [sessions], + ); + + // `parent--sub` channels pair with their parents: only mains render in + // the left list; subs surface as tabs inside their parent. + const subIndex = React.useMemo(() => indexSubChannels(sessions), [sessions]); + + // The left list orders mains by their whole family's latest activity, so + // a busy sub-channel floats its parent. + const listChannels = React.useMemo(() => { + const overrides = aggregateLastActivity(subIndex); + if (overrides.size === 0) return subIndex.mains; + return subIndex.mains.map((channel) => { + const latest = overrides.get(channel.id); + return latest && latest > (channel.lastMessageAt ?? "") + ? { ...channel, lastMessageAt: latest } + : channel; + }); + }, [subIndex]); + + const navigatorUnreadIds = React.useMemo( + () => aggregateUnreadMains(subIndex, unreadChannelIds), + [subIndex, unreadChannelIds], + ); + + const pinnedIds = usePinnedChannels(); + // Pinned chats on top, everything else below — each newest-first; `flat` + // matches the navigator's render order so ↑/↓ walk what is on screen. + const { groups: channelGroups, flat: orderedChannels } = React.useMemo( + () => groupSessionChannels(listChannels, pinnedIds), + [pinnedIds, listChannels], + ); + + const findChannel = React.useCallback( + (channelId: string | null) => + (channelsQuery.data ?? []).find((channel) => channel.id === channelId) ?? + null, + [channelsQuery.data], + ); + + const activeChannel = + view === "channel" ? findChannel(activeSessionId) : null; + const previewChannel = view === "navigator" ? findChannel(navigatorId) : null; + const topBarChannel = activeChannel ?? previewChannel; + // A stored id whose channel vanished (or is still propagating) renders as + // the fresh-session state; navigation starts from what is actually shown. + const effectiveSessionId = activeChannel?.id ?? null; + + // Logical selection: the open channel's main. When a sub tab is active, + // the left list keeps highlighting the parent and ⌥↑↓/Escape navigate by + // parent; the transcript and composer stay on the physical channel. + const activeMainId = activeChannel + ? (subIndex.parentIdByChildId.get(activeChannel.id) ?? activeChannel.id) + : null; + const activeMainChannel = activeMainId ? findChannel(activeMainId) : null; + const activeSubChannels = React.useMemo( + () => + activeMainId ? (subIndex.subsByParentId.get(activeMainId) ?? []) : [], + [activeMainId, subIndex], + ); + const subDraftActive = + view === "channel" && + subDraftParentId !== null && + subDraftParentId === activeMainId; + + // Shares the transcript's query cache — used only for card navigation. + const messagesQuery = useChannelMessagesQuery(activeChannel); + const roots = React.useMemo( + () => selectRootEvents(messagesQuery.data), + [messagesQuery.data], + ); + const selectedRoot = roots.find((root) => root.id === selectedRootId) ?? null; + + // Viewing an open channel marks its channel-level posts read (same passive + // NIP-RS path the standard channel screen uses). topLevelOnly keeps thread + // replies out of the marker — thread unread clears through what is actually + // seen: the inline first reply (transcript) and the side chat (panel). + const { markChannelRead } = useAppShell(); + const latestRootAt = + roots.length > 0 ? roots[roots.length - 1].created_at : null; + const activeChannelIdForRead = activeChannel?.isMember + ? activeChannel.id + : null; + React.useEffect(() => { + if (!activeChannelIdForRead) return; + markChannelRead( + activeChannelIdForRead, + latestRootAt === null + ? null + : new Date(latestRootAt * 1_000).toISOString(), + { topLevelOnly: true }, + ); + }, [activeChannelIdForRead, latestRootAt, markChannelRead]); + + // Card selection and the side chat belong to one channel's transcript. + // biome-ignore lint/correctness/useExhaustiveDependencies: intentional — selection resets only on channel switch + React.useEffect(() => { + setSelectedRootId(null); + setThreadOpen(false); + setActivePane("main"); + setSubDraftParentId(null); + }, [effectiveSessionId]); + + // Window refocus restores the last text input; dead-space clicks never + // blur it (see useShellFocusGuards). + const { handleFocusCapture, handleShellMouseDown, handleShellMouseUp } = + useShellFocusGuards({ cardSelectionActive, focusComposer }); + + // Lifted here (not inside the navigator) so the top bar's columns track + // the navigator width live while the divider is dragged. + const navigatorWidthControls = useNavigatorWidth(); + + const closePalette = React.useCallback(() => { + setPaletteOpen(false); + focusComposer(); + }, [focusComposer]); + + const openPalette = React.useCallback((mode: "root" | "members" = "root") => { + setPaletteInitialMode(mode); + setPaletteOpen(true); + }, []); + + const closeShortcuts = React.useCallback(() => { + setShortcutsOpen(false); + focusComposer(); + }, [focusComposer]); + + const openChannel = React.useCallback( + (channelId: string) => { + setActiveSessionId(channelId); + // The left list only shows mains — highlight the family's parent when + // a sub tab is opened directly (palette, #ref link, tab click). + setNavigatorId(subIndex.parentIdByChildId.get(channelId) ?? channelId); + setView("channel"); + focusComposer(); + }, + [focusComposer, subIndex], + ); + + const handleOpenThread = React.useCallback((rootId: string) => { + setSelectedRootId(rootId); + setThreadOpen(true); + setActivePane("thread"); + }, []); + + const openChannelAtUnread = useUnreadRouting({ + subIndex, + unreadChannelIds, + topLevelUnreadChannelIds, + activeChannel, + roots, + openChannel, + openThread: handleOpenThread, + }); + + // "+ tab" (tab strip, palette, or ⌘⇧T): the composer's next Enter spawns + // a new tab (sub-channel) of the open main instead of posting to the + // channel. + const startSubChannelDraft = React.useCallback(() => { + if (!activeMainId) return; + setSubDraftParentId(activeMainId); + setSelectedRootId(null); + setThreadOpen(false); + setActivePane("main"); + focusComposer(); + }, [activeMainId, focusComposer]); + + const goToFresh = React.useCallback(() => { + setView("fresh"); + setActiveSessionId(null); + setThreadOpen(false); + setSelectedRootId(null); + setSubDraftParentId(null); + focusComposer(); + }, [focusComposer]); + + // Leaving/archiving a chat lands on the most recently active non-pinned + // chat (the departed channel may still be in the cached list, so exclude + // it); with nowhere to go, fall back to the fresh composer. + const handleChannelLeft = React.useCallback( + (leftChannelId: string) => { + // Leaving a sub tab returns to its parent's main tab. + const parentId = subIndex.parentIdByChildId.get(leftChannelId); + if (parentId) { + openChannel(parentId); + return; + } + const next = channelGroups + .find((group) => !group.pinned) + ?.channels.find((channel) => channel.id !== leftChannelId); + if (next) { + openChannel(next.id); + } else { + goToFresh(); + } + }, + [channelGroups, goToFresh, openChannel, subIndex], + ); + + // ⌘T's draft side chat: the pane opens with no thread yet; its first send + // posts a new message to the channel and attaches the pane to that thread. + const draftSideChat = React.useCallback(() => { + setSelectedRootId(null); + setThreadOpen(true); + setActivePane("thread"); + }, []); + + const togglePalette = React.useCallback(() => { + setPaletteInitialMode("root"); + setPaletteOpen((current) => !current); + }, []); + + useDevModeShortcuts({ + view, + activeChannel, + activeMainChannel, + activeSubChannels, + onTogglePalette: togglePalette, + onNewSession: goToFresh, + onDraftSideChat: + view === "channel" && activeSessionId ? draftSideChat : null, + onDraftTab: + view === "channel" && activeMainId ? startSubChannelDraft : null, + onOpenChannel: openChannel, + }); + + const handleCycleMode = React.useCallback( + (direction: 1 | -1) => { + if (modes.length === 0) return; + const nextIndex = (modeIndex + direction + modes.length) % modes.length; + const nextKey = devComposerModeKey(modes[nextIndex]); + setModeKey(nextKey); + storeLastComposerModeKey(nextKey); + }, + [modeIndex, modes], + ); + + const navigateChannels = React.useCallback( + (direction: 1 | -1) => { + if (orderedChannels.length === 0) return; + const currentIndex = orderedChannels.findIndex( + (session) => session.id === navigatorId, + ); + if (currentIndex === -1) { + setNavigatorId(orderedChannels[orderedChannels.length - 1].id); + return; + } + // ↑ walks up the visible list; ↓ back down. The navigator stays + // highlighted at the ends — only Enter or Escape leave it. + const nextIndex = Math.min( + orderedChannels.length - 1, + Math.max(0, currentIndex + direction), + ); + setNavigatorId(orderedChannels[nextIndex].id); + }, + [navigatorId, orderedChannels], + ); + + // ⌥↑/⌥↓ from the composer: open the previous/next channel in the visible + // list directly — focus stays in the box the whole time. + const stepChannel = React.useCallback( + (direction: 1 | -1) => { + if (orderedChannels.length === 0) return; + const referenceId = view === "channel" ? activeMainId : navigatorId; + const currentIndex = orderedChannels.findIndex( + (session) => session.id === referenceId, + ); + if (currentIndex === -1) { + // Nothing open — ⌥↑ enters the list at the bottom (nearest channel). + if (direction === -1) { + openChannel(orderedChannels[orderedChannels.length - 1].id); + } + return; + } + const nextIndex = Math.min( + orderedChannels.length - 1, + Math.max(0, currentIndex + direction), + ); + if (nextIndex === currentIndex) return; + openChannel(orderedChannels[nextIndex].id); + }, + [activeMainId, navigatorId, openChannel, orderedChannels, view], + ); + + const navigateCards = React.useCallback( + (direction: 1 | -1) => { + if (roots.length === 0) return; + const currentIndex = roots.findIndex( + (root) => root.id === selectedRootId, + ); + if (currentIndex === -1) { + // ArrowUp enters the cards at the newest prompt; ArrowDown is a no-op. + if (direction === -1) { + setSelectedRootId(roots[roots.length - 1].id); + } + return; + } + const nextIndex = currentIndex + direction; + if (nextIndex >= roots.length) { + // Past the newest card — back to plain channel input. + setSelectedRootId(null); + setThreadOpen(false); + return; + } + setSelectedRootId(roots[Math.max(0, nextIndex)].id); + }, + [roots, selectedRootId], + ); + + const handleNavigate = React.useCallback( + (direction: 1 | -1) => { + if (view === "channel") { + navigateCards(direction); + return; + } + if (view === "fresh") { + if (direction === -1) { + setView("navigator"); + setNavigatorId( + orderedChannels.length > 0 + ? orderedChannels[orderedChannels.length - 1].id + : null, + ); + } + return; + } + navigateChannels(direction); + }, + [navigateChannels, navigateCards, orderedChannels, view], + ); + + const handleEscape = React.useCallback(() => { + if (view === "channel") { + if (subDraftActive) { + setSubDraftParentId(null); + focusComposer(); + return; + } + if (threadOpen) { + setThreadOpen(false); + setActivePane("main"); + focusComposer(); + return; + } + if (selectedRootId) { + setSelectedRootId(null); + return; + } + // Back out to the navigator with the current channel's main + // highlighted (subs have no row of their own). + setNavigatorId(activeMainId); + setActiveSessionId(null); + setView("navigator"); + return; + } + if (view === "navigator") { + goToFresh(); + } + }, [ + activeMainId, + focusComposer, + goToFresh, + selectedRootId, + subDraftActive, + threadOpen, + view, + ]); + + const handleSwitchPane = React.useCallback( + (pane: "main" | "thread") => { + if (!threadOpen) return; + setActivePane(pane); + if (pane === "main") { + focusComposer(); + } + }, + [focusComposer, threadOpen], + ); + + const handleSubmit = React.useCallback( + (mentions: MentionRecord[] = [], media: ImetaMedia[] = []) => { + const prompt = input.trim(); + // A media-only send is a real send inside a channel; elsewhere the + // empty-input Enter keeps its navigation meaning (a fresh-composer + // channel needs prompt text for naming anyway). + const mediaOnlySend = + !prompt && media.length > 0 && view === "channel" && activeChannel; + if (!prompt && !mediaOnlySend) { + if (view === "navigator" && navigatorId) { + openChannelAtUnread(navigatorId); + return; + } + // Empty-input Enter opens the selected card's side chat. + if (view === "channel" && selectedRootId) { + handleOpenThread(selectedRootId); + } + return; + } + if (busy || !mode) return; + + storeLastComposerModeKey(devComposerModeKey(mode)); + setBusy(true); + setError(null); + setInput(""); + void (async () => { + try { + let channel = activeChannel; + if (!channel) { + channel = await createSessionChannel(prompt, mode); + setActiveSessionId(channel.id); + setNavigatorId(channel.id); + setView("channel"); + } else if (subDraftActive && activeMainChannel) { + // "+ sub" draft: spawn a sub-channel of the open main and land + // on its tab; the prompt goes to the new sub, not the main. + channel = await createSubChannel(activeMainChannel, prompt, mode); + setSubDraftParentId(null); + setActiveSessionId(channel.id); + } + await sendToSession( + channel, + prompt, + mode, + undefined, + mentions, + media, + ); + // The conversation moved to the new prompt at the bottom. + setSelectedRootId(null); + } catch (submitError) { + setError( + submitError instanceof Error + ? submitError.message + : "Failed to send prompt.", + ); + // Restore the failed prompt unless the user already typed on. + setInput((current) => (current === "" ? prompt : current)); + } finally { + setBusy(false); + } + })(); + }, + [ + activeChannel, + activeMainChannel, + busy, + createSessionChannel, + createSubChannel, + handleOpenThread, + input, + mode, + navigatorId, + openChannelAtUnread, + selectedRootId, + sendToSession, + subDraftActive, + view, + ], + ); + + const handleThreadSend = React.useCallback( + async (prompt: string, mentions: MentionRecord[], media: ImetaMedia[]) => { + if (!activeChannel || !mode) { + throw new Error("Thread is no longer available."); + } + storeLastComposerModeKey(devComposerModeKey(mode)); + if (selectedRoot) { + await sendToSession( + activeChannel, + prompt, + mode, + selectedRoot.id, + mentions, + media, + ); + return; + } + // Draft side chat (⌘T): the first send posts a root message to the + // channel exactly like the main composer, then the pane attaches to + // that new thread. + const newRoot = await sendToSession( + activeChannel, + prompt, + mode, + undefined, + mentions, + media, + ); + setSelectedRootId(newRoot.id); + }, + [activeChannel, mode, selectedRoot, sendToSession], + ); + + const placeholder = subDraftActive + ? `Prompt spawns a new tab in # ${activeMainChannel?.name ?? ""}…` + : activeChannel + ? mode?.kind === "agent" + ? `Message # ${activeChannel.name} and put ${devComposerModeLabel(mode)} to work…` + : `Message # ${activeChannel.name}…` + : mode?.kind === "agent" + ? `Prompt ${devComposerModeLabel(mode)} — spawns a new channel where it works…` + : "Start a discussion — spawns a new channel for humans…"; + + const composerActive = + !paletteOpen && + !shortcutsOpen && + !(threadOpen && activePane === "thread") && + !cardSelectionActive; + + React.useEffect(() => { + if (!cardSelectionActive || paletteOpen || shortcutsOpen) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.metaKey || event.ctrlKey || event.altKey) return; + // A focused input owns its own keys (e.g. a click landed in one). + if ( + event.target instanceof HTMLElement && + event.target.matches("textarea, input, [contenteditable='true']") + ) { + return; + } + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + event.preventDefault(); + navigateCards(event.key === "ArrowUp" ? -1 : 1); + } else if (event.key === "Enter") { + event.preventDefault(); + if (selectedRootId) handleOpenThread(selectedRootId); + } else if (event.key === "Escape") { + event.preventDefault(); + handleEscape(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [ + cardSelectionActive, + handleEscape, + handleOpenThread, + navigateCards, + paletteOpen, + selectedRootId, + shortcutsOpen, + ]); + + const transcriptFor = ( + channel: NonNullable, + { markRead = false } = {}, + ) => ( + + ); + + const sideChatOpen = Boolean( + view === "channel" && activeChannel && threadOpen && mode, + ); + + const composer = mode ? ( + openPalette()} + onOpenShortcuts={() => setShortcutsOpen(true)} + onStepChannel={stepChannel} + onReactivate={() => { + if (cardSelectionActive) setSelectedRootId(null); + }} + onSubmit={handleSubmit} + onSwitchPane={handleSwitchPane} + placeholder={placeholder} + selfPubkey={identityQuery.data?.pubkey ?? null} + value={input} + /> + ) : null; + + const errorBar = error ? ( +
+ {error} +
+ ) : null; + + // Fixed px clearance: the native macOS traffic lights overlay this strip + // and ignore the app's text zoom, so rem-based padding would slide the + // title under them. The 56px community rail absorbs most of the lights' + // ~88px footprint, leaving ~32px protruding into the shell. + const macChrome = isMacPlatform() && !isFullscreen; + const titleClearance = macChrome + ? hasCommunityRail + ? "pl-[32px]" + : "pl-[88px]" + : "pl-4"; + + return ( + + {/* biome-ignore lint/a11y/noStaticElementInteractions: handlers only guard focus (track last input, keep dead-space clicks from blurring it) — the div is not interactive */} +
{ + copySelectionWithLinkUrls(event.nativeEvent); + }} + onFocusCapture={handleFocusCapture} + onMouseDown={handleShellMouseDown} + onMouseUp={handleShellMouseUp} + > + {/* Two columns sharing the navigator's live width so "buzz · + developer mode" sits over the channel list and the channel + name/members sit over the transcript, even mid-drag. */} +
+
+ + buzz · developer mode + +
+
+ + {topBarChannel ? <># {topBarChannel.name} : null} + + {topBarChannel ? ( + + openPalette("members")} + /> + + ) : null} +
+
+ +
+ + +
+ {view === "channel" && activeChannel && activeMainChannel ? ( + + ) : null} + {view === "navigator" && previewChannel ? ( +
+
+ preview +
+ {transcriptFor(previewChannel)} +
+ ) : view === "channel" && activeChannel ? ( + threadOpen && mode ? ( + + {transcriptFor(activeChannel, { + markRead: activeChannel.isMember, + })} + {composer} + + } + side={ + { + setThreadOpen(false); + setActivePane("main"); + focusComposer(); + }} + onCycleMode={handleCycleMode} + onSend={handleThreadSend} + onSwitchPane={handleSwitchPane} + root={selectedRoot} + /> + } + /> + ) : ( + transcriptFor(activeChannel, { + markRead: activeChannel.isMember, + }) + ) + ) : ( +
+
+
new session
+
+ Type a prompt — it spawns a channel and puts the selected + target to work. Type ? for keyboard shortcuts. +
+
+
+ )} + + {/* Inside a channel the composer covers only this pane; the fresh + and navigator states' composer below spans the full shell. */} + {view === "channel" && !sideChatOpen ? ( + <> + {errorBar} + {composer} + + ) : null} + {sideChatOpen ? errorBar : null} +
+
+ + {view !== "channel" ? ( + <> + {errorBar} + {composer} + + ) : null} + + {paletteOpen ? ( + setShortcutsOpen(true)} + onNewSubChannel={ + view === "channel" && activeMainChannel + ? startSubChannelDraft + : null + } + onOpenChannel={openChannel} + parentOfActive={ + topBarChannel + ? (findChannel( + subIndex.parentIdByChildId.get(topBarChannel.id) ?? null, + ) ?? null) + : null + } + /> + ) : null} + + {shortcutsOpen ? ( + + ) : null} +
+
+ ); +} diff --git a/desktop/src/features/dev-mode/ui/DevPromptComposer.tsx b/desktop/src/features/dev-mode/ui/DevPromptComposer.tsx new file mode 100644 index 0000000000..75d0831832 --- /dev/null +++ b/desktop/src/features/dev-mode/ui/DevPromptComposer.tsx @@ -0,0 +1,294 @@ +import * as React from "react"; + +import { useAuthorColorResolver } from "@/features/dev-mode/lib/authorColors"; +import { cn } from "@/shared/lib/cn"; +import type { MentionRecord } from "@/features/dev-mode/lib/mentionRecords"; +import { useChannelRefAutocomplete } from "@/features/dev-mode/lib/useChannelRefAutocomplete"; +import { useComposerAutoGrow } from "@/features/dev-mode/lib/useComposerAutoGrow"; +import type { DevComposerMode } from "@/features/dev-mode/lib/useDevComposerModes"; +import { useMentionAutocomplete } from "@/features/dev-mode/lib/useMentionAutocomplete"; +import { DevChannelSuggestions } from "@/features/dev-mode/ui/DevChannelSuggestions"; +import { DevMentionSuggestions } from "@/features/dev-mode/ui/DevMentionSuggestions"; +import { DevComposerAttachments } from "@/features/dev-mode/ui/DevComposerAttachments"; +import { DevComposerModeLine } from "@/features/dev-mode/ui/DevComposerModeLine"; +import { DevComposerResizeHandle } from "@/features/dev-mode/ui/DevComposerResizeHandle"; +import type { ImetaMedia } from "@/features/messages/lib/imetaMediaMarkdown"; +import { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; + +type DevPromptComposerProps = { + value: string; + mode: DevComposerMode; + placeholder: string; + busy: boolean; + /** Whether this composer owns the keyboard (side chat may own it instead). */ + active: boolean; + /** + * When set the composer is drafting something other than a channel message + * (e.g. a new tab) — renders a highlighted banner so the redirect is + * unmissable, not just placeholder text. + */ + draftLabel?: string | null; + /** Increment to pull focus back here (e.g. after the palette closes). */ + focusSignal: number; + /** Channel whose members rank first in `@` mention suggestions. */ + channelId: string | null; + /** Excluded from mention suggestions. */ + selfPubkey: string | null; + onChange: (value: string) => void; + /** Mentions are the `@Name`s still present in the submitted text. */ + onSubmit: (mentions: MentionRecord[], media: ImetaMedia[]) => void; + /** Tab / Shift+Tab. */ + onCycleMode: (direction: 1 | -1) => void; + /** ArrowUp / ArrowDown while the input is empty — channels or prompt cards. */ + onNavigate: (direction: 1 | -1) => void; + /** ⌥ArrowUp / ⌥ArrowDown — switch channels without leaving the box. */ + onStepChannel: (direction: 1 | -1) => void; + /** ArrowLeft / ArrowRight while the input is empty — side-chat pane focus. */ + onSwitchPane: (pane: "main" | "thread") => void; + /** `/` on an empty input. */ + onOpenPalette: () => void; + /** `?` on an empty input. */ + onOpenShortcuts: () => void; + onEscape: () => void; + /** Click on the box while inactive — the user wants to type again. */ + onReactivate?: () => void; +}; + +export function DevPromptComposer({ + value, + mode, + placeholder, + busy, + active, + draftLabel = null, + focusSignal, + channelId, + selfPubkey, + onChange, + onSubmit, + onCycleMode, + onNavigate, + onStepChannel, + onSwitchPane, + onOpenPalette, + onOpenShortcuts, + onEscape, + onReactivate, +}: DevPromptComposerProps) { + const { textareaRef, dragging, resizeHandleProps } = useComposerAutoGrow( + value, + "buzz.devMode.composerHeight", + ); + const autocomplete = useChannelRefAutocomplete({ + value, + onChange, + textareaRef, + }); + const mentionAutocomplete = useMentionAutocomplete({ + channelId, + selfPubkey, + value, + onChange, + textareaRef, + }); + const resolveColor = useAuthorColorResolver(); + // The pill (and caret) wear the same color the agent's name has in chat. + const agentColor = + mode.kind === "agent" ? resolveColor(mode.target.pubkey) : null; + + // Pasted images/videos upload immediately and attach to the next send. + const { + handlePaste, + isUploading, + pendingImeta, + removeAttachment, + setPendingImeta, + uploadingPreviews, + uploadState, + } = useMediaUpload(); + + // biome-ignore lint/correctness/useExhaustiveDependencies: focusSignal is an intentional focus-pull trigger + React.useEffect(() => { + if (active) { + textareaRef.current?.focus(); + } else { + // Inactive means something else owns the keyboard (card selection, + // side chat, palette) — the caret must leave the box. + textareaRef.current?.blur(); + } + }, [active, focusSignal]); + + const handleKeyDown = (event: React.KeyboardEvent) => { + // Open `#channel` / `@user` suggestions own Tab/Enter/arrows/Escape. + if (autocomplete.handleKeyDown(event)) { + return; + } + if (mentionAutocomplete.handleKeyDown(event)) { + return; + } + + if (event.key === "Tab") { + event.preventDefault(); + onCycleMode(event.shiftKey ? -1 : 1); + return; + } + + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + // An upload still in flight would silently miss the send — hold Enter + // until the attachment chips settle. + if (isUploading) return; + // Empty-input Enter is also meaningful (opens the highlighted channel + // or the selected card's side chat), so the shell decides; busy only + // blocks actual sends there. + onSubmit(mentionAutocomplete.extract(value), pendingImeta); + if (pendingImeta.length > 0) setPendingImeta([]); + return; + } + + if (event.key === "Escape") { + event.preventDefault(); + onEscape(); + return; + } + + if (event.key === "/" && !value) { + event.preventDefault(); + onOpenPalette(); + return; + } + + if (event.key === "?" && !value) { + event.preventDefault(); + onOpenShortcuts(); + return; + } + + if (event.key === "ArrowUp" || event.key === "ArrowDown") { + // ⌥↑/⌥↓ steps through the channel list, draft or not, without the + // caret ever leaving the box. + if (event.altKey) { + event.preventDefault(); + onStepChannel(event.key === "ArrowUp" ? -1 : 1); + return; + } + if (!value) { + event.preventDefault(); + onNavigate(event.key === "ArrowUp" ? -1 : 1); + return; + } + } + + if ((event.key === "ArrowLeft" || event.key === "ArrowRight") && !value) { + event.preventDefault(); + onSwitchPane(event.key === "ArrowLeft" ? "main" : "thread"); + } + }; + + return ( +
+ + {/* Mirror the composer row's prefix so the mode line lines up exactly + with the textarea's left edge. A tab draft swaps the mode line for + the highlighted draft label in the same row, so entering and + leaving the draft never shifts the layout. */} +
+ + ⏵ + + {draftLabel ? ( + + {draftLabel} + + esc cancels + + ) : ( + + )} +
+ +
+ {active && autocomplete.open ? ( + + ) : active && mentionAutocomplete.open ? ( + + ) : null} + + ⏵ + +