From 8e2793216519cbe68591eb7eaea30eb531ed11ec Mon Sep 17 00:00:00 2001 From: kennylauren Date: Thu, 6 Aug 2026 13:34:24 -0700 Subject: [PATCH 1/5] add in-app pull request tracker Co-authored-by: Jon Tirsen Signed-off-by: Jon Tirsen --- docs/work-status-platform-surfaces.md | 55 ++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/pr_tracker.rs | 647 +++++++++++++++++ src-tauri/src/lib.rs | 3 + src-tauri/src/services/log_export.rs | 2 +- src/app/App.tsx | 2 + src/app/ui/TopBar.tsx | 6 + .../ui/MessageTimelineScrollContainer.tsx | 183 +---- .../generated/componentManifest.ts | 49 ++ .../__tests__/ExperimentsSettings.test.tsx | 16 + .../experiments/experimentDefinitions.ts | 8 + .../work-status/PullRequestsPanel.tsx | 651 ++++++++++++++++++ .../work-status/PullRequestsPopover.tsx | 161 +++++ .../work-status/WorkStatusBridge.test.tsx | 196 ++++++ src/features/work-status/WorkStatusBridge.tsx | 98 +++ .../work-status/githubPullRequests.test.ts | 164 +++++ .../work-status/githubPullRequests.ts | 209 ++++++ src/features/work-status/statusModel.ts | 12 + src/features/work-status/topBarLabel.test.ts | 30 + src/features/work-status/types.ts | 50 ++ .../work-status/workStatusData.test.ts | 52 ++ src/features/work-status/workStatusData.ts | 24 + src/features/work-status/workStatusNative.ts | 11 + src/features/work-status/workStatusStore.ts | 46 ++ src/shared/i18n/locales/en/common.json | 66 ++ src/shared/i18n/locales/en/settings.json | 4 + src/shared/i18n/locales/es/common.json | 66 ++ src/shared/i18n/locales/es/settings.json | 4 + src/shared/ui/content-toolbar-icon-button.tsx | 30 + src/shared/ui/scroll-intent-area.tsx | 147 ++++ src/shared/ui/sidebar-tokens.ts | 5 +- 31 files changed, 2831 insertions(+), 167 deletions(-) create mode 100644 docs/work-status-platform-surfaces.md create mode 100644 src-tauri/src/commands/pr_tracker.rs create mode 100644 src/features/work-status/PullRequestsPanel.tsx create mode 100644 src/features/work-status/PullRequestsPopover.tsx create mode 100644 src/features/work-status/WorkStatusBridge.test.tsx create mode 100644 src/features/work-status/WorkStatusBridge.tsx create mode 100644 src/features/work-status/githubPullRequests.test.ts create mode 100644 src/features/work-status/githubPullRequests.ts create mode 100644 src/features/work-status/statusModel.ts create mode 100644 src/features/work-status/topBarLabel.test.ts create mode 100644 src/features/work-status/types.ts create mode 100644 src/features/work-status/workStatusData.test.ts create mode 100644 src/features/work-status/workStatusData.ts create mode 100644 src/features/work-status/workStatusNative.ts create mode 100644 src/features/work-status/workStatusStore.ts create mode 100644 src/shared/ui/content-toolbar-icon-button.tsx create mode 100644 src/shared/ui/scroll-intent-area.tsx diff --git a/docs/work-status-platform-surfaces.md b/docs/work-status-platform-surfaces.md new file mode 100644 index 000000000..5dbfea068 --- /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 tracker | Pull Requests popover | +| macOS | Work Status | menu bar popover | +| Windows | Work Status | system tray flyout | + +## 1. In-app PR tracker + +The in-app 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..a0e0110de --- /dev/null +++ b/src-tauri/src/commands/pr_tracker.rs @@ -0,0 +1,647 @@ +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 session_activity AS ( + SELECT s.id, + s.working_dir, + s.project_id, + COALESCE( + MAX( + CASE + WHEN m.created_timestamp > 10000000000 + THEN m.created_timestamp / 1000 + ELSE m.created_timestamp + END + ), + 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 messages 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) != '' + GROUP BY s.id, s.working_dir, s.project_id, s.created_at, s.updated_at +), 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::log_export::goose_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_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 + ) + "#, + ) + .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_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/log_export.rs b/src-tauri/src/services/log_export.rs index 29ba0892b..39242afaf 100644 --- a/src-tauri/src/services/log_export.rs +++ b/src-tauri/src/services/log_export.rs @@ -81,7 +81,7 @@ pub(crate) fn resolve_log_dirs(app: &tauri::AppHandle) -> Result Result { +pub(crate) fn goose_state_dir() -> Result { if let Ok(root) = std::env::var("GOOSE_PATH_ROOT") { let root = root.trim(); if !root.is_empty() { diff --git a/src/app/App.tsx b/src/app/App.tsx index 291058817..a1aa93144 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -6,6 +6,7 @@ import { SelectedTextContextMenu } from "@/app/ui/SelectedTextContextMenu"; import { StartupLoadingView } from "@/app/ui/StartupLoadingView"; import { useAuthGate } from "@/features/auth/hooks/useAuthGate"; import { GlobalShortcutBridge } from "@/features/global-shortcut/GlobalShortcutBridge"; +import { WorkStatusBridge } from "@/features/work-status/WorkStatusBridge"; import { LoginView } from "@/features/auth/ui/LoginView"; import { getBuildFeatureState } from "@/shared/profile/buildProfile"; import { useZoom } from "@/shared/hooks/useZoom"; @@ -48,6 +49,7 @@ export function App() { content = ( + breadcrumb.id === "chat-session")?.label ?? breadcrumbs.find((breadcrumb) => breadcrumb.id === "skills")?.label ?? @@ -150,6 +155,7 @@ export function TopBar({ ) : null}
+ {workStatusEnabled ? : 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/__tests__/ExperimentsSettings.test.tsx b/src/features/experiments/__tests__/ExperimentsSettings.test.tsx index 37324174f..9c9c4ce9d 100644 --- a/src/features/experiments/__tests__/ExperimentsSettings.test.tsx +++ b/src/features/experiments/__tests__/ExperimentsSettings.test.tsx @@ -13,6 +13,7 @@ import { STARTER_TASKS_EXPERIMENT_ID, TRANSCRIPT_VIRTUAL_RENDERER_EXPERIMENT_ID, VOICE_CONVERSATION_EXPERIMENT_ID, + WORK_STATUS_EXPERIMENT_ID, type ExperimentDefinition, } from "../experimentDefinitions"; import { ExperimentsSettings } from "../ExperimentsSettings"; @@ -129,11 +130,26 @@ describe("ExperimentsSettings", () => { ).toBeInTheDocument(); }); + it("enables Work Status by default and lets users turn it off", async () => { + vi.stubEnv("DEV", false); + const user = userEvent.setup(); + renderWithProviders(); + + const toggle = screen.getByRole("switch", { + name: i18n.t("experiments.workStatus.title", { ns: "settings" }), + }); + expect(toggle).toBeChecked(); + + await user.click(toggle); + expect(toggle).not.toBeChecked(); + }); + it("registers only the currently supported experiments", () => { expect(EXPERIMENT_DEFINITIONS.map(({ id }) => id)).toEqual([ BUILDERBOT_SURFACE_EXPERIMENT_ID, TRANSCRIPT_VIRTUAL_RENDERER_EXPERIMENT_ID, SKILL_DISCOVERY_EXPERIMENT_ID, + WORK_STATUS_EXPERIMENT_ID, STARTER_TASKS_EXPERIMENT_ID, VOICE_CONVERSATION_EXPERIMENT_ID, CHAT_ON_CANVAS_EXPERIMENT_ID, diff --git a/src/features/experiments/experimentDefinitions.ts b/src/features/experiments/experimentDefinitions.ts index bee329150..1e734db86 100644 --- a/src/features/experiments/experimentDefinitions.ts +++ b/src/features/experiments/experimentDefinitions.ts @@ -66,6 +66,8 @@ 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 WORK_STATUS_EXPERIMENT_ID = "work-status"; export const EXPERIMENT_DEFINITIONS = [ { id: BUILDERBOT_SURFACE_EXPERIMENT_ID, @@ -86,6 +88,12 @@ export const EXPERIMENT_DEFINITIONS = [ // sq-agents CLI and can make remote catalog requests. defaultEnabled: false, }, + { + id: WORK_STATUS_EXPERIMENT_ID, + titleKey: "experiments.workStatus.title", + descriptionKey: "experiments.workStatus.description", + defaultEnabled: true, + }, { id: STARTER_TASKS_EXPERIMENT_ID, titleKey: "experiments.starterTasks.title", diff --git a/src/features/work-status/PullRequestsPanel.tsx b/src/features/work-status/PullRequestsPanel.tsx new file mode 100644 index 000000000..094c4181c --- /dev/null +++ b/src/features/work-status/PullRequestsPanel.tsx @@ -0,0 +1,651 @@ +import { memo, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { + AlertCircle, + CheckCircle2, + ChevronDown, + ChevronRight, + CircleDashed, + GitPullRequest, + MonitorCog, + RefreshCw, + TestTube2, + XOctagon, +} from "lucide-react"; + +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) => ( + + ))} +
+
+
+ ); +} + +const PullRequestRow = memo(function PullRequestRow({ + item, +}: { + item: WorkStatusItem; +}) { + const { t } = useTranslation("common"); + const { formatDate } = useLocaleFormatting(); + const StatusIcon = iconForStatus(item.status); + + return ( + + ); +}); + +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 iconForStatus(status: WorkStatusState) { + switch (status) { + case "readyToMerge": + return CheckCircle2; + case "draft": + case "awaitingApproval": + case "checksPending": + return CircleDashed; + case "changesRequested": + return RefreshCw; + case "checksFailing": + case "mergeBlocked": + case "error": + return XOctagon; + default: + return CircleDashed; + } +} + +function statusClass(status: WorkStatusState): string { + switch (status) { + case "readyToMerge": + return "text-success"; + case "awaitingApproval": + case "checksPending": + return "text-warning"; + case "changesRequested": + case "checksFailing": + case "mergeBlocked": + case "error": + return "text-destructive"; + default: + return "text-muted-foreground"; + } +} + +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..8639ae9c5 --- /dev/null +++ b/src/features/work-status/PullRequestsPopover.tsx @@ -0,0 +1,161 @@ +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 { 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..4b26096f1 --- /dev/null +++ b/src/features/work-status/WorkStatusBridge.test.tsx @@ -0,0 +1,196 @@ +import { StrictMode } from "react"; +import { act, render, waitFor } from "@testing-library/react"; +import { 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("@/features/experiments/experimentPreferences", () => ({ + useExperiment: () => ({ enabled: true }), +})); + +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", () => { + beforeEach(() => { + vi.clearAllMocks(); + useWorkStatusStore.setState({ + snapshot: EMPTY_WORK_STATUS_SNAPSHOT, + pullRequestsRefreshedAt: null, + isManualRefreshPending: false, + lastManualRefreshSucceeded: null, + }); + }); + + it("starts a fresh request for the active Strict Mode effect generation", async () => { + const first = deferred(); + const second = deferred(); + buildWorkStatusSnapshotMock + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + render( + + + , + ); + + await waitFor(() => + expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(2), + ); + + await act(async () => first.resolve(snapshot("stale"))); + expect(useWorkStatusStore.getState().snapshot.pullRequests).toEqual([]); + + await act(async () => second.resolve(snapshot("current"))); + expect(useWorkStatusStore.getState().snapshot.pullRequests[0]?.title).toBe( + "current", + ); + }); + + 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..98e5f3fce --- /dev/null +++ b/src/features/work-status/WorkStatusBridge.tsx @@ -0,0 +1,98 @@ +import { useEffect } from "react"; + +import { WORK_STATUS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; +import { useExperiment } from "@/features/experiments/experimentPreferences"; +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; + +export function WorkStatusBridge() { + const enabled = useExperiment(WORK_STATUS_EXPERIMENT_ID)?.enabled === true; + const publishSnapshot = useWorkStatusStore((state) => state.publishSnapshot); + const resetSnapshot = useWorkStatusStore((state) => state.resetSnapshot); + const setManualRefreshOutcome = useWorkStatusStore( + (state) => state.setManualRefreshOutcome, + ); + const setManualRefreshPending = useWorkStatusStore( + (state) => state.setManualRefreshPending, + ); + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + let refreshInFlight = false; + let manualRefreshQueued = false; + let automaticRefreshBlockedUntil = 0; + + async function refresh({ manual = false } = {}) { + if (!manual && Date.now() < automaticRefreshBlockedUntil) return; + if (refreshInFlight) { + if (manual) manualRefreshQueued = true; + return; + } + + refreshInFlight = true; + let servicedManualRefresh = manual; + let manualRefreshSucceeded = false; + try { + do { + manualRefreshQueued = false; + try { + const snapshot = await buildWorkStatusSnapshot( + useWorkStatusStore.getState().snapshot, + ); + if (!cancelled) publishSnapshot(snapshot); + automaticRefreshBlockedUntil = snapshot.errors.some( + (error) => error.code === "rateLimited", + ) + ? Date.now() + RATE_LIMIT_BACKOFF_MS + : 0; + if (servicedManualRefresh) { + manualRefreshSucceeded = snapshot.isFresh; + } + } catch (error) { + manualRefreshSucceeded = false; + console.error("Failed to refresh PR tracker:", error); + } + if (manualRefreshQueued) servicedManualRefresh = true; + } while (!cancelled && manualRefreshQueued); + } finally { + refreshInFlight = false; + manualRefreshQueued = false; + if (!cancelled && servicedManualRefresh) { + setManualRefreshOutcome(manualRefreshSucceeded); + setManualRefreshPending(false); + } + } + } + + const handleRefreshRequest = () => { + setManualRefreshOutcome(null); + void refresh({ manual: true }); + }; + void refresh(); + window.addEventListener(WORK_STATUS_REFRESH_EVENT, handleRefreshRequest); + const id = window.setInterval(refresh, REFRESH_INTERVAL_MS); + return () => { + cancelled = true; + manualRefreshQueued = false; + resetSnapshot(); + window.removeEventListener( + WORK_STATUS_REFRESH_EVENT, + handleRefreshRequest, + ); + window.clearInterval(id); + }; + }, [ + enabled, + publishSnapshot, + resetSnapshot, + 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..ce3bb65d8 --- /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 tracker top-bar label", () => { + it.each([ + ["en", 0, "Pull Requests, 0 open"], + ["en", 1, "Pull Requests, 1 open"], + ["en", 2, "Pull Requests, 2 open"], + ["es", 0, "Solicitudes de incorporación, 0 abiertas"], + ["es", 1, "Solicitud de incorporación, 1 abierta"], + ["es", 2, "Solicitudes de incorporación, 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..ccf1ca1be 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": "Pull Requests", + "updated": "Updated {{time}}", + "collecting": "Collecting pull request status…", + "updating": "Updating pull request status…", + "updatedNow": "Pull requests updated.", + "refreshFailed": "Pull requests could not be updated.", + "refresh": "Refresh pull requests", + "refreshing": "Refreshing pull requests", + "loading": "Loading pull requests", + "resize": "Resize Pull Requests popover", + "openError": "Couldn’t open the pull request. Try again.", + "topBarLabel_one": "Pull Requests, {{count}} open", + "topBarLabel_other": "Pull Requests, {{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 Pull Requests 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..fa5789c76 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -248,6 +248,10 @@ "resetSuccess": "Onboarding starter tasks reset.", "title": "Onboarding starter tasks" }, + "workStatus": { + "description": "Show in-app PR tracker", + "title": "PR tracker" + }, "title": "Experiments", "transcriptVirtualRenderer": { "description": "Routes chats through the virtual transcript renderer bridge for implementation testing.", diff --git a/src/shared/i18n/locales/es/common.json b/src/shared/i18n/locales/es/common.json index 55dca20cd..fb863295c 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": "Solicitudes de incorporación", + "updated": "Actualizado {{time}}", + "collecting": "Recopilando el estado de las solicitudes…", + "updating": "Actualizando el estado de las solicitudes…", + "updatedNow": "Solicitudes actualizadas.", + "refreshFailed": "No se pudieron actualizar las solicitudes.", + "refresh": "Actualizar solicitudes", + "refreshing": "Actualizando solicitudes", + "loading": "Cargando solicitudes", + "resize": "Cambiar el tamaño del panel de solicitudes", + "openError": "No se pudo abrir la solicitud. Inténtalo de nuevo.", + "topBarLabel_one": "Solicitud de incorporación, {{count}} abierta", + "topBarLabel_other": "Solicitudes de incorporación, {{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 solicitudes", + "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..dcc7226b6 100644 --- a/src/shared/i18n/locales/es/settings.json +++ b/src/shared/i18n/locales/es/settings.json @@ -248,6 +248,10 @@ "resetSuccess": "Se restablecieron las tareas iniciales.", "title": "Tareas iniciales de incorporación" }, + "workStatus": { + "description": "Muestra el seguimiento de PR dentro de la aplicación", + "title": "Seguimiento de PR" + }, "title": "Experimentos", "transcriptVirtualRenderer": { "description": "Envía los chats por el puente del renderizador virtual de transcripciones para probar la implementación.", 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) => ( + + /> ); -}); +} function groupItemsByProject( items: WorkStatusItem[], @@ -595,39 +574,22 @@ function formatCount(count: number): string { return count > 999 ? "999+" : String(count); } -function iconForStatus(status: WorkStatusState) { - switch (status) { - case "readyToMerge": - return CheckCircle2; - case "draft": - case "awaitingApproval": - case "checksPending": - return CircleDashed; - case "changesRequested": - return RefreshCw; - case "checksFailing": - case "mergeBlocked": - case "error": - return XOctagon; - default: - return CircleDashed; - } -} - -function statusClass(status: WorkStatusState): string { +function toneForStatus( + status: WorkStatusState, +): PullRequestListItemStatus["tone"] { switch (status) { case "readyToMerge": - return "text-success"; + return "success"; case "awaitingApproval": case "checksPending": - return "text-warning"; + return "warning"; case "changesRequested": case "checksFailing": case "mergeBlocked": case "error": - return "text-destructive"; + return "danger"; default: - return "text-muted-foreground"; + return "muted"; } } From 33cfc2ec4d287e313b50eaebceb6b9a3944dd120 Mon Sep 17 00:00:00 2001 From: Jon Tirsen Date: Mon, 24 Aug 2026 10:18:03 +0200 Subject: [PATCH 4/5] fix: scope PR Inbox refresh to visibility Co-authored-by: Jon Tirsen Signed-off-by: Jon Tirsen --- docs/work-status-platform-surfaces.md | 4 +- src-tauri/src/commands/pr_tracker.rs | 34 ++-- src/app/App.tsx | 2 - src/app/ui/TopBar.tsx | 8 +- .../__tests__/ExperimentsSettings.test.tsx | 16 -- .../experiments/experimentDefinitions.ts | 7 - .../work-status/PullRequestsPopover.tsx | 90 +++++----- .../work-status/WorkStatusBridge.test.tsx | 100 ++++++++--- src/features/work-status/WorkStatusBridge.tsx | 158 ++++++++++++------ src/features/work-status/topBarLabel.test.ts | 14 +- src/shared/i18n/locales/en/common.json | 24 +-- src/shared/i18n/locales/en/settings.json | 8 +- src/shared/i18n/locales/es/common.json | 24 +-- src/shared/i18n/locales/es/settings.json | 8 +- 14 files changed, 297 insertions(+), 200 deletions(-) diff --git a/docs/work-status-platform-surfaces.md b/docs/work-status-platform-surfaces.md index 5dbfea068..1cb41a07c 100644 --- a/docs/work-status-platform-surfaces.md +++ b/docs/work-status-platform-surfaces.md @@ -7,13 +7,13 @@ without coupling the implementations. | Track | User-facing name | Implementation term | | --- | --- | --- | -| Berd top bar | PR tracker | Pull Requests popover | +| 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 in-app popover shows open pull requests only. Berd already exposes chat +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 diff --git a/src-tauri/src/commands/pr_tracker.rs b/src-tauri/src/commands/pr_tracker.rs index e9eb8c722..fd25591fd 100644 --- a/src-tauri/src/commands/pr_tracker.rs +++ b/src-tauri/src/commands/pr_tracker.rs @@ -34,18 +34,28 @@ 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 session_activity AS ( +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( - MAX( - CASE - WHEN m.created_timestamp > 10000000000 - THEN m.created_timestamp / 1000 - ELSE m.created_timestamp - END - ), + m.activity_at, CASE WHEN unixepoch(s.updated_at) >= unixepoch(s.created_at) THEN unixepoch(s.updated_at) @@ -53,14 +63,13 @@ WITH session_activity AS ( END ) AS activity_at FROM sessions s - LEFT JOIN messages m ON m.session_id = s.id + 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) != '' - GROUP BY s.id, s.working_dir, s.project_id, s.created_at, s.updated_at ), ranked_workspaces AS ( SELECT id, working_dir, @@ -232,6 +241,7 @@ async fn resolve_pr_tracker_projects_inner( .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 @@ -579,7 +589,8 @@ mod tests { 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) @@ -624,6 +635,7 @@ mod tests { .unwrap(); let rows = sqlx::query(WORKSPACE_CANDIDATES_QUERY) + .bind(MAX_MESSAGE_CANDIDATES) .bind(MAX_WORKSPACE_CANDIDATES as i64) .fetch_all(&pool) .await diff --git a/src/app/App.tsx b/src/app/App.tsx index a1aa93144..291058817 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -6,7 +6,6 @@ import { SelectedTextContextMenu } from "@/app/ui/SelectedTextContextMenu"; import { StartupLoadingView } from "@/app/ui/StartupLoadingView"; import { useAuthGate } from "@/features/auth/hooks/useAuthGate"; import { GlobalShortcutBridge } from "@/features/global-shortcut/GlobalShortcutBridge"; -import { WorkStatusBridge } from "@/features/work-status/WorkStatusBridge"; import { LoginView } from "@/features/auth/ui/LoginView"; import { getBuildFeatureState } from "@/shared/profile/buildProfile"; import { useZoom } from "@/shared/hooks/useZoom"; @@ -49,7 +48,6 @@ export function App() { content = ( - breadcrumb.id === "chat-session")?.label ?? breadcrumbs.find((breadcrumb) => breadcrumb.id === "skills")?.label ?? @@ -155,7 +155,7 @@ export function TopBar({ ) : null}
- {workStatusEnabled ? : null} + {pullRequestsEnabled ? : null} {viewActions} diff --git a/src/features/experiments/__tests__/ExperimentsSettings.test.tsx b/src/features/experiments/__tests__/ExperimentsSettings.test.tsx index 9c9c4ce9d..37324174f 100644 --- a/src/features/experiments/__tests__/ExperimentsSettings.test.tsx +++ b/src/features/experiments/__tests__/ExperimentsSettings.test.tsx @@ -13,7 +13,6 @@ import { STARTER_TASKS_EXPERIMENT_ID, TRANSCRIPT_VIRTUAL_RENDERER_EXPERIMENT_ID, VOICE_CONVERSATION_EXPERIMENT_ID, - WORK_STATUS_EXPERIMENT_ID, type ExperimentDefinition, } from "../experimentDefinitions"; import { ExperimentsSettings } from "../ExperimentsSettings"; @@ -130,26 +129,11 @@ describe("ExperimentsSettings", () => { ).toBeInTheDocument(); }); - it("enables Work Status by default and lets users turn it off", async () => { - vi.stubEnv("DEV", false); - const user = userEvent.setup(); - renderWithProviders(); - - const toggle = screen.getByRole("switch", { - name: i18n.t("experiments.workStatus.title", { ns: "settings" }), - }); - expect(toggle).toBeChecked(); - - await user.click(toggle); - expect(toggle).not.toBeChecked(); - }); - it("registers only the currently supported experiments", () => { expect(EXPERIMENT_DEFINITIONS.map(({ id }) => id)).toEqual([ BUILDERBOT_SURFACE_EXPERIMENT_ID, TRANSCRIPT_VIRTUAL_RENDERER_EXPERIMENT_ID, SKILL_DISCOVERY_EXPERIMENT_ID, - WORK_STATUS_EXPERIMENT_ID, STARTER_TASKS_EXPERIMENT_ID, VOICE_CONVERSATION_EXPERIMENT_ID, CHAT_ON_CANVAS_EXPERIMENT_ID, diff --git a/src/features/experiments/experimentDefinitions.ts b/src/features/experiments/experimentDefinitions.ts index 1e734db86..49d131b4b 100644 --- a/src/features/experiments/experimentDefinitions.ts +++ b/src/features/experiments/experimentDefinitions.ts @@ -67,7 +67,6 @@ export const SKILL_DISCOVERY_EXPERIMENT_ID = "skill-discovery"; export const RELATED_PULL_REQUESTS_EXPERIMENT_ID = "related-pull-requests"; -export const WORK_STATUS_EXPERIMENT_ID = "work-status"; export const EXPERIMENT_DEFINITIONS = [ { id: BUILDERBOT_SURFACE_EXPERIMENT_ID, @@ -88,12 +87,6 @@ export const EXPERIMENT_DEFINITIONS = [ // sq-agents CLI and can make remote catalog requests. defaultEnabled: false, }, - { - id: WORK_STATUS_EXPERIMENT_ID, - titleKey: "experiments.workStatus.title", - descriptionKey: "experiments.workStatus.description", - defaultEnabled: true, - }, { id: STARTER_TASKS_EXPERIMENT_ID, titleKey: "experiments.starterTasks.title", diff --git a/src/features/work-status/PullRequestsPopover.tsx b/src/features/work-status/PullRequestsPopover.tsx index 8639ae9c5..df7eb8afe 100644 --- a/src/features/work-status/PullRequestsPopover.tsx +++ b/src/features/work-status/PullRequestsPopover.tsx @@ -11,6 +11,7 @@ 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; @@ -114,48 +115,53 @@ export function PullRequestsPopover() { }; return ( - - - + + + + + + {pullRequestCount > 0 ? ( + + ) : null} + + + - - {pullRequestCount > 0 ? ( - - ) : null} - - - - -
-
-
+ +
+ + + ); } diff --git a/src/features/work-status/WorkStatusBridge.test.tsx b/src/features/work-status/WorkStatusBridge.test.tsx index 4b26096f1..093415171 100644 --- a/src/features/work-status/WorkStatusBridge.test.tsx +++ b/src/features/work-status/WorkStatusBridge.test.tsx @@ -1,6 +1,5 @@ -import { StrictMode } from "react"; import { act, render, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { WorkStatusSnapshot } from "./types"; import { WorkStatusBridge } from "./WorkStatusBridge"; @@ -12,10 +11,6 @@ import { const buildWorkStatusSnapshotMock = vi.hoisted(() => vi.fn()); -vi.mock("@/features/experiments/experimentPreferences", () => ({ - useExperiment: () => ({ enabled: true }), -})); - vi.mock("./workStatusData", () => ({ buildWorkStatusSnapshot: buildWorkStatusSnapshotMock, })); @@ -54,6 +49,11 @@ function snapshot(title: string): WorkStatusSnapshot { } describe("WorkStatusBridge", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + beforeEach(() => { vi.clearAllMocks(); useWorkStatusStore.setState({ @@ -64,29 +64,81 @@ describe("WorkStatusBridge", () => { }); }); - it("starts a fresh request for the active Strict Mode effect generation", async () => { - const first = deferred(); - const second = deferred(); - buildWorkStatusSnapshotMock - .mockReturnValueOnce(first.promise) - .mockReturnValueOnce(second.promise); + it("does not request data while the PR Inbox is closed", async () => { + const { rerender } = render(); - 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), ); + }); - await act(async () => first.resolve(snapshot("stale"))); - expect(useWorkStatusStore.getState().snapshot.pullRequests).toEqual([]); + it("refreshes immediately each time the PR Inbox opens", async () => { + buildWorkStatusSnapshotMock.mockResolvedValue(snapshot("current")); + const { rerender } = render(); - await act(async () => second.resolve(snapshot("current"))); - expect(useWorkStatusStore.getState().snapshot.pullRequests[0]?.title).toBe( - "current", + rerender(); + await waitFor(() => + expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(1), + ); + + rerender(); + rerender(); + await waitFor(() => + expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(2), ); }); @@ -97,7 +149,7 @@ describe("WorkStatusBridge", () => { .mockReturnValueOnce(initial.promise) .mockReturnValueOnce(manualRefresh.promise); - render(); + render(); await waitFor(() => expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(1), ); @@ -135,7 +187,7 @@ describe("WorkStatusBridge", () => { .mockReturnValueOnce(initial.promise) .mockReturnValueOnce(manualRefresh.promise); - render(); + render(); await waitFor(() => expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(1), ); @@ -168,7 +220,7 @@ describe("WorkStatusBridge", () => { .mockReturnValueOnce(initial.promise) .mockReturnValueOnce(followUp.promise); - render(); + render(); await waitFor(() => expect(buildWorkStatusSnapshotMock).toHaveBeenCalledTimes(1), ); diff --git a/src/features/work-status/WorkStatusBridge.tsx b/src/features/work-status/WorkStatusBridge.tsx index 98e5f3fce..a4bad9927 100644 --- a/src/features/work-status/WorkStatusBridge.tsx +++ b/src/features/work-status/WorkStatusBridge.tsx @@ -1,7 +1,5 @@ -import { useEffect } from "react"; +import { useEffect, useRef, useState } from "react"; -import { WORK_STATUS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; -import { useExperiment } from "@/features/experiments/experimentPreferences"; import { buildWorkStatusSnapshot } from "./workStatusData"; import { WORK_STATUS_REFRESH_EVENT } from "./workStatusNative"; import { useWorkStatusStore } from "./workStatusStore"; @@ -9,87 +7,149 @@ import { useWorkStatusStore } from "./workStatusStore"; const REFRESH_INTERVAL_MS = 30_000; const RATE_LIMIT_BACKOFF_MS = 5 * 60_000; -export function WorkStatusBridge() { - const enabled = useExperiment(WORK_STATUS_EXPERIMENT_ID)?.enabled === true; +interface WorkStatusBridgeProps { + active: boolean; +} + +export function WorkStatusBridge({ active }: WorkStatusBridgeProps) { + const [documentVisible, setDocumentVisible] = useState( + () => document.visibilityState !== "hidden", + ); const publishSnapshot = useWorkStatusStore((state) => state.publishSnapshot); - const resetSnapshot = useWorkStatusStore((state) => state.resetSnapshot); 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(() => { - if (!enabled) return; + 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; - let refreshInFlight = false; - let manualRefreshQueued = false; - let automaticRefreshBlockedUntil = 0; - async function refresh({ manual = false } = {}) { - if (!manual && Date.now() < automaticRefreshBlockedUntil) return; - if (refreshInFlight) { - if (manual) manualRefreshQueued = true; + 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; } - refreshInFlight = true; - let servicedManualRefresh = manual; + refreshInFlightRef.current = true; let manualRefreshSucceeded = false; try { - do { - manualRefreshQueued = false; - try { - const snapshot = await buildWorkStatusSnapshot( - useWorkStatusStore.getState().snapshot, - ); - if (!cancelled) publishSnapshot(snapshot); - automaticRefreshBlockedUntil = snapshot.errors.some( - (error) => error.code === "rateLimited", - ) - ? Date.now() + RATE_LIMIT_BACKOFF_MS - : 0; - if (servicedManualRefresh) { - manualRefreshSucceeded = snapshot.isFresh; - } - } catch (error) { - manualRefreshSucceeded = false; - console.error("Failed to refresh PR tracker:", error); - } - if (manualRefreshQueued) servicedManualRefresh = true; - } while (!cancelled && manualRefreshQueued); + 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 { - refreshInFlight = false; - manualRefreshQueued = false; - if (!cancelled && servicedManualRefresh) { + 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); - void refresh({ manual: true }); + refreshRef.current({ manual: true }); }; - void refresh(); + + refreshRef.current(); window.addEventListener(WORK_STATUS_REFRESH_EVENT, handleRefreshRequest); - const id = window.setInterval(refresh, REFRESH_INTERVAL_MS); return () => { cancelled = true; - manualRefreshQueued = false; - resetSnapshot(); + clearRefreshTimer(); window.removeEventListener( WORK_STATUS_REFRESH_EVENT, handleRefreshRequest, ); - window.clearInterval(id); }; }, [ - enabled, + eligible, publishSnapshot, - resetSnapshot, setManualRefreshOutcome, setManualRefreshPending, ]); diff --git a/src/features/work-status/topBarLabel.test.ts b/src/features/work-status/topBarLabel.test.ts index ce3bb65d8..28be00d41 100644 --- a/src/features/work-status/topBarLabel.test.ts +++ b/src/features/work-status/topBarLabel.test.ts @@ -4,14 +4,14 @@ 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 tracker top-bar label", () => { +describe("PR Inbox top-bar label", () => { it.each([ - ["en", 0, "Pull Requests, 0 open"], - ["en", 1, "Pull Requests, 1 open"], - ["en", 2, "Pull Requests, 2 open"], - ["es", 0, "Solicitudes de incorporación, 0 abiertas"], - ["es", 1, "Solicitud de incorporación, 1 abierta"], - ["es", 2, "Solicitudes de incorporación, 2 abiertas"], + ["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({ diff --git a/src/shared/i18n/locales/en/common.json b/src/shared/i18n/locales/en/common.json index ccf1ca1be..15fef66a5 100644 --- a/src/shared/i18n/locales/en/common.json +++ b/src/shared/i18n/locales/en/common.json @@ -168,19 +168,19 @@ "totalCost": "Total cost" }, "workStatus": { - "title": "Pull Requests", + "title": "PR Inbox", "updated": "Updated {{time}}", - "collecting": "Collecting pull request status…", - "updating": "Updating pull request status…", - "updatedNow": "Pull requests updated.", - "refreshFailed": "Pull requests could not be updated.", - "refresh": "Refresh pull requests", - "refreshing": "Refreshing pull requests", - "loading": "Loading pull requests", - "resize": "Resize Pull Requests popover", + "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": "Pull Requests, {{count}} open", - "topBarLabel_other": "Pull Requests, {{count}} open", + "topBarLabel_one": "PR Inbox, {{count}} open", + "topBarLabel_other": "PR Inbox, {{count}} open", "truncated": "Showing the first {{count}} open pull requests.", "noProject": "No project", "empty": { @@ -213,7 +213,7 @@ "error": "Error" }, "preview": { - "ariaLabel": "Preview Pull Requests states", + "ariaLabel": "Preview PR Inbox states", "devOnly": "Dev only state testing", "live": "Live data", "noPrs": "No pull requests", diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index fa5789c76..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.", @@ -248,10 +248,6 @@ "resetSuccess": "Onboarding starter tasks reset.", "title": "Onboarding starter tasks" }, - "workStatus": { - "description": "Show in-app PR tracker", - "title": "PR tracker" - }, "title": "Experiments", "transcriptVirtualRenderer": { "description": "Routes chats through the virtual transcript renderer bridge for implementation testing.", diff --git a/src/shared/i18n/locales/es/common.json b/src/shared/i18n/locales/es/common.json index fb863295c..25c2e5ebd 100644 --- a/src/shared/i18n/locales/es/common.json +++ b/src/shared/i18n/locales/es/common.json @@ -166,19 +166,19 @@ "totalCost": "Costo total" }, "workStatus": { - "title": "Solicitudes de incorporación", + "title": "PR Inbox", "updated": "Actualizado {{time}}", - "collecting": "Recopilando el estado de las solicitudes…", - "updating": "Actualizando el estado de las solicitudes…", - "updatedNow": "Solicitudes actualizadas.", - "refreshFailed": "No se pudieron actualizar las solicitudes.", - "refresh": "Actualizar solicitudes", - "refreshing": "Actualizando solicitudes", - "loading": "Cargando solicitudes", - "resize": "Cambiar el tamaño del panel de solicitudes", + "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": "Solicitud de incorporación, {{count}} abierta", - "topBarLabel_other": "Solicitudes de incorporación, {{count}} abiertas", + "topBarLabel_one": "PR Inbox, {{count}} abierta", + "topBarLabel_other": "PR Inbox, {{count}} abiertas", "truncated": "Mostrando las primeras {{count}} solicitudes abiertas.", "noProject": "Sin proyecto", "empty": { @@ -211,7 +211,7 @@ "error": "Error" }, "preview": { - "ariaLabel": "Previsualizar estados de solicitudes", + "ariaLabel": "Previsualizar estados de PR Inbox", "devOnly": "Pruebas de estado solo para desarrollo", "live": "Datos reales", "noPrs": "Sin solicitudes", diff --git a/src/shared/i18n/locales/es/settings.json b/src/shared/i18n/locales/es/settings.json index dcc7226b6..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.", @@ -248,10 +248,6 @@ "resetSuccess": "Se restablecieron las tareas iniciales.", "title": "Tareas iniciales de incorporación" }, - "workStatus": { - "description": "Muestra el seguimiento de PR dentro de la aplicación", - "title": "Seguimiento de PR" - }, "title": "Experimentos", "transcriptVirtualRenderer": { "description": "Envía los chats por el puente del renderizador virtual de transcripciones para probar la implementación.", From 529b0c25abb62662334ac31cccc3105638b0d454 Mon Sep 17 00:00:00 2001 From: Jon Tirsen Date: Mon, 24 Aug 2026 12:31:55 +0200 Subject: [PATCH 5/5] Match PR Inbox rows to sidebar styling Give the shared pull request row the compact muted card treatment used in the sidebar and align tracker cards beneath their project groups. Co-authored-by: Jon Tirsen Signed-off-by: Jon Tirsen --- src/features/pull-requests/ui/PullRequestListItem.test.tsx | 6 ++++++ src/features/pull-requests/ui/PullRequestListItem.tsx | 2 +- src/features/work-status/PullRequestsPanel.tsx | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) 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 index a3a9f96db..7da13239e 100644 --- a/src/features/work-status/PullRequestsPanel.tsx +++ b/src/features/work-status/PullRequestsPanel.tsx @@ -522,7 +522,7 @@ function PullRequestRow({ item }: { item: WorkStatusItem }) { statuses={statuses} timestamp={formatPullRequestTimestamp(item.updatedAt, formatDate)} ariaLabel={`${item.title} · ${item.groupName} ${item.subtitle ?? ""}`.trim()} - className="pl-[38px]" + className="ml-[38px] w-[calc(100%-38px)]" onOpen={() => { openWorkStatusUrl(item.destination.url).catch((error) => { console.error("Failed to open pull request:", error);