diff --git a/docs/work-status-platform-surfaces.md b/docs/work-status-platform-surfaces.md new file mode 100644 index 000000000..1cb41a07c --- /dev/null +++ b/docs/work-status-platform-surfaces.md @@ -0,0 +1,55 @@ +# PR tracker and Work Status platform plan + +This feature is split into three tracks so each surface can match its platform +without coupling the implementations. + +## Naming + +| Track | User-facing name | Implementation term | +| --- | --- | --- | +| Berd top bar | PR Inbox | PR Inbox popover | +| macOS | Work Status | menu bar popover | +| Windows | Work Status | system tray flyout | + +## 1. In-app PR tracker + +The PR Inbox popover shows open pull requests only. Berd already exposes chat +status in its left sidebar, so duplicating chats inside the app would add noise. +The PR tracker groups a pull request under the Berd project of the session that +created it when that association can be recovered; otherwise it uses **No +project**. + +This is the only product surface implemented by the current PR. + +## 2. macOS Work Status menu bar popover + +Implement this in a follow-up PR. It should show both Berd chats and pull +requests because it is available while the user works in other applications. + +The production macOS implementation should use a native `NSStatusItem` and +`NSPopover`, with custom Work Status content hosted inside the native popover. +It must use native anchoring, outside-click dismissal, activation, focus, and +popover chrome. A borderless top-level Tauri window is not an acceptable +substitute. + +## 3. Windows Work Status system tray flyout + +Implement this in a separate follow-up PR. It should show both Berd chats and +pull requests and should feel native on Windows, even if its host implementation +differs from macOS. + +The Windows design must account for: + +- taskbar position on every screen edge +- multi-monitor placement +- per-monitor DPI scaling +- outside-click dismissal and focus behavior +- WebView2 lifecycle and activation +- Windows executable discovery for GitHub CLI +- Windows application-data paths for Berd chat data + +## Cross-platform maintenance + +The macOS and Windows implementations will be separate, but later changes to +shared status labels, interactions, and content must be applied to both. Their +follow-up PRs should reference this document and one another. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 7b1c1c700..a27e67131 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -40,6 +40,7 @@ pub mod path_resolver; #[cfg(target_os = "macos")] mod pocket_playback_speed_dsp; pub mod pocket_voice; +pub mod pr_tracker; pub mod project_icons; pub mod pull_requests; pub mod renderer; diff --git a/src-tauri/src/commands/pr_tracker.rs b/src-tauri/src/commands/pr_tracker.rs new file mode 100644 index 000000000..fd25591fd --- /dev/null +++ b/src-tauri/src/commands/pr_tracker.rs @@ -0,0 +1,659 @@ +use futures_util::{stream, StreamExt}; +use serde::{Deserialize, Serialize}; +use std::process::Stdio; +use std::sync::{Mutex, OnceLock}; +use std::time::Duration; +use tauri_plugin_opener::OpenerExt; +use tokio::process::Command as TokioCommand; +use tokio::time::timeout; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PullRequestIdentity { + id: String, + url: String, + repository: String, + head_repository: Option, + head_ref_name: String, +} + +type ProjectGitIdentity = (String, Option<(String, String)>); + +#[derive(Serialize)] +struct PullRequestUrlMatch { + url: String, +} + +const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); +const PROJECT_RESOLUTION_TIMEOUT: Duration = Duration::from_secs(20); +const MAX_PULL_REQUESTS: usize = 250; +const MAX_WORKSPACE_CANDIDATES: usize = 25; +const MAX_MESSAGE_CANDIDATES: i64 = 2_000; +const MAX_ID_LENGTH: usize = 256; +const MAX_REPOSITORY_LENGTH: usize = 256; +const MAX_BRANCH_LENGTH: usize = 512; +const GIT_PROBE_CONCURRENCY: usize = 4; +const WORKSPACE_CANDIDATES_QUERY: &str = r#" +WITH recent_messages AS ( + SELECT session_id, created_timestamp + FROM messages + ORDER BY id DESC + LIMIT ? +), recent_message_activity AS ( + SELECT session_id, + MAX( + CASE + WHEN created_timestamp > 10000000000 + THEN created_timestamp / 1000 + ELSE created_timestamp + END + ) AS activity_at + FROM recent_messages + GROUP BY session_id +), session_activity AS ( + SELECT s.id, + s.working_dir, + s.project_id, + COALESCE( + m.activity_at, + CASE + WHEN unixepoch(s.updated_at) >= unixepoch(s.created_at) + THEN unixepoch(s.updated_at) + ELSE COALESCE(unixepoch(s.created_at), unixepoch(s.updated_at)) + END + ) AS activity_at + FROM sessions s + LEFT JOIN recent_message_activity m ON m.session_id = s.id + WHERE s.archived_at IS NULL + AND COALESCE(s.session_type, 'user') IN ('user', 'acp') + AND s.project_id IS NOT NULL + AND TRIM(s.project_id) != '' + AND s.working_dir IS NOT NULL + AND TRIM(s.working_dir) != '' +), ranked_workspaces AS ( + SELECT id, + working_dir, + project_id, + activity_at, + ROW_NUMBER() OVER ( + PARTITION BY working_dir + ORDER BY activity_at DESC, id DESC + ) AS workspace_rank + FROM session_activity +) +SELECT id, working_dir, project_id, activity_at +FROM ranked_workspaces +WHERE workspace_rank = 1 +ORDER BY activity_at DESC, id DESC +LIMIT ? +"#; +static PROJECT_BY_PR_URL_CACHE: OnceLock>> = + OnceLock::new(); + +fn project_by_pr_url_cache() -> &'static Mutex> { + PROJECT_BY_PR_URL_CACHE.get_or_init(|| Mutex::new(std::collections::HashMap::new())) +} + +const PULL_REQUEST_QUERY: &str = r#" +query($q:String!,$after:String){search(query:$q,type:ISSUE,first:50,after:$after){ + pageInfo{hasNextPage endCursor} + nodes{... on PullRequest{ + id number title url isDraft updatedAt mergeable mergeStateStatus reviewDecision headRefName + repository{nameWithOwner} + headRepository{nameWithOwner} + commits(last:1){nodes{commit{statusCheckRollup{state}}}} + }} +}} +"#; + +#[tauri::command] +pub async fn list_pr_tracker_pull_requests() -> Result { + timeout(COMMAND_TIMEOUT, list_pr_tracker_pull_requests_inner()) + .await + .map_err(|_| "GitHub CLI timed out".to_string())? +} + +async fn list_pr_tracker_pull_requests_inner() -> Result { + let shell_env = crate::services::dir_env::capture_home_interactive_env().await; + let executable = find_executable("gh", shell_env.get("PATH").map(String::as_str)) + .ok_or_else(|| "GitHub CLI was not found".to_string())?; + let mut after: Option = None; + let mut nodes = Vec::new(); + let mut is_truncated = false; + + loop { + let mut command = TokioCommand::new(&executable); + command.args([ + "api", + "graphql", + "-f", + &format!("query={PULL_REQUEST_QUERY}"), + "-f", + "q=is:pr is:open author:@me", + ]); + if let Some(cursor) = after.as_deref() { + command.args(["-f", &format!("after={cursor}")]); + } + command.kill_on_drop(true); + command.stdin(Stdio::null()); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + if let Some(path) = shell_env.get("PATH") { + command.env("PATH", path); + } + + let output = command + .output() + .await + .map_err(|error| format!("Failed to run GitHub CLI: {error}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return Err(if stderr.is_empty() { + format!("GitHub CLI exited with status {}", output.status) + } else { + stderr + }); + } + + let page: serde_json::Value = serde_json::from_slice(&output.stdout) + .map_err(|error| format!("Invalid GitHub response: {error}"))?; + let search = &page["data"]["search"]; + let page_nodes = search["nodes"] + .as_array() + .ok_or_else(|| "GitHub response did not include pull requests".to_string())?; + nodes.extend( + page_nodes + .iter() + .take(MAX_PULL_REQUESTS.saturating_sub(nodes.len())) + .cloned(), + ); + let has_next_page = search["pageInfo"]["hasNextPage"].as_bool() == Some(true); + if nodes.len() >= MAX_PULL_REQUESTS { + is_truncated = has_next_page || page_nodes.len() > MAX_PULL_REQUESTS; + break; + } + if !has_next_page { + break; + } + after = search["pageInfo"]["endCursor"].as_str().map(str::to_string); + if after.is_none() { + return Err("GitHub response omitted the next page cursor".to_string()); + } + } + + serde_json::to_string(&serde_json::json!({ + "data": { "search": { "nodes": nodes } }, + "isTruncated": is_truncated, + })) + .map_err(|error| format!("Failed to encode GitHub response: {error}")) +} + +#[tauri::command] +pub async fn resolve_pr_tracker_projects( + pull_requests: Vec, +) -> Result>, String> { + let pull_requests = validate_pull_requests(pull_requests)?; + let fallback = pull_requests + .iter() + .map(|pr| (pr.id.clone(), None)) + .collect::>(); + match timeout( + PROJECT_RESOLUTION_TIMEOUT, + resolve_pr_tracker_projects_inner(pull_requests), + ) + .await + { + Ok(Ok(resolved)) => Ok(resolved), + Ok(Err(error)) => Err(error), + Err(_) => Ok(fallback), + } +} + +async fn resolve_pr_tracker_projects_inner( + pull_requests: Vec, +) -> Result>, String> { + let canonical_db_path = crate::services::goose_config::state_dir()? + .join("sessions") + .join("sessions.db"); + let legacy_db_path = dirs::home_dir() + .map(|home| { + home.join(".local") + .join("share") + .join("goose") + .join("sessions") + .join("sessions.db") + }) + .filter(|path| path.exists()); + let db_path = if canonical_db_path.exists() { + canonical_db_path + } else if std::env::var_os("GOOSE_PATH_ROOT").is_some() { + return Ok(pull_requests.into_iter().map(|pr| (pr.id, None)).collect()); + } else if let Some(legacy_db_path) = legacy_db_path { + legacy_db_path + } else { + return Ok(pull_requests.into_iter().map(|pr| (pr.id, None)).collect()); + }; + + let db_url = format!("sqlite:{}?mode=ro", db_path.to_string_lossy()); + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect(&db_url) + .await + .map_err(|error| format!("Failed to open Berd chat database: {error}"))?; + let rows = sqlx::query(WORKSPACE_CANDIDATES_QUERY) + .bind(MAX_MESSAGE_CANDIDATES) + .bind(MAX_WORKSPACE_CANDIDATES as i64) + .fetch_all(&pool) + .await + .map_err(|error| format!("Failed to read Berd chat projects: {error}"))?; + + let mut session_workspaces = Vec::with_capacity(rows.len()); + let mut seen_working_dirs = std::collections::HashSet::new(); + for row in rows { + use sqlx::Row; + let working_dir: Option = row.try_get("working_dir").map_err(to_string)?; + let Some(working_dir) = working_dir else { + continue; + }; + if !seen_working_dirs.insert(working_dir.clone()) { + continue; + } + let project_id: String = row.try_get("project_id").map_err(to_string)?; + session_workspaces.push((project_id, working_dir)); + } + + let mut project_by_url = project_by_pr_url_cache() + .lock() + .map_err(|_| "PR project cache is unavailable".to_string())? + .clone(); + let requested_urls = pull_requests + .iter() + .filter(|pr| !project_by_url.contains_key(&pr.url)) + .map(|pr| PullRequestUrlMatch { + url: pr.url.clone(), + }) + .collect::>(); + if !requested_urls.is_empty() { + let requested_urls_json = serde_json::to_string(&requested_urls) + .map_err(|error| format!("Failed to encode pull request URLs: {error}"))?; + let message_matches = sqlx::query( + r#" + WITH requested_urls AS ( + SELECT json_extract(value, '$.url') AS url + FROM json_each(?) + ), recent_messages AS ( + SELECT id, session_id, content_json + FROM messages + WHERE role = 'assistant' + ORDER BY id DESC + LIMIT ? + ), candidate_messages AS ( + SELECT requested_urls.url, + recent_messages.id AS message_id, + recent_messages.session_id, + recent_messages.content_json + FROM requested_urls + JOIN recent_messages + ON INSTR(recent_messages.content_json, requested_urls.url) > 0 + ), text_matches AS ( + SELECT candidate_messages.url, + candidate_messages.message_id, + s.project_id + FROM candidate_messages + JOIN sessions s ON s.id = candidate_messages.session_id + JOIN json_each(candidate_messages.content_json) AS content + WHERE s.project_id IS NOT NULL + AND TRIM(s.project_id) != '' + AND json_extract(content.value, '$.type') = 'text' + AND INSTR(json_extract(content.value, '$.text'), candidate_messages.url) > 0 + AND SUBSTR( + json_extract(content.value, '$.text'), + INSTR(json_extract(content.value, '$.text'), candidate_messages.url) + + LENGTH(candidate_messages.url), + 1 + ) NOT GLOB '[0-9]' + ) + SELECT url, project_id + FROM text_matches + WHERE message_id = ( + SELECT MIN(first_match.message_id) + FROM text_matches AS first_match + WHERE first_match.url = text_matches.url + ) + "#, + ) + .bind(requested_urls_json) + .bind(MAX_MESSAGE_CANDIDATES) + .fetch_all(&pool) + .await + .map_err(|error| format!("Failed to match pull requests to Berd chats: {error}"))?; + let mut cache = project_by_pr_url_cache() + .lock() + .map_err(|_| "PR project cache is unavailable".to_string())?; + for row in message_matches { + use sqlx::Row; + let url: String = row.try_get("url").map_err(to_string)?; + let project_id: String = row.try_get("project_id").map_err(to_string)?; + project_by_url.insert(url.clone(), project_id.clone()); + cache.insert(url, project_id); + } + } + + let mut git_identities: Option> = None; + let mut resolved = std::collections::HashMap::with_capacity(pull_requests.len()); + for pr in pull_requests { + let repository = + normalize_github_repository(pr.head_repository.as_deref().unwrap_or(&pr.repository)); + let project_id = if let Some(project_id) = project_by_url.get(&pr.url) { + Some(project_id.clone()) + } else { + if git_identities.is_none() { + let mut identities = stream::iter(session_workspaces.iter().cloned().enumerate()) + .map(|(index, (project_id, working_dir))| async move { + ( + index, + project_id, + git_repository_and_branch(&working_dir).await, + ) + }) + .buffer_unordered(GIT_PROBE_CONCURRENCY) + .collect::>() + .await; + identities.sort_by_key(|(index, _, _)| *index); + git_identities = Some( + identities + .into_iter() + .map(|(_, project_id, git_identity)| (project_id, git_identity)) + .collect(), + ); + } + git_identities.as_ref().and_then(|sessions| { + sessions.iter().find_map(|(project_id, git_identity)| { + let (session_repository, session_branch) = git_identity.as_ref()?; + (session_repository == &repository && session_branch == &pr.head_ref_name) + .then(|| project_id.clone()) + }) + }) + }; + resolved.insert(pr.id, project_id); + } + Ok(resolved) +} + +#[tauri::command] +pub fn open_pr_tracker_url( + app: tauri::AppHandle, + url: String, +) -> Result<(), String> { + validate_github_url(&url)?; + app.opener() + .open_url(&url, None::<&str>) + .map_err(|error| format!("Failed to open URL: {error}")) +} + +async fn git_repository_and_branch(working_dir: &str) -> Option<(String, String)> { + let branch = git_output(working_dir, &["branch", "--show-current"]).await?; + if branch.is_empty() { + return None; + } + let remote = git_output(working_dir, &["remote", "get-url", "origin"]).await?; + Some((normalize_github_repository(&remote), branch)) +} + +async fn git_output(working_dir: &str, args: &[&str]) -> Option { + let mut command = TokioCommand::new("git"); + command + .args(["-C", working_dir]) + .args(args) + .kill_on_drop(true) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()); + let output = timeout(Duration::from_secs(5), command.output()) + .await + .ok()? + .ok()?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn normalize_github_repository(value: &str) -> String { + let value = value.trim().trim_end_matches(".git"); + let path = value + .strip_prefix("git@github.com:") + .or_else(|| value.strip_prefix("ssh://git@github.com/")) + .or_else(|| value.strip_prefix("https://github.com/")) + .or_else(|| value.strip_prefix("http://github.com/")) + .unwrap_or(value); + path.trim_matches('/').to_ascii_lowercase() +} + +fn validate_pull_requests( + pull_requests: Vec, +) -> Result, String> { + if pull_requests.len() > MAX_PULL_REQUESTS { + return Err(format!( + "PR tracker accepts at most {MAX_PULL_REQUESTS} pull requests" + )); + } + + let mut seen_ids = std::collections::HashSet::with_capacity(pull_requests.len()); + for pull_request in &pull_requests { + if pull_request.id.trim().is_empty() || pull_request.id.len() > MAX_ID_LENGTH { + return Err("Pull request id is missing or too long".to_string()); + } + if !seen_ids.insert(pull_request.id.as_str()) { + return Err("Pull request ids must be unique".to_string()); + } + if pull_request.repository.len() > MAX_REPOSITORY_LENGTH + || pull_request + .head_repository + .as_ref() + .is_some_and(|repository| repository.len() > MAX_REPOSITORY_LENGTH) + || pull_request.head_ref_name.trim().is_empty() + || pull_request.head_ref_name.len() > MAX_BRANCH_LENGTH + { + return Err("Pull request repository or branch is invalid".to_string()); + } + let parsed = reqwest::Url::parse(&pull_request.url) + .map_err(|error| format!("Invalid pull request URL: {error}"))?; + if parsed.scheme() != "https" || parsed.host_str() != Some("github.com") { + return Err("Pull request URLs must use https://github.com".to_string()); + } + let segments = parsed + .path_segments() + .map(|segments| segments.collect::>()) + .unwrap_or_default(); + if segments.len() != 4 + || segments[2] != "pull" + || segments[3].parse::().is_err() + || !format!("{}/{}", segments[0], segments[1]) + .eq_ignore_ascii_case(&pull_request.repository) + { + return Err("Pull request URL does not match its repository".to_string()); + } + } + Ok(pull_requests) +} + +fn validate_github_url(url: &str) -> Result<(), String> { + let parsed = reqwest::Url::parse(url).map_err(|error| format!("Invalid URL: {error}"))?; + if parsed.scheme() != "https" { + return Err("Only https URLs can be opened from PR tracker".to_string()); + } + let host = parsed.host_str().unwrap_or_default(); + if host != "github.com" && !host.ends_with(".github.com") { + return Err("Only GitHub URLs can be opened from PR tracker".to_string()); + } + Ok(()) +} + +fn find_executable(name: &str, shell_path: Option<&str>) -> Option { + let executable_name = if cfg!(windows) { + format!("{name}.exe") + } else { + name.to_string() + }; + let mut directories = shell_path + .map(std::env::split_paths) + .into_iter() + .flatten() + .collect::>(); + if let Some(path) = std::env::var_os("PATH") { + directories.extend(std::env::split_paths(&path)); + } + directories + .into_iter() + .map(|directory| directory.join(&executable_name)) + .find(|path| path.is_file()) +} + +fn to_string(error: impl std::fmt::Display) -> String { + error.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn pull_request(id: &str, url: &str, repository: &str) -> PullRequestIdentity { + PullRequestIdentity { + id: id.to_string(), + url: url.to_string(), + repository: repository.to_string(), + head_repository: None, + head_ref_name: "feature/test".to_string(), + } + } + + #[test] + fn validates_pull_request_payload_bounds() { + assert!(validate_pull_requests(vec![pull_request( + "pr-1", + "https://github.com/squareup/berd/pull/1", + "squareup/berd", + )]) + .is_ok()); + + let duplicate = pull_request( + "pr-1", + "https://github.com/squareup/berd/pull/2", + "squareup/berd", + ); + assert!(validate_pull_requests(vec![ + pull_request( + "pr-1", + "https://github.com/squareup/berd/pull/1", + "squareup/berd", + ), + duplicate, + ]) + .is_err()); + assert!(validate_pull_requests(vec![pull_request( + "pr-1", + "https://github.com/squareup/other/pull/1", + "squareup/berd", + )]) + .is_err()); + assert!(validate_pull_requests(vec![pull_request( + "pr-1", + "https://example.com/squareup/berd/pull/1", + "squareup/berd", + )]) + .is_err()); + } + + #[tokio::test] + async fn workspace_candidates_rank_by_latest_real_activity_before_limiting() { + use sqlx::Row; + + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::query( + r#" + CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + working_dir TEXT, + project_id TEXT, + created_at TEXT, + updated_at TEXT, + archived_at TEXT, + session_type TEXT + ); + CREATE TABLE messages ( + id INTEGER PRIMARY KEY, + session_id TEXT NOT NULL, + created_timestamp INTEGER NOT NULL + ); + CREATE INDEX idx_messages_session ON messages(session_id); + "#, + ) + .execute(&pool) + .await + .unwrap(); + + for index in 0..MAX_WORKSPACE_CANDIDATES { + sqlx::query("INSERT INTO sessions VALUES (?, ?, ?, ?, ?, NULL, 'acp')") + .bind(format!("candidate-{index}")) + .bind(format!("/workspace/{index}")) + .bind(format!("project-{index}")) + .bind(format!("2026-08-{:02}T00:00:00Z", index + 1)) + .bind(format!("2026-08-{:02}T00:00:00Z", index + 1)) + .execute(&pool) + .await + .unwrap(); + } + sqlx::query("INSERT INTO sessions VALUES (?, ?, ?, ?, ?, NULL, 'acp')") + .bind("skewed-old") + .bind("/workspace/skewed") + .bind("project-old") + .bind("2026-07-01T00:00:00Z") + .bind("2026-07-01T00:00:00Z") + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO sessions VALUES (?, ?, ?, ?, ?, NULL, 'acp')") + .bind("skewed-new") + .bind("/workspace/skewed") + .bind("project-new") + .bind("2026-09-01T00:00:00Z") + .bind("2026-01-01T00:00:00Z") + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO messages VALUES (?, ?, ?)") + .bind(1) + .bind("skewed-new") + .bind(1_790_812_800_000_i64) + .execute(&pool) + .await + .unwrap(); + + let rows = sqlx::query(WORKSPACE_CANDIDATES_QUERY) + .bind(MAX_MESSAGE_CANDIDATES) + .bind(MAX_WORKSPACE_CANDIDATES as i64) + .fetch_all(&pool) + .await + .unwrap(); + + assert_eq!(rows.len(), MAX_WORKSPACE_CANDIDATES); + assert_eq!(rows[0].get::("id"), "skewed-new"); + assert_eq!(rows[0].get::("project_id"), "project-new"); + assert_eq!(rows[0].get::("activity_at"), 1_790_812_800); + assert!(!rows + .iter() + .any(|row| row.get::("id") == "skewed-old")); + } + + #[test] + fn validates_only_github_https_urls() { + assert!(validate_github_url("https://github.com/block/berd/pull/1").is_ok()); + assert!(validate_github_url("http://github.com/block/berd/pull/1").is_err()); + assert!(validate_github_url("https://example.com/block/berd/pull/1").is_err()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b01ce27d8..901bbfdd6 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -445,6 +445,9 @@ pub fn run() { Ok(()) }) .invoke_handler(tauri::generate_handler![ + commands::pr_tracker::open_pr_tracker_url, + commands::pr_tracker::resolve_pr_tracker_projects, + commands::pr_tracker::list_pr_tracker_pull_requests, commands::agents::read_import_persona_file, commands::agents::read_import_agent_file, commands::agents::read_import_agent_image, diff --git a/src-tauri/src/services/goose_config.rs b/src-tauri/src/services/goose_config.rs index 60a7f1b98..e444ac3d5 100644 --- a/src-tauri/src/services/goose_config.rs +++ b/src-tauri/src/services/goose_config.rs @@ -33,6 +33,23 @@ pub(crate) fn config_path() -> Result { Ok(strategy.config_dir().join(CONFIG_FILE_NAME)) } +/// Resolve the upstream Goose state directory using the same path strategy as +/// goosed. `GOOSE_PATH_ROOT` stores state beneath `/state`. +pub(crate) fn state_dir() -> Result { + if let Some(root) = validated_path_root(env::var_os(GOOSE_PATH_ROOT_ENV)) { + return Ok(root.join("state")); + } + + let strategy = choose_app_strategy(AppStrategyArgs { + top_level_domain: "Block".to_string(), + author: "Block".to_string(), + app_name: "goose".to_string(), + }) + .map_err(|err| format!("Failed to resolve goose state directory: {err}"))?; + + Ok(strategy.state_dir().unwrap_or_else(|| strategy.data_dir())) +} + fn validated_path_root(value: Option) -> Option { value.map(PathBuf::from).filter(|path| path.is_absolute()) } diff --git a/src-tauri/src/services/log_export.rs b/src-tauri/src/services/log_export.rs index 29ba0892b..286f1549c 100644 --- a/src-tauri/src/services/log_export.rs +++ b/src-tauri/src/services/log_export.rs @@ -27,7 +27,6 @@ use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; use chrono::{DateTime, NaiveDateTime, Utc}; -use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs}; use tauri::Manager; use zip::write::SimpleFileOptions; use zip::{CompressionMethod, ZipWriter}; @@ -74,34 +73,13 @@ pub(crate) fn resolve_log_dirs(app: &tauri::AppHandle) -> Result Result { - if let Ok(root) = std::env::var("GOOSE_PATH_ROOT") { - let root = root.trim(); - if !root.is_empty() { - return Ok(PathBuf::from(root).join("state")); - } - } - - // NOTE: "Block" matches goosed's own `Paths` strategy (kept for backwards - // compatibility with existing install dirs). Reusing the same `etcetera` - // call guarantees we resolve the identical directory on every platform. - let strategy = choose_app_strategy(AppStrategyArgs { - top_level_domain: "Block".to_string(), - author: "Block".to_string(), - app_name: "goose".to_string(), - }) - .map_err(|error| format!("Failed to resolve goose state directory: {error}"))?; - - Ok(strategy.state_dir().unwrap_or_else(|| strategy.data_dir())) -} - struct ZipEntry { /// Path inside the archive. name: String, diff --git a/src/app/ui/TopBar.tsx b/src/app/ui/TopBar.tsx index 97737fc5c..81431fb34 100644 --- a/src/app/ui/TopBar.tsx +++ b/src/app/ui/TopBar.tsx @@ -10,7 +10,10 @@ import { } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; import { useTopBarActions } from "@/app/contexts/TopBarActionsContext"; +import { RELATED_PULL_REQUESTS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; +import { useExperiment } from "@/features/experiments/experimentPreferences"; import { BetaBadge } from "@/features/updates/ui/BetaBadge"; +import { PullRequestsPopover } from "@/features/work-status/PullRequestsPopover"; import { cn } from "@/shared/lib/cn"; import { TopBarIconButton } from "@/shared/ui/top-bar-icon-button"; import { BerdIcon } from "@/shared/ui/icons/BerdIcon"; @@ -65,6 +68,8 @@ export function TopBar({ }: TopBarProps) { const { t } = useTranslation(["sidebar", "feedback"]); const viewActions = useTopBarActions(); + const pullRequestsEnabled = + useExperiment(RELATED_PULL_REQUESTS_EXPERIMENT_ID)?.enabled === true; const topBarTitle = breadcrumbs.find((breadcrumb) => breadcrumb.id === "chat-session")?.label ?? breadcrumbs.find((breadcrumb) => breadcrumb.id === "skills")?.label ?? @@ -150,6 +155,7 @@ export function TopBar({ ) : null}
+ {pullRequestsEnabled ? : null} {viewActions} diff --git a/src/features/chat/ui/MessageTimelineScrollContainer.tsx b/src/features/chat/ui/MessageTimelineScrollContainer.tsx index b7bb7a540..742670dfc 100644 --- a/src/features/chat/ui/MessageTimelineScrollContainer.tsx +++ b/src/features/chat/ui/MessageTimelineScrollContainer.tsx @@ -1,178 +1,31 @@ -import { - forwardRef, - useCallback, - useEffect, - useLayoutEffect, - useRef, - type ComponentPropsWithoutRef, - type ForwardedRef, -} from "react"; +import { forwardRef, type ComponentPropsWithoutRef } from "react"; + import { cn } from "@/shared/lib/cn"; +import { ScrollIntentArea } from "@/shared/ui/scroll-intent-area"; -const MESSAGE_TIMELINE_SCROLL_CONTAINER_CLASS = - "scrollbar-subtle relative z-0 min-h-0 flex-1 overflow-y-auto overscroll-contain"; -const SCROLLBAR_PASSIVE_SUPPRESSED_ATTRIBUTE = - "data-scrollbar-passive-suppressed"; -const SCROLL_REVEAL_LISTENER_OPTIONS: AddEventListenerOptions = { - passive: true, -}; -const RESIZE_DELTA_EPSILON_PX = 0.5; +const MESSAGE_TIMELINE_SCROLL_CONTAINER_CLASS = "relative z-0 min-h-0 flex-1"; interface MessageTimelineScrollContainerProps extends ComponentPropsWithoutRef<"div"> { hasFooter: boolean; } -function assignForwardedRef(ref: ForwardedRef, value: T | null) { - if (typeof ref === "function") { - ref(value); - return; - } - - if (ref) { - ref.current = value; - } -} - export const MessageTimelineScrollContainer = forwardRef< HTMLDivElement, MessageTimelineScrollContainerProps ->(({ children, className, hasFooter, ...props }, forwardedRef) => { - const containerRef = useRef(null); - const lastContainerSizeRef = useRef<{ - width: number; - height: number; - } | null>(null); - - const setContainerRef = useCallback( - (node: HTMLDivElement | null) => { - containerRef.current = node; - lastContainerSizeRef.current = null; - assignForwardedRef(forwardedRef, node); - }, - [forwardedRef], - ); - - const setPassiveSuppression = useCallback((suppressed: boolean) => { - const container = containerRef.current; - if (!container) { - return; - } - - if (suppressed) { - container.setAttribute(SCROLLBAR_PASSIVE_SUPPRESSED_ATTRIBUTE, "true"); - return; - } - - container.removeAttribute(SCROLLBAR_PASSIVE_SUPPRESSED_ATTRIBUTE); - }, []); - - const revealScrollbarForUserIntent = useCallback(() => { - setPassiveSuppression(false); - }, [setPassiveSuppression]); - - useLayoutEffect(() => { - setPassiveSuppression(true); - }, [setPassiveSuppression]); - - useEffect(() => { - const handleWindowResize = () => setPassiveSuppression(true); - - window.addEventListener("resize", handleWindowResize); - - return () => { - window.removeEventListener("resize", handleWindowResize); - setPassiveSuppression(false); - }; - }, [setPassiveSuppression]); - - useEffect(() => { - const container = containerRef.current; - if (!container) { - return; - } - - container.addEventListener("focusin", revealScrollbarForUserIntent); - container.addEventListener("keydown", revealScrollbarForUserIntent); - container.addEventListener("pointerdown", revealScrollbarForUserIntent); - container.addEventListener( - "touchmove", - revealScrollbarForUserIntent, - SCROLL_REVEAL_LISTENER_OPTIONS, - ); - container.addEventListener( - "wheel", - revealScrollbarForUserIntent, - SCROLL_REVEAL_LISTENER_OPTIONS, - ); - - return () => { - container.removeEventListener("focusin", revealScrollbarForUserIntent); - container.removeEventListener("keydown", revealScrollbarForUserIntent); - container.removeEventListener( - "pointerdown", - revealScrollbarForUserIntent, - ); - container.removeEventListener( - "touchmove", - revealScrollbarForUserIntent, - SCROLL_REVEAL_LISTENER_OPTIONS, - ); - container.removeEventListener( - "wheel", - revealScrollbarForUserIntent, - SCROLL_REVEAL_LISTENER_OPTIONS, - ); - }; - }, [revealScrollbarForUserIntent]); - - useEffect(() => { - const container = containerRef.current; - if (!container || typeof ResizeObserver === "undefined") { - return; - } - - const resizeObserver = new ResizeObserver((entries) => { - const entry = entries.find((candidate) => candidate.target === container); - if (!entry) { - return; - } - - const { width, height } = entry.contentRect; - const lastSize = lastContainerSizeRef.current; - lastContainerSizeRef.current = { width, height }; - - if (!lastSize) { - return; - } - - const sizeChanged = - Math.abs(width - lastSize.width) > RESIZE_DELTA_EPSILON_PX || - Math.abs(height - lastSize.height) > RESIZE_DELTA_EPSILON_PX; - if (sizeChanged) { - setPassiveSuppression(true); - } - }); - - resizeObserver.observe(container); - - return () => resizeObserver.disconnect(); - }, [setPassiveSuppression]); - - return ( -
- {children} -
- ); -}); +>(({ children, className, hasFooter, ...props }, forwardedRef) => ( + + {children} + +)); MessageTimelineScrollContainer.displayName = "MessageTimelineScrollContainer"; diff --git a/src/features/design-system/generated/componentManifest.ts b/src/features/design-system/generated/componentManifest.ts index 0cffb39eb..2f84dbf2c 100644 --- a/src/features/design-system/generated/componentManifest.ts +++ b/src/features/design-system/generated/componentManifest.ts @@ -1199,6 +1199,44 @@ export const designSystemComponentManifest = [ stateClasses: [], sourceTokenClasses: [], }, + { + name: "Content Toolbar Icon Button", + source: "src/shared/ui/content-toolbar-icon-button.tsx", + description: + "Icon action for compact toolbars inside content surfaces such as popovers.\nUses top-bar geometry without inheriting app-chrome colors or hover fills.", + exports: ["ContentToolbarIconButton", "ContentToolbarIconButtonProps"], + slots: [], + cva: [], + tokenClasses: [ + "active:text-foreground", + "aria-expanded:text-foreground", + "data-[state=open]:text-foreground", + "hover:text-foreground", + "text-foreground", + ], + stateClasses: [ + "active:bg-transparent", + "active:opacity-[var(--app-top-bar-control-hover-opacity)]", + "active:text-foreground", + "aria-expanded:bg-transparent", + "aria-expanded:opacity-[var(--app-top-bar-control-hover-opacity)]", + "aria-expanded:text-foreground", + "data-[state=open]:bg-transparent", + "data-[state=open]:opacity-[var(--app-top-bar-control-hover-opacity)]", + "data-[state=open]:text-foreground", + "focus-visible:bg-transparent", + "hover:bg-transparent", + "hover:opacity-[var(--app-top-bar-control-hover-opacity)]", + "hover:text-foreground", + ], + sourceTokenClasses: [ + "active:text-foreground", + "aria-expanded:text-foreground", + "data-[state=open]:text-foreground", + "hover:text-foreground", + "text-foreground", + ], + }, { name: "Context Menu", source: "src/shared/ui/context-menu.tsx", @@ -2555,6 +2593,17 @@ export const designSystemComponentManifest = [ ], sourceTokenClasses: ["bg-border", "focus-visible:ring-ring/50"], }, + { + name: "Scroll Intent Area", + source: "src/shared/ui/scroll-intent-area.tsx", + description: "", + exports: ["ScrollIntentArea"], + slots: [], + cva: [], + tokenClasses: [], + stateClasses: ["data-scrollbar-passive-suppressed"], + sourceTokenClasses: [], + }, { name: "Searchable Select", source: "src/shared/ui/searchable-select.tsx", diff --git a/src/features/experiments/experimentDefinitions.ts b/src/features/experiments/experimentDefinitions.ts index bee329150..49d131b4b 100644 --- a/src/features/experiments/experimentDefinitions.ts +++ b/src/features/experiments/experimentDefinitions.ts @@ -66,6 +66,7 @@ export const BERDY_ONBOARDING_EXPERIMENT_ID = "berdy-onboarding"; export const SKILL_DISCOVERY_EXPERIMENT_ID = "skill-discovery"; export const RELATED_PULL_REQUESTS_EXPERIMENT_ID = "related-pull-requests"; + export const EXPERIMENT_DEFINITIONS = [ { id: BUILDERBOT_SURFACE_EXPERIMENT_ID, diff --git a/src/features/pull-requests/ui/PullRequestListItem.test.tsx b/src/features/pull-requests/ui/PullRequestListItem.test.tsx index 2826d4d3b..46e1bd011 100644 --- a/src/features/pull-requests/ui/PullRequestListItem.test.tsx +++ b/src/features/pull-requests/ui/PullRequestListItem.test.tsx @@ -27,6 +27,12 @@ describe("PullRequestListItem", () => { expect(screen.getByText("Checks pending")).toBeVisible(); expect(screen.getByText("Aug 19")).toBeVisible(); + const item = screen.getByRole("button", { + name: "Open block/berd pull request #42 on GitHub", + }); + expect(item.className).toContain("bg-muted/60"); + expect(item.className).toContain("normal-case"); + fireEvent.click( screen.getByRole("button", { name: "Open block/berd pull request #42 on GitHub", diff --git a/src/features/pull-requests/ui/PullRequestListItem.tsx b/src/features/pull-requests/ui/PullRequestListItem.tsx index 67218ab8e..860f53ce2 100644 --- a/src/features/pull-requests/ui/PullRequestListItem.tsx +++ b/src/features/pull-requests/ui/PullRequestListItem.tsx @@ -49,7 +49,7 @@ export function PullRequestListItem({ type="button" data-slot="pull-request-list-item" className={cn( - "group flex w-full min-w-0 items-start gap-2 overflow-hidden rounded-sm px-3 py-2.5 text-left text-foreground outline-none transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-ring", + "group flex w-full min-w-0 items-start gap-2 overflow-hidden rounded-xl bg-muted/60 px-3 py-2.5 text-left normal-case text-foreground outline-none transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:ring-2 focus-visible:ring-ring", className, )} aria-label={ariaLabel} diff --git a/src/features/work-status/PullRequestsPanel.tsx b/src/features/work-status/PullRequestsPanel.tsx new file mode 100644 index 000000000..7da13239e --- /dev/null +++ b/src/features/work-status/PullRequestsPanel.tsx @@ -0,0 +1,613 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { + AlertCircle, + CheckCircle2, + ChevronDown, + ChevronRight, + GitPullRequest, + MonitorCog, + RefreshCw, + TestTube2, +} from "lucide-react"; + +import { + PullRequestListItem, + type PullRequestListItemStatus, +} from "@/features/pull-requests/ui/PullRequestListItem"; +import type { ProjectInfo } from "@/features/projects/api/projects"; +import { useProjectStore } from "@/features/projects/stores/projectStore"; +import { ProjectIcon } from "@/features/projects/ui/ProjectIcon"; +import { Badge } from "@/shared/ui/badge"; +import { CollapseReveal } from "@/shared/ui/collapse-reveal"; +import { ScrollIntentArea } from "@/shared/ui/scroll-intent-area"; +import { ContentToolbarIconButton } from "@/shared/ui/content-toolbar-icon-button"; +import { Skeleton } from "@/shared/ui/skeleton"; +import { cn } from "@/shared/lib/cn"; +import { useLocaleFormatting } from "@/shared/i18n/format"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; +import { + SIDEBAR_GROUP_LABEL_TEXT_CLASS, + SIDEBAR_ROW_HORIZONTAL_INSET_CLASS, +} from "@/shared/ui/sidebar-tokens"; +import { useWorkStatusStore } from "./workStatusStore"; +import { WORK_STATUS_LABEL_KEYS } from "./statusModel"; +import { + openWorkStatusUrl, + WORK_STATUS_REFRESH_EVENT, +} from "./workStatusNative"; +import type { + WorkStatusErrorCode, + WorkStatusItem, + WorkStatusState, +} from "./types"; + +interface PullRequestsPanelProps { + className?: string; +} + +type PullRequestsPreviewState = + | "live" + | "no-prs" + | "github" + | "error" + | "rate-limit" + | "stale-error" + | "truncated" + | "statuses"; + +export function PullRequestsPanel({ className }: PullRequestsPanelProps) { + const { t } = useTranslation("common"); + const { formatRelativeTimeToNow } = useLocaleFormatting(); + const [previewState, setPreviewState] = + useState("live"); + const pullRequests = useWorkStatusStore( + (state) => state.snapshot.pullRequests, + ); + const errors = useWorkStatusStore((state) => state.snapshot.errors); + const isTruncated = useWorkStatusStore((state) => state.snapshot.isTruncated); + const pullRequestsRefreshedAt = useWorkStatusStore( + (state) => state.pullRequestsRefreshedAt, + ); + const isManualRefreshPending = useWorkStatusStore( + (state) => state.isManualRefreshPending, + ); + const lastManualRefreshSucceeded = useWorkStatusStore( + (state) => state.lastManualRefreshSucceeded, + ); + const setManualRefreshPending = useWorkStatusStore( + (state) => state.setManualRefreshPending, + ); + const projects = useProjectStore((state) => state.projects); + const [showRefreshFeedback, setShowRefreshFeedback] = useState(false); + const previousManualRefreshPendingRef = useRef(isManualRefreshPending); + const [refreshAnnouncement, setRefreshAnnouncement] = useState(""); + + useEffect(() => { + if (previousManualRefreshPendingRef.current && !isManualRefreshPending) { + setRefreshAnnouncement( + lastManualRefreshSucceeded + ? t("workStatus.updatedNow") + : t("workStatus.refreshFailed"), + ); + } + previousManualRefreshPendingRef.current = isManualRefreshPending; + }, [isManualRefreshPending, lastManualRefreshSucceeded, t]); + const groups = useMemo( + () => groupItemsByProject(pullRequests, projects), + [pullRequests, projects], + ); + const blockingAuthError = + pullRequests.length === 0 + ? errors.find((error) => error.id === "github-auth") + : undefined; + const blockingError = + pullRequests.length === 0 + ? errors.find((error) => error.id !== "github-auth") + : undefined; + const otherErrors = errors.filter( + (error) => error !== blockingAuthError && error !== blockingError, + ); + + return ( +
+ + {refreshAnnouncement} + +
+
+ +
+
+

+ {t("workStatus.title")} +

+

+ {isManualRefreshPending + ? t("workStatus.updating") + : pullRequestsRefreshedAt + ? t("workStatus.updated", { + time: formatRelativeTimeToNow(pullRequestsRefreshedAt), + }) + : t("workStatus.collecting")} +

+
+ {import.meta.env.DEV ? ( + + + + + + + + + {t("workStatus.preview.devOnly")} + + + setPreviewState("live")}> + {t("workStatus.preview.live")} + + setPreviewState("no-prs")}> + {t("workStatus.preview.noPrs")} + + setPreviewState("github")}> + {t("workStatus.preview.githubDisconnected")} + + setPreviewState("error")}> + {t("workStatus.preview.connectionError")} + + setPreviewState("rate-limit")}> + {t("workStatus.preview.rateLimited")} + + setPreviewState("stale-error")}> + {t("workStatus.preview.staleError")} + + setPreviewState("truncated")}> + {t("workStatus.preview.truncated")} + + setPreviewState("statuses")}> + {t("workStatus.preview.statuses")} + + + + ) : null} + { + setRefreshAnnouncement(t("workStatus.updating")); + setShowRefreshFeedback(false); + setManualRefreshPending(true); + window.requestAnimationFrame(() => setShowRefreshFeedback(true)); + window.dispatchEvent(new CustomEvent(WORK_STATUS_REFRESH_EVENT)); + }} + > + setShowRefreshFeedback(false)} + /> + +
+ +
+ div]:h-full" + : undefined, + )} + > + {previewState === "no-prs" ? ( + + ) : previewState === "github" ? ( + + ) : previewState === "error" ? ( + + ) : previewState === "rate-limit" ? ( + + ) : previewState === "stale-error" ? ( + + ) : previewState === "truncated" ? ( + + ) : previewState === "statuses" ? ( + + ) : blockingAuthError ? ( + + ) : blockingError ? ( + + ) : pullRequestsRefreshedAt === null ? ( + + ) : pullRequests.length === 0 ? ( + + ) : ( +
+ {groups.map((group) => ( + + ))} +
+ )} +
+ {(previewState === "live" && + pullRequests.length > 0 && + !blockingAuthError && + !blockingError) || + previewState === "statuses" ? ( + + + {isTruncated || previewState === "truncated" ? ( +
+ {t("workStatus.truncated", { count: 250 })} +
+ ) : null} + + {previewState === "stale-error" ? ( +
+ {t("workStatus.error.network")} +
+ ) : null} + + {otherErrors.length > 0 ? ( +
+ {otherErrors.map((error) => ( +
+ + + {t(`workStatus.error.${error.code}`)} + +
+ ))} +
+ ) : null} +
+ ); +} + +function PullRequestsLoadingState({ label }: { label: string }) { + return ( +
+ {[0, 1, 2].map((index) => ( +
+ + + +
+ ))} +
+ ); +} + +function NoPullRequestsEmptyState() { + const { t } = useTranslation("common"); + return ( +
+ + + +

{t("workStatus.empty.title")}

+

+ {t("workStatus.empty.description")} +

+
+ ); +} + +function PullRequestsErrorPreview({ + errorCode, +}: { + errorCode?: WorkStatusErrorCode; +}) { + const { t } = useTranslation("common"); + return ( +
+ + + +

{t("workStatus.error.title")}

+

+ {t(`workStatus.error.${errorCode ?? "unknown"}`)} +

+
+ ); +} + +function GitHubConnectionEmptyState() { + const { t } = useTranslation("common"); + return ( +
+ + + +

+ {t("workStatus.githubDisconnected.title")} +

+

+ {t("workStatus.githubDisconnected.description")} +

+ + gh auth login + +
+ ); +} + +const PREVIEW_PR_STATES: WorkStatusState[] = [ + "draft", + "awaitingApproval", + "changesRequested", + "checksFailing", + "readyToMerge", + "mergeBlocked", +]; + +function PullRequestStatusPreview() { + const { t } = useTranslation("common"); + return ( +
+ {PREVIEW_PR_STATES.map((status, index) => ( + + ))} +
+ ); +} + +interface PullRequestProjectGroupModel { + project: ProjectInfo | null; + items: WorkStatusItem[]; +} + +function PullRequestProjectGroup({ + group, +}: { + group: PullRequestProjectGroupModel; +}) { + const { t } = useTranslation("common"); + const [expanded, setExpanded] = useState(true); + const title = group.project?.name ?? t("workStatus.noProject"); + + return ( +
+ + +
+ {group.items.map((item) => ( + + ))} +
+
+
+ ); +} + +function PullRequestRow({ item }: { item: WorkStatusItem }) { + const { t } = useTranslation("common"); + const { formatDate } = useLocaleFormatting(); + const number = item.subtitle ?? item.destination.url.split("/").at(-1) ?? ""; + const statuses: PullRequestListItemStatus[] = [ + { + label: t(WORK_STATUS_LABEL_KEYS[item.status]), + tone: toneForStatus(item.status), + }, + ]; + + return ( + { + openWorkStatusUrl(item.destination.url).catch((error) => { + console.error("Failed to open pull request:", error); + toast.error(t("workStatus.openError")); + }); + }} + /> + ); +} + +function groupItemsByProject( + items: WorkStatusItem[], + projects: ProjectInfo[], +): PullRequestProjectGroupModel[] { + const projectsById = new Map( + projects.map((project) => [project.id, project]), + ); + const grouped = new Map(); + for (const item of items) { + const project = item.projectId + ? (projectsById.get(item.projectId) ?? null) + : null; + const key = project?.id ?? "no-project"; + const group = grouped.get(key) ?? { project, items: [] }; + group.items.push(item); + grouped.set(key, group); + } + + const sorted = Array.from(grouped.values()).map((group) => ({ + ...group, + items: [...group.items].sort( + (a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt), + ), + })); + const noProject = sorted.find((group) => group.project === null); + return [ + ...sorted + .filter((group) => group.project !== null) + .sort( + (a, b) => + (a.project?.order ?? Number.MAX_SAFE_INTEGER) - + (b.project?.order ?? Number.MAX_SAFE_INTEGER), + ), + ...(noProject ? [noProject] : []), + ]; +} + +function formatCount(count: number): string { + return count > 999 ? "999+" : String(count); +} + +function toneForStatus( + status: WorkStatusState, +): PullRequestListItemStatus["tone"] { + switch (status) { + case "readyToMerge": + return "success"; + case "awaitingApproval": + case "checksPending": + return "warning"; + case "changesRequested": + case "checksFailing": + case "mergeBlocked": + case "error": + return "danger"; + default: + return "muted"; + } +} + +function formatPullRequestTimestamp( + value: string, + formatDate: ( + value: Date | string | number, + options?: Intl.DateTimeFormatOptions, + ) => string, +): string { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const now = new Date(); + const sameDay = date.toDateString() === now.toDateString(); + return formatDate( + date, + sameDay + ? { hour: "numeric", minute: "2-digit" } + : { month: "short", day: "numeric" }, + ); +} diff --git a/src/features/work-status/PullRequestsPopover.tsx b/src/features/work-status/PullRequestsPopover.tsx new file mode 100644 index 000000000..df7eb8afe --- /dev/null +++ b/src/features/work-status/PullRequestsPopover.tsx @@ -0,0 +1,167 @@ +import { + useEffect, + useRef, + useState, + type KeyboardEvent, + type PointerEvent, +} from "react"; +import { useTranslation } from "react-i18next"; +import { GitPullRequest } from "lucide-react"; + +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { TopBarIconButton } from "@/shared/ui/top-bar-icon-button"; +import { PullRequestsPanel } from "./PullRequestsPanel"; +import { WorkStatusBridge } from "./WorkStatusBridge"; +import { useWorkStatusStore } from "./workStatusStore"; + +const DEFAULT_POPOVER_HEIGHT = 480; +const MIN_POPOVER_HEIGHT = 320; +const VIEWPORT_BOTTOM_GUTTER = 96; +const MIN_USABLE_POPOVER_HEIGHT = 160; + +function maxAvailableHeight(): number { + return Math.max( + MIN_USABLE_POPOVER_HEIGHT, + (window.visualViewport?.height ?? window.innerHeight) - + VIEWPORT_BOTTOM_GUTTER, + ); +} + +function minAvailableHeight(): number { + return Math.min(MIN_POPOVER_HEIGHT, maxAvailableHeight()); +} + +function formatCount(count: number): string { + return count >= 1_000 ? "999+" : String(count); +} + +export function PullRequestsPopover() { + const { t } = useTranslation("common"); + const pullRequestCount = useWorkStatusStore( + (state) => state.snapshot.pullRequests.length, + ); + const [open, setOpen] = useState(false); + const [height, setHeight] = useState(DEFAULT_POPOVER_HEIGHT); + const resizeStartRef = useRef<{ pointerY: number; height: number } | null>( + null, + ); + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + if (nextOpen) { + setHeight( + Math.min( + maxAvailableHeight(), + Math.max(minAvailableHeight(), DEFAULT_POPOVER_HEIGHT), + ), + ); + } + }; + + useEffect(() => { + if (!open) return; + const clampHeight = () => { + const maxHeight = maxAvailableHeight(); + setHeight((current) => + Math.min(maxHeight, Math.max(minAvailableHeight(), current)), + ); + }; + window.addEventListener("resize", clampHeight); + window.visualViewport?.addEventListener("resize", clampHeight); + return () => { + window.removeEventListener("resize", clampHeight); + window.visualViewport?.removeEventListener("resize", clampHeight); + }; + }, [open]); + + const handleResizePointerDown = (event: PointerEvent) => { + event.preventDefault(); + event.currentTarget.setPointerCapture(event.pointerId); + resizeStartRef.current = { pointerY: event.clientY, height }; + }; + + const handleResizePointerMove = (event: PointerEvent) => { + const start = resizeStartRef.current; + if (!start || !event.currentTarget.hasPointerCapture(event.pointerId)) { + return; + } + const maxHeight = maxAvailableHeight(); + setHeight( + Math.min( + maxHeight, + Math.max( + minAvailableHeight(), + start.height + event.clientY - start.pointerY, + ), + ), + ); + }; + + const handleResizePointerUp = (event: PointerEvent) => { + resizeStartRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + }; + + const handleResizeKeyDown = (event: KeyboardEvent) => { + const maxHeight = maxAvailableHeight(); + let nextHeight: number | null = null; + if (event.key === "ArrowUp") nextHeight = height - 24; + if (event.key === "ArrowDown") nextHeight = height + 24; + if (event.key === "Home") nextHeight = minAvailableHeight(); + if (event.key === "End") nextHeight = maxHeight; + if (nextHeight === null) return; + event.preventDefault(); + setHeight(Math.min(maxHeight, Math.max(minAvailableHeight(), nextHeight))); + }; + + return ( + <> + + + + + + {pullRequestCount > 0 ? ( + + ) : null} + + + + +
+
+
+ + ); +} diff --git a/src/features/work-status/WorkStatusBridge.test.tsx b/src/features/work-status/WorkStatusBridge.test.tsx new file mode 100644 index 000000000..093415171 --- /dev/null +++ b/src/features/work-status/WorkStatusBridge.test.tsx @@ -0,0 +1,248 @@ +import { act, render, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { WorkStatusSnapshot } from "./types"; +import { WorkStatusBridge } from "./WorkStatusBridge"; +import { WORK_STATUS_REFRESH_EVENT } from "./workStatusNative"; +import { + EMPTY_WORK_STATUS_SNAPSHOT, + useWorkStatusStore, +} from "./workStatusStore"; + +const buildWorkStatusSnapshotMock = vi.hoisted(() => vi.fn()); + +vi.mock("./workStatusData", () => ({ + buildWorkStatusSnapshot: buildWorkStatusSnapshotMock, +})); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function snapshot(title: string): WorkStatusSnapshot { + return { + chats: [], + errors: [], + isFresh: true, + isTruncated: false, + pullRequests: [ + { + id: title, + title, + groupName: "squareup/berd", + source: "github", + status: "draft", + updatedAt: "2026-08-07T00:00:00.000Z", + destination: { + type: "url", + url: "https://github.com/squareup/berd/pull/1", + }, + }, + ], + }; +} + +describe("WorkStatusBridge", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + beforeEach(() => { + vi.clearAllMocks(); + useWorkStatusStore.setState({ + snapshot: EMPTY_WORK_STATUS_SNAPSHOT, + pullRequestsRefreshedAt: null, + isManualRefreshPending: false, + lastManualRefreshSucceeded: null, + }); + }); + + it("does not request data while the PR Inbox is closed", async () => { + const { rerender } = render(); + + expect(buildWorkStatusSnapshotMock).not.toHaveBeenCalled(); + + rerender(); + await waitFor(() => + expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(1), + ); + + rerender(); + act(() => { + window.dispatchEvent(new CustomEvent(WORK_STATUS_REFRESH_EVENT)); + }); + expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(1); + }); + + it("polls only while the PR Inbox is open", async () => { + vi.useFakeTimers(); + buildWorkStatusSnapshotMock.mockResolvedValue(snapshot("current")); + const { rerender } = render(); + + await act(async () => Promise.resolve()); + expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(30_000); + }); + expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(2); + + rerender(); + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(2); + }); + + it("pauses while hidden and refreshes immediately when visible again", async () => { + let visibilityState: DocumentVisibilityState = "visible"; + vi.spyOn(document, "visibilityState", "get").mockImplementation( + () => visibilityState, + ); + buildWorkStatusSnapshotMock.mockResolvedValue(snapshot("current")); + render(); + await waitFor(() => + expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(1), + ); + + visibilityState = "hidden"; + act(() => document.dispatchEvent(new Event("visibilitychange"))); + act(() => { + window.dispatchEvent(new CustomEvent(WORK_STATUS_REFRESH_EVENT)); + }); + expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(1); + + visibilityState = "visible"; + act(() => document.dispatchEvent(new Event("visibilitychange"))); + await waitFor(() => + expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(2), + ); + }); + + it("refreshes immediately each time the PR Inbox opens", async () => { + buildWorkStatusSnapshotMock.mockResolvedValue(snapshot("current")); + const { rerender } = render(); + + rerender(); + await waitFor(() => + expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(1), + ); + + rerender(); + rerender(); + await waitFor(() => + expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(2), + ); + }); + + it("reports a rejected manual refresh as failed without advancing freshness", async () => { + const initial = deferred(); + const manualRefresh = deferred(); + buildWorkStatusSnapshotMock + .mockReturnValueOnce(initial.promise) + .mockReturnValueOnce(manualRefresh.promise); + + render(); + await waitFor(() => + expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(1), + ); + + await act(async () => initial.resolve(snapshot("initial"))); + const refreshedAt = useWorkStatusStore.getState().pullRequestsRefreshedAt; + expect(refreshedAt).not.toBeNull(); + + act(() => { + useWorkStatusStore.getState().setManualRefreshPending(true); + window.dispatchEvent(new CustomEvent(WORK_STATUS_REFRESH_EVENT)); + }); + await waitFor(() => + expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(2), + ); + + await act(async () => + manualRefresh.reject(new Error("malformed response")), + ); + + expect(useWorkStatusStore.getState()).toMatchObject({ + isManualRefreshPending: false, + lastManualRefreshSucceeded: false, + pullRequestsRefreshedAt: refreshedAt, + }); + expect(useWorkStatusStore.getState().snapshot.pullRequests[0]?.title).toBe( + "initial", + ); + }); + + it("runs a queued manual refresh after an automatic request rejects", async () => { + const initial = deferred(); + const manualRefresh = deferred(); + buildWorkStatusSnapshotMock + .mockReturnValueOnce(initial.promise) + .mockReturnValueOnce(manualRefresh.promise); + + render(); + await waitFor(() => + expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(1), + ); + + act(() => { + useWorkStatusStore.getState().setManualRefreshPending(true); + window.dispatchEvent(new CustomEvent(WORK_STATUS_REFRESH_EVENT)); + }); + + await act(async () => initial.reject(new Error("automatic failure"))); + await waitFor(() => + expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(2), + ); + expect(useWorkStatusStore.getState().isManualRefreshPending).toBe(true); + + await act(async () => manualRefresh.resolve(snapshot("manual"))); + expect(useWorkStatusStore.getState()).toMatchObject({ + isManualRefreshPending: false, + lastManualRefreshSucceeded: true, + }); + expect(useWorkStatusStore.getState().snapshot.pullRequests[0]?.title).toBe( + "manual", + ); + }); + + it("coalesces clicks during an active refresh into one follow-up request", async () => { + const initial = deferred(); + const followUp = deferred(); + buildWorkStatusSnapshotMock + .mockReturnValueOnce(initial.promise) + .mockReturnValueOnce(followUp.promise); + + render(); + await waitFor(() => + expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(1), + ); + + act(() => { + useWorkStatusStore.getState().setManualRefreshPending(true); + window.dispatchEvent(new CustomEvent(WORK_STATUS_REFRESH_EVENT)); + window.dispatchEvent(new CustomEvent(WORK_STATUS_REFRESH_EVENT)); + }); + expect(useWorkStatusStore.getState().isManualRefreshPending).toBe(true); + + await act(async () => initial.resolve(snapshot("initial"))); + await waitFor(() => + expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(2), + ); + expect(useWorkStatusStore.getState().isManualRefreshPending).toBe(true); + + await act(async () => followUp.resolve(snapshot("follow-up"))); + expect(useWorkStatusStore.getState().snapshot.pullRequests[0]?.title).toBe( + "follow-up", + ); + expect(useWorkStatusStore.getState().isManualRefreshPending).toBe(false); + expect(useWorkStatusStore.getState().lastManualRefreshSucceeded).toBe(true); + }); +}); diff --git a/src/features/work-status/WorkStatusBridge.tsx b/src/features/work-status/WorkStatusBridge.tsx new file mode 100644 index 000000000..a4bad9927 --- /dev/null +++ b/src/features/work-status/WorkStatusBridge.tsx @@ -0,0 +1,158 @@ +import { useEffect, useRef, useState } from "react"; + +import { buildWorkStatusSnapshot } from "./workStatusData"; +import { WORK_STATUS_REFRESH_EVENT } from "./workStatusNative"; +import { useWorkStatusStore } from "./workStatusStore"; + +const REFRESH_INTERVAL_MS = 30_000; +const RATE_LIMIT_BACKOFF_MS = 5 * 60_000; + +interface WorkStatusBridgeProps { + active: boolean; +} + +export function WorkStatusBridge({ active }: WorkStatusBridgeProps) { + const [documentVisible, setDocumentVisible] = useState( + () => document.visibilityState !== "hidden", + ); + const publishSnapshot = useWorkStatusStore((state) => state.publishSnapshot); + const setManualRefreshOutcome = useWorkStatusStore( + (state) => state.setManualRefreshOutcome, + ); + const setManualRefreshPending = useWorkStatusStore( + (state) => state.setManualRefreshPending, + ); + const eligibleRef = useRef(false); + const generationRef = useRef(0); + const refreshInFlightRef = useRef(false); + const refreshQueuedRef = useRef(false); + const manualRefreshQueuedRef = useRef(false); + const refreshRef = useRef<(options?: { manual?: boolean }) => void>(() => {}); + const automaticRefreshBlockedUntilRef = useRef(0); + + useEffect(() => { + const handleVisibilityChange = () => { + setDocumentVisible(document.visibilityState !== "hidden"); + }; + document.addEventListener("visibilitychange", handleVisibilityChange); + return () => { + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + }, []); + + const eligible = active && documentVisible; + eligibleRef.current = eligible; + + useEffect(() => { + generationRef.current += 1; + const generation = generationRef.current; + let refreshTimer: number | null = null; + let cancelled = false; + + const clearRefreshTimer = () => { + if (refreshTimer !== null) { + window.clearTimeout(refreshTimer); + refreshTimer = null; + } + }; + + if (!eligible) { + refreshQueuedRef.current = false; + manualRefreshQueuedRef.current = false; + setManualRefreshPending(false); + return clearRefreshTimer; + } + + const scheduleRefresh = () => { + clearRefreshTimer(); + if (cancelled || !eligibleRef.current) return; + refreshTimer = window.setTimeout(() => { + refreshTimer = null; + refreshRef.current(); + }, REFRESH_INTERVAL_MS); + }; + + const refresh = async ({ manual = false } = {}) => { + if ( + cancelled || + generationRef.current !== generation || + !eligibleRef.current + ) { + return; + } + if (!manual && Date.now() < automaticRefreshBlockedUntilRef.current) { + scheduleRefresh(); + return; + } + if (refreshInFlightRef.current) { + refreshQueuedRef.current = true; + if (manual) manualRefreshQueuedRef.current = true; + return; + } + + refreshInFlightRef.current = true; + let manualRefreshSucceeded = false; + try { + const snapshot = await buildWorkStatusSnapshot( + useWorkStatusStore.getState().snapshot, + ); + automaticRefreshBlockedUntilRef.current = snapshot.errors.some( + (error) => error.code === "rateLimited", + ) + ? Date.now() + RATE_LIMIT_BACKOFF_MS + : 0; + if ( + !cancelled && + generationRef.current === generation && + eligibleRef.current + ) { + publishSnapshot(snapshot); + manualRefreshSucceeded = snapshot.isFresh; + } + } catch (error) { + console.error("Failed to refresh PR Inbox:", error); + } finally { + refreshInFlightRef.current = false; + if (manual && generationRef.current === generation) { + setManualRefreshOutcome(manualRefreshSucceeded); + setManualRefreshPending(false); + } + if (refreshQueuedRef.current && eligibleRef.current) { + const queuedManualRefresh = manualRefreshQueuedRef.current; + refreshQueuedRef.current = false; + manualRefreshQueuedRef.current = false; + refreshRef.current({ manual: queuedManualRefresh }); + } else if (generationRef.current === generation) { + scheduleRefresh(); + } + } + }; + + refreshRef.current = (options) => { + void refresh(options); + }; + const handleRefreshRequest = () => { + if (!eligibleRef.current) return; + setManualRefreshOutcome(null); + refreshRef.current({ manual: true }); + }; + + refreshRef.current(); + window.addEventListener(WORK_STATUS_REFRESH_EVENT, handleRefreshRequest); + return () => { + cancelled = true; + clearRefreshTimer(); + window.removeEventListener( + WORK_STATUS_REFRESH_EVENT, + handleRefreshRequest, + ); + }; + }, [ + eligible, + publishSnapshot, + setManualRefreshOutcome, + setManualRefreshPending, + ]); + + return null; +} diff --git a/src/features/work-status/githubPullRequests.test.ts b/src/features/work-status/githubPullRequests.test.ts new file mode 100644 index 000000000..7af3ada3d --- /dev/null +++ b/src/features/work-status/githubPullRequests.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, it } from "vitest"; + +import { classifyPullRequest } from "./githubPullRequests"; + +describe("classifyPullRequest", () => { + it.each([ + { + name: "draft", + input: { + isDraft: true, + reviewDecision: null, + checks: null, + mergeable: "UNKNOWN", + mergeState: "UNKNOWN", + }, + expected: "draft", + }, + { + name: "changes requested", + input: { + isDraft: false, + reviewDecision: "CHANGES_REQUESTED", + checks: "SUCCESS", + mergeable: "MERGEABLE", + mergeState: "CLEAN", + }, + expected: "changesRequested", + }, + { + name: "failing checks", + input: { + isDraft: false, + reviewDecision: "APPROVED", + checks: "FAILURE", + mergeable: "MERGEABLE", + mergeState: "CLEAN", + }, + expected: "checksFailing", + }, + { + name: "awaiting approval", + input: { + isDraft: false, + reviewDecision: "REVIEW_REQUIRED", + checks: "SUCCESS", + mergeable: "MERGEABLE", + mergeState: "BLOCKED", + }, + expected: "awaitingApproval", + }, + { + name: "no required review and passing checks", + input: { + isDraft: false, + reviewDecision: null, + checks: "SUCCESS", + mergeable: "MERGEABLE", + mergeState: "CLEAN", + }, + expected: "readyToMerge", + }, + { + name: "merge conflict", + input: { + isDraft: false, + reviewDecision: "APPROVED", + checks: "SUCCESS", + mergeable: "CONFLICTING", + mergeState: "DIRTY", + }, + expected: "mergeBlocked", + }, + { + name: "pending checks with blocked merge state", + input: { + isDraft: false, + reviewDecision: "APPROVED", + checks: "PENDING", + mergeable: "MERGEABLE", + mergeState: "BLOCKED", + }, + expected: "checksPending", + }, + { + name: "conflict with pending checks", + input: { + isDraft: false, + reviewDecision: "APPROVED", + checks: "PENDING", + mergeable: "CONFLICTING", + mergeState: "DIRTY", + }, + expected: "mergeBlocked", + }, + { + name: "approved with pending checks", + input: { + isDraft: false, + reviewDecision: "APPROVED", + checks: "PENDING", + mergeable: "MERGEABLE", + mergeState: "CLEAN", + }, + expected: "checksPending", + }, + { + name: "approved with no checks", + input: { + isDraft: false, + reviewDecision: "APPROVED", + checks: null, + mergeable: "MERGEABLE", + mergeState: "CLEAN", + }, + expected: "readyToMerge", + }, + { + name: "no required review with no checks", + input: { + isDraft: false, + reviewDecision: null, + checks: null, + mergeable: "MERGEABLE", + mergeState: "CLEAN", + }, + expected: "readyToMerge", + }, + { + name: "approved with passing checks but an out-of-date branch", + input: { + isDraft: false, + reviewDecision: "APPROVED", + checks: "SUCCESS", + mergeable: "MERGEABLE", + mergeState: "BEHIND", + }, + expected: "mergeBlocked", + }, + { + name: "passing checks with pre-receive hooks", + input: { + isDraft: false, + reviewDecision: "APPROVED", + checks: "SUCCESS", + mergeable: "MERGEABLE", + mergeState: "HAS_HOOKS", + }, + expected: "readyToMerge", + }, + { + name: "ready to merge", + input: { + isDraft: false, + reviewDecision: "APPROVED", + checks: "SUCCESS", + mergeable: "MERGEABLE", + mergeState: "CLEAN", + }, + expected: "readyToMerge", + }, + ])("classifies $name", ({ input, expected }) => { + expect(classifyPullRequest(input)).toBe(expected); + }); +}); diff --git a/src/features/work-status/githubPullRequests.ts b/src/features/work-status/githubPullRequests.ts new file mode 100644 index 000000000..a248d9b43 --- /dev/null +++ b/src/features/work-status/githubPullRequests.ts @@ -0,0 +1,209 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { + WorkStatusError, + WorkStatusErrorCode, + WorkStatusItem, + WorkStatusState, +} from "./types"; + +interface GitHubPullRequestResponse { + data: { + search: { + nodes: GitHubPullRequest[]; + }; + }; + isTruncated: boolean; +} + +interface GitHubPullRequest { + id: string; + number: number; + title: string; + url: string; + isDraft: boolean; + updatedAt: string; + mergeable: string; + mergeStateStatus: string; + reviewDecision: string | null; + headRefName: string; + repository: { + nameWithOwner: string; + }; + headRepository: { + nameWithOwner: string; + } | null; + commits: { + nodes: Array<{ + commit: { + statusCheckRollup: { + state: string; + } | null; + }; + }>; + }; +} + +export interface GitHubPullRequestResult { + items: WorkStatusItem[]; + isTruncated: boolean; + error?: WorkStatusError; +} + +export async function fetchGitHubPullRequests(): Promise { + try { + const raw = await invoke("list_pr_tracker_pull_requests"); + const response = JSON.parse(raw) as GitHubPullRequestResponse; + if (response.data.search.nodes.length === 0) { + return { items: [], isTruncated: response.isTruncated }; + } + const projectIdsByPullRequest = await invoke>( + "resolve_pr_tracker_projects", + { + pullRequests: response.data.search.nodes.map((pr) => ({ + id: pr.id, + url: pr.url, + repository: pr.repository.nameWithOwner, + headRepository: pr.headRepository?.nameWithOwner ?? null, + headRefName: pr.headRefName, + })), + }, + ).catch((error) => { + console.warn( + "Failed to associate pull requests with Berd projects:", + error, + ); + return {} as Record; + }); + return { + items: response.data.search.nodes.map((pr) => + mapPullRequest(pr, projectIdsByPullRequest[pr.id] ?? null), + ), + isTruncated: response.isTruncated, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + items: [], + isTruncated: false, + error: { + id: githubErrorId(message), + source: "github", + code: githubErrorCode(message), + message, + }, + }; + } +} + +function githubErrorId(message: string): string { + return githubErrorCode(message) === "authentication" + ? "github-auth" + : "github"; +} + +function githubErrorCode(message: string): WorkStatusErrorCode { + const normalized = message.toLowerCase(); + if ( + normalized.includes("authenticate") || + normalized.includes("not logged") || + normalized.includes("gh auth login") || + normalized.includes("authentication") || + normalized.includes("bad credentials") || + normalized.includes("expired token") || + normalized.includes("http 401") || + normalized.includes("status 401") + ) { + return "authentication"; + } + if ( + normalized.includes("rate limit") || + normalized.includes("secondary rate") || + normalized.includes("http 429") || + normalized.includes("status 429") + ) { + return "rateLimited"; + } + if (normalized.includes("cli was not found")) return "cliMissing"; + if (normalized.includes("timed out")) return "timeout"; + if (normalized.includes("database") || normalized.includes("sqlite")) { + return "database"; + } + if ( + normalized.includes("network") || + normalized.includes("connect") || + normalized.includes("could not resolve") + ) { + return "network"; + } + return "unknown"; +} + +function mapPullRequest( + pr: GitHubPullRequest, + projectId: string | null, +): WorkStatusItem { + return { + id: `github-${pr.id}`, + title: pr.title, + subtitle: `#${pr.number}`, + groupName: pr.repository.nameWithOwner, + projectId, + source: "github", + status: classifyPullRequest({ + isDraft: pr.isDraft, + reviewDecision: pr.reviewDecision, + checks: pr.commits.nodes.at(0)?.commit.statusCheckRollup?.state, + mergeable: pr.mergeable, + mergeState: pr.mergeStateStatus, + }), + updatedAt: normalizeDate(pr.updatedAt), + destination: { + type: "url", + url: pr.url, + }, + }; +} + +export function classifyPullRequest({ + isDraft, + reviewDecision, + checks, + mergeable, + mergeState, +}: { + isDraft: boolean; + reviewDecision: string | null | undefined; + checks: string | null | undefined; + mergeable: string; + mergeState: string; +}): WorkStatusState { + if (isDraft) return "draft"; + if (reviewDecision === "CHANGES_REQUESTED") return "changesRequested"; + if (checks === "FAILURE" || checks === "ERROR") return "checksFailing"; + if (mergeable === "CONFLICTING" || mergeState === "DIRTY") { + return "mergeBlocked"; + } + if (checks === "PENDING" || checks === "EXPECTED") return "checksPending"; + if ( + reviewDecision === "REVIEW_REQUIRED" || + reviewDecision === "REVIEW_REQUIRED_BY_PROTECTED_BRANCH" + ) { + return "awaitingApproval"; + } + if (mergeState === "BLOCKED" || mergeState === "BEHIND") { + return "mergeBlocked"; + } + if ( + (checks === "SUCCESS" || checks == null) && + mergeable === "MERGEABLE" && + (mergeState === "CLEAN" || mergeState === "HAS_HOOKS") + ) { + return "readyToMerge"; + } + return "checksPending"; +} + +function normalizeDate(value: string): string { + const time = Date.parse(value); + return Number.isFinite(time) ? new Date(time).toISOString() : value; +} diff --git a/src/features/work-status/statusModel.ts b/src/features/work-status/statusModel.ts new file mode 100644 index 000000000..50abd72d9 --- /dev/null +++ b/src/features/work-status/statusModel.ts @@ -0,0 +1,12 @@ +import type { WorkStatusState } from "./types"; + +export const WORK_STATUS_LABEL_KEYS = { + draft: "workStatus.status.draft", + awaitingApproval: "workStatus.status.awaitingApproval", + changesRequested: "workStatus.status.changesRequested", + checksFailing: "workStatus.status.checksFailing", + checksPending: "workStatus.status.checksPending", + readyToMerge: "workStatus.status.readyToMerge", + mergeBlocked: "workStatus.status.mergeBlocked", + error: "workStatus.status.error", +} satisfies Record; diff --git a/src/features/work-status/topBarLabel.test.ts b/src/features/work-status/topBarLabel.test.ts new file mode 100644 index 000000000..28be00d41 --- /dev/null +++ b/src/features/work-status/topBarLabel.test.ts @@ -0,0 +1,30 @@ +import i18next from "i18next"; +import { describe, expect, it } from "vitest"; + +import enCommon from "@/shared/i18n/locales/en/common.json"; +import esCommon from "@/shared/i18n/locales/es/common.json"; + +describe("PR Inbox top-bar label", () => { + it.each([ + ["en", 0, "PR Inbox, 0 open"], + ["en", 1, "PR Inbox, 1 open"], + ["en", 2, "PR Inbox, 2 open"], + ["es", 0, "PR Inbox, 0 abiertas"], + ["es", 1, "PR Inbox, 1 abierta"], + ["es", 2, "PR Inbox, 2 abiertas"], + ])("formats %s count %i", async (locale, count, expected) => { + const instance = i18next.createInstance(); + await instance.init({ + lng: locale, + fallbackLng: false, + resources: { + en: { common: enCommon }, + es: { common: esCommon }, + }, + }); + + expect(instance.t("workStatus.topBarLabel", { count, ns: "common" })).toBe( + expected, + ); + }); +}); diff --git a/src/features/work-status/types.ts b/src/features/work-status/types.ts new file mode 100644 index 000000000..b7a7b49df --- /dev/null +++ b/src/features/work-status/types.ts @@ -0,0 +1,50 @@ +export type WorkStatusState = + | "draft" + | "awaitingApproval" + | "changesRequested" + | "checksFailing" + | "checksPending" + | "readyToMerge" + | "mergeBlocked" + | "error"; + +export type WorkStatusSource = "github"; + +export interface WorkStatusItem { + id: string; + title: string; + subtitle?: string; + groupName: string; + projectId?: string | null; + source: WorkStatusSource; + status: WorkStatusState; + updatedAt: string; + destination: { + type: "url"; + url: string; + }; +} + +export interface WorkStatusSnapshot { + chats: WorkStatusItem[]; + pullRequests: WorkStatusItem[]; + errors: WorkStatusError[]; + isFresh: boolean; + isTruncated: boolean; +} + +export type WorkStatusErrorCode = + | "authentication" + | "cliMissing" + | "timeout" + | "database" + | "network" + | "rateLimited" + | "unknown"; + +export interface WorkStatusError { + id: string; + source: WorkStatusSource; + code: WorkStatusErrorCode; + message: string; +} diff --git a/src/features/work-status/workStatusData.test.ts b/src/features/work-status/workStatusData.test.ts new file mode 100644 index 000000000..7cd1a87e9 --- /dev/null +++ b/src/features/work-status/workStatusData.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { WorkStatusSnapshot } from "./types"; +import { buildWorkStatusSnapshot } from "./workStatusData"; + +const fetchGitHubPullRequestsMock = vi.hoisted(() => vi.fn()); + +vi.mock("./githubPullRequests", () => ({ + fetchGitHubPullRequests: fetchGitHubPullRequestsMock, +})); + +const previous: WorkStatusSnapshot = { + chats: [], + errors: [], + isFresh: true, + isTruncated: false, + pullRequests: [ + { + id: "existing", + title: "Existing PR", + groupName: "squareup/berd", + source: "github", + status: "draft", + updatedAt: "2026-08-07T00:00:00.000Z", + destination: { + type: "url", + url: "https://github.com/squareup/berd/pull/1", + }, + }, + ], +}; + +describe("buildWorkStatusSnapshot", () => { + it("keeps stale PR rows when refresh fails", async () => { + fetchGitHubPullRequestsMock.mockResolvedValue({ + items: [], + isTruncated: false, + error: { + id: "github", + source: "github", + code: "network", + message: "raw network details", + }, + }); + + const result = await buildWorkStatusSnapshot(previous); + + expect(result.pullRequests).toEqual(previous.pullRequests); + expect(result.errors[0]?.code).toBe("network"); + expect(result.isFresh).toBe(false); + }); +}); diff --git a/src/features/work-status/workStatusData.ts b/src/features/work-status/workStatusData.ts new file mode 100644 index 000000000..638e4cd70 --- /dev/null +++ b/src/features/work-status/workStatusData.ts @@ -0,0 +1,24 @@ +import { fetchGitHubPullRequests } from "./githubPullRequests"; +import type { WorkStatusSnapshot } from "./types"; + +export async function buildWorkStatusSnapshot( + previous: WorkStatusSnapshot, +): Promise { + const pullRequests = await fetchGitHubPullRequests(); + if (pullRequests.error && previous.pullRequests.length > 0) { + return { + ...previous, + errors: [pullRequests.error], + isFresh: false, + }; + } + return { + chats: [], + pullRequests: [...pullRequests.items].sort( + (a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt), + ), + errors: pullRequests.error ? [pullRequests.error] : [], + isFresh: !pullRequests.error, + isTruncated: pullRequests.isTruncated, + }; +} diff --git a/src/features/work-status/workStatusNative.ts b/src/features/work-status/workStatusNative.ts new file mode 100644 index 000000000..4ea9cf1ee --- /dev/null +++ b/src/features/work-status/workStatusNative.ts @@ -0,0 +1,11 @@ +import { invoke } from "@tauri-apps/api/core"; + +export const WORK_STATUS_REFRESH_EVENT = "berd:work-status-refresh"; + +export async function openWorkStatusUrl(url: string): Promise { + if (!window.__TAURI_INTERNALS__) { + window.open(url, "_blank", "noopener,noreferrer"); + return; + } + await invoke("open_pr_tracker_url", { url }); +} diff --git a/src/features/work-status/workStatusStore.ts b/src/features/work-status/workStatusStore.ts new file mode 100644 index 000000000..586bf5617 --- /dev/null +++ b/src/features/work-status/workStatusStore.ts @@ -0,0 +1,46 @@ +import { create } from "zustand"; +import type { WorkStatusSnapshot } from "./types"; + +interface WorkStatusState { + snapshot: WorkStatusSnapshot; + pullRequestsRefreshedAt: string | null; + isManualRefreshPending: boolean; + lastManualRefreshSucceeded: boolean | null; + publishSnapshot: (snapshot: WorkStatusSnapshot) => void; + resetSnapshot: () => void; + setManualRefreshOutcome: (succeeded: boolean | null) => void; + setManualRefreshPending: (pending: boolean) => void; +} + +export const EMPTY_WORK_STATUS_SNAPSHOT: WorkStatusSnapshot = { + chats: [], + pullRequests: [], + errors: [], + isFresh: false, + isTruncated: false, +}; + +export const useWorkStatusStore = create((set) => ({ + snapshot: EMPTY_WORK_STATUS_SNAPSHOT, + pullRequestsRefreshedAt: null, + isManualRefreshPending: false, + lastManualRefreshSucceeded: null, + publishSnapshot: (snapshot) => + set((state) => ({ + snapshot, + pullRequestsRefreshedAt: snapshot.isFresh + ? new Date().toISOString() + : state.pullRequestsRefreshedAt, + })), + resetSnapshot: () => + set({ + snapshot: EMPTY_WORK_STATUS_SNAPSHOT, + pullRequestsRefreshedAt: null, + isManualRefreshPending: false, + lastManualRefreshSucceeded: null, + }), + setManualRefreshOutcome: (lastManualRefreshSucceeded) => + set({ lastManualRefreshSucceeded }), + setManualRefreshPending: (isManualRefreshPending) => + set({ isManualRefreshPending }), +})); diff --git a/src/shared/i18n/locales/en/common.json b/src/shared/i18n/locales/en/common.json index 7099f619c..15fef66a5 100644 --- a/src/shared/i18n/locales/en/common.json +++ b/src/shared/i18n/locales/en/common.json @@ -166,5 +166,71 @@ "output": "Output", "reasoning": "Reasoning", "totalCost": "Total cost" + }, + "workStatus": { + "title": "PR Inbox", + "updated": "Updated {{time}}", + "collecting": "Loading PR Inbox…", + "updating": "Updating PR Inbox…", + "updatedNow": "PR Inbox updated.", + "refreshFailed": "PR Inbox could not be updated.", + "refresh": "Refresh PR Inbox", + "refreshing": "Refreshing PR Inbox", + "loading": "Loading PR Inbox", + "resize": "Resize PR Inbox", + "openError": "Couldn’t open the pull request. Try again.", + "topBarLabel_one": "PR Inbox, {{count}} open", + "topBarLabel_other": "PR Inbox, {{count}} open", + "truncated": "Showing the first {{count}} open pull requests.", + "noProject": "No project", + "empty": { + "title": "No open pull requests", + "description": "Pull requests you create will appear here." + }, + "error": { + "title": "Pull requests aren’t available", + "description": "Check your connection and try refreshing.", + "authentication": "Sign in with GitHub CLI, then try again.", + "cliMissing": "Install GitHub CLI to load pull requests.", + "timeout": "GitHub took too long to respond. Try again.", + "database": "Berd couldn’t match pull requests to projects. Try again.", + "network": "Check your connection and try again.", + "rateLimited": "GitHub is rate limiting requests. Automatic refresh will retry in a few minutes.", + "unknown": "Pull requests couldn’t be loaded. Try again." + }, + "githubDisconnected": { + "title": "Connect GitHub to see pull requests", + "description": "Sign in with GitHub CLI, then refresh to load pull requests authored by you." + }, + "status": { + "draft": "Draft", + "awaitingApproval": "Awaiting approval", + "changesRequested": "Changes requested", + "checksFailing": "Checks failing", + "checksPending": "Checks pending", + "readyToMerge": "Ready to merge", + "mergeBlocked": "Merge blocked", + "error": "Error" + }, + "preview": { + "ariaLabel": "Preview PR Inbox states", + "devOnly": "Dev only state testing", + "live": "Live data", + "noPrs": "No pull requests", + "githubDisconnected": "GitHub not connected", + "connectionError": "Connection error", + "rateLimited": "Rate limited", + "staleError": "Stale data with warning", + "truncated": "Truncated results", + "statuses": "PR statuses", + "titles": { + "draft": "Draft pull request", + "awaitingApproval": "Waiting for reviewer approval", + "changesRequested": "Reviewer requested changes", + "checksFailing": "Continuous integration checks are failing", + "readyToMerge": "Approved and ready to merge", + "mergeBlocked": "Merge is blocked by conflicts" + } + } } } diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index d9ed08376..ad97db474 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -216,8 +216,8 @@ "title": "Builderbot" }, "relatedPullRequests": { - "description": "Find pull requests linked in a chat and show their current status in the context panel.", - "title": "Related pull requests" + "description": "Show the PR Inbox and the current status of pull requests linked in a chat.", + "title": "PR Inbox" }, "defaultLabel": "default", "description": "Opt into in-progress Berd features on this device. Experiments can change, break, or disappear.", diff --git a/src/shared/i18n/locales/es/common.json b/src/shared/i18n/locales/es/common.json index 55dca20cd..25c2e5ebd 100644 --- a/src/shared/i18n/locales/es/common.json +++ b/src/shared/i18n/locales/es/common.json @@ -164,5 +164,71 @@ "output": "Salida", "reasoning": "Razonamiento", "totalCost": "Costo total" + }, + "workStatus": { + "title": "PR Inbox", + "updated": "Actualizado {{time}}", + "collecting": "Cargando PR Inbox…", + "updating": "Actualizando PR Inbox…", + "updatedNow": "PR Inbox actualizado.", + "refreshFailed": "No se pudo actualizar PR Inbox.", + "refresh": "Actualizar PR Inbox", + "refreshing": "Actualizando PR Inbox", + "loading": "Cargando PR Inbox", + "resize": "Cambiar el tamaño de PR Inbox", + "openError": "No se pudo abrir la solicitud. Inténtalo de nuevo.", + "topBarLabel_one": "PR Inbox, {{count}} abierta", + "topBarLabel_other": "PR Inbox, {{count}} abiertas", + "truncated": "Mostrando las primeras {{count}} solicitudes abiertas.", + "noProject": "Sin proyecto", + "empty": { + "title": "No hay solicitudes abiertas", + "description": "Las solicitudes que crees aparecerán aquí." + }, + "error": { + "title": "Las solicitudes no están disponibles", + "description": "Comprueba tu conexión e intenta actualizar de nuevo.", + "authentication": "Inicia sesión con GitHub CLI e inténtalo de nuevo.", + "cliMissing": "Instala GitHub CLI para cargar las solicitudes.", + "timeout": "GitHub tardó demasiado en responder. Inténtalo de nuevo.", + "database": "Berd no pudo asociar las solicitudes con proyectos. Inténtalo de nuevo.", + "network": "Comprueba tu conexión e inténtalo de nuevo.", + "rateLimited": "GitHub está limitando las solicitudes. La actualización automática se reintentará en unos minutos.", + "unknown": "No se pudieron cargar las solicitudes. Inténtalo de nuevo." + }, + "githubDisconnected": { + "title": "Conecta GitHub para ver las solicitudes", + "description": "Inicia sesión con GitHub CLI y luego actualiza para cargar las solicitudes creadas por ti." + }, + "status": { + "draft": "Borrador", + "awaitingApproval": "Esperando aprobación", + "changesRequested": "Cambios solicitados", + "checksFailing": "Comprobaciones fallidas", + "checksPending": "Comprobaciones pendientes", + "readyToMerge": "Lista para fusionar", + "mergeBlocked": "Fusión bloqueada", + "error": "Error" + }, + "preview": { + "ariaLabel": "Previsualizar estados de PR Inbox", + "devOnly": "Pruebas de estado solo para desarrollo", + "live": "Datos reales", + "noPrs": "Sin solicitudes", + "githubDisconnected": "GitHub no conectado", + "connectionError": "Error de conexión", + "rateLimited": "Límite de solicitudes", + "staleError": "Datos anteriores con advertencia", + "truncated": "Resultados truncados", + "statuses": "Estados de solicitudes", + "titles": { + "draft": "Solicitud en borrador", + "awaitingApproval": "Esperando la aprobación del revisor", + "changesRequested": "El revisor solicitó cambios", + "checksFailing": "Las comprobaciones de integración continua están fallando", + "readyToMerge": "Aprobada y lista para fusionar", + "mergeBlocked": "La fusión está bloqueada por conflictos" + } + } } } diff --git a/src/shared/i18n/locales/es/settings.json b/src/shared/i18n/locales/es/settings.json index 9b6b72d85..37e5533c5 100644 --- a/src/shared/i18n/locales/es/settings.json +++ b/src/shared/i18n/locales/es/settings.json @@ -216,8 +216,8 @@ "title": "Builderbot" }, "relatedPullRequests": { - "description": "Busca solicitudes de incorporación de cambios enlazadas en un chat y muestra su estado actual en el panel de contexto.", - "title": "Solicitudes de incorporación de cambios relacionadas" + "description": "Muestra PR Inbox y el estado actual de las solicitudes de incorporación enlazadas en un chat.", + "title": "PR Inbox" }, "defaultLabel": "predeterminado", "description": "Activa funciones de Berd en desarrollo en este dispositivo. Los experimentos pueden cambiar, fallar o desaparecer.", diff --git a/src/shared/ui/content-toolbar-icon-button.tsx b/src/shared/ui/content-toolbar-icon-button.tsx new file mode 100644 index 000000000..ffd1ed4e3 --- /dev/null +++ b/src/shared/ui/content-toolbar-icon-button.tsx @@ -0,0 +1,30 @@ +import * as React from "react"; + +import { cn } from "@/shared/lib/cn"; +import { Button, type ButtonProps } from "@/shared/ui/button"; + +/** + * Icon action for compact toolbars inside content surfaces such as popovers. + * Uses top-bar geometry without inheriting app-chrome colors or hover fills. + */ +const CONTENT_TOOLBAR_ICON_RECIPE = + "bg-transparent text-foreground shadow-none transition-[color,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] hover:bg-transparent hover:text-foreground hover:opacity-[var(--app-top-bar-control-hover-opacity)] active:bg-transparent active:text-foreground active:opacity-[var(--app-top-bar-control-hover-opacity)] focus-visible:bg-transparent data-[state=open]:bg-transparent data-[state=open]:text-foreground data-[state=open]:opacity-[var(--app-top-bar-control-hover-opacity)] aria-expanded:bg-transparent aria-expanded:text-foreground aria-expanded:opacity-[var(--app-top-bar-control-hover-opacity)]"; + +export type ContentToolbarIconButtonProps = Omit< + ButtonProps, + "variant" | "flush" +>; + +export const ContentToolbarIconButton = React.forwardRef< + HTMLButtonElement, + ContentToolbarIconButtonProps +>(({ className, size = "icon-top-bar", ...props }, ref) => ( +