From d65d5c9a195d95bbff89d10952a6fc0403134782 Mon Sep 17 00:00:00 2001 From: Jon Tirsen Date: Fri, 31 Jul 2026 11:46:37 +0200 Subject: [PATCH 01/13] Show related pull requests in session context Co-authored-by: Jon Tirsen Signed-off-by: Jon Tirsen --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/pull_requests.rs | 272 ++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/features/chat/lib/pullRequests.test.ts | 89 ++++++ src/features/chat/lib/pullRequests.ts | 66 +++++ src/features/chat/ui/ContextPanel.tsx | 18 +- .../ui/widgets/PullRequestsWidget.test.tsx | 68 +++++ .../chat/ui/widgets/PullRequestsWidget.tsx | 176 ++++++++++++ src/shared/api/pullRequests.test.ts | 23 ++ src/shared/api/pullRequests.ts | 24 ++ src/shared/i18n/locales/en/chat.json | 16 ++ src/shared/i18n/locales/es/chat.json | 16 ++ 12 files changed, 769 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/commands/pull_requests.rs create mode 100644 src/features/chat/lib/pullRequests.test.ts create mode 100644 src/features/chat/lib/pullRequests.ts create mode 100644 src/features/chat/ui/widgets/PullRequestsWidget.test.tsx create mode 100644 src/features/chat/ui/widgets/PullRequestsWidget.tsx create mode 100644 src/shared/api/pullRequests.test.ts create mode 100644 src/shared/api/pullRequests.ts diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a806cb4f4..1545fab10 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -40,6 +40,7 @@ pub mod path_resolver; mod pocket_playback_speed_dsp; pub mod pocket_voice; pub mod project_icons; +pub mod pull_requests; pub mod renderer; pub mod runtime_config; pub mod security_threshold; diff --git a/src-tauri/src/commands/pull_requests.rs b/src-tauri/src/commands/pull_requests.rs new file mode 100644 index 000000000..351501483 --- /dev/null +++ b/src-tauri/src/commands/pull_requests.rs @@ -0,0 +1,272 @@ +use futures_util::{stream, StreamExt}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tokio::process::Command; +use tokio::time::timeout; +use url::Url; + +use crate::services::dir_env; + +const MAX_PULL_REQUESTS: usize = 12; +const GH_COMMAND_TIMEOUT: Duration = Duration::from_secs(20); +const ENV_CAPTURE_TIMEOUT: Duration = Duration::from_secs(15); +const GH_CONCURRENCY: usize = 4; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct PullRequestRef { + url: String, + repo_slug: String, + number: u64, +} + +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PullRequestSummary { + url: String, + repo_slug: String, + number: u64, + title: Option, + state: Option, + is_draft: Option, + checks_status: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GhPullRequest { + title: Option, + state: Option, + is_draft: Option, + status_check_rollup: Option>, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct GhStatusCheck { + state: Option, + status: Option, + conclusion: Option, +} + +fn parse_github_pull_request_url(value: &str) -> Option { + let parsed = Url::parse(value).ok()?; + if parsed.scheme() != "https" || parsed.host_str()? != "github.com" { + return None; + } + + let segments = parsed.path_segments()?.collect::>(); + if segments.len() < 4 || segments[2] != "pull" { + return None; + } + let owner = segments[0]; + let repo = segments[1]; + let number = segments[3].parse::().ok()?; + if owner.is_empty() || repo.is_empty() || number == 0 { + return None; + } + + let repo_slug = format!("{owner}/{repo}"); + Some(PullRequestRef { + url: format!("https://github.com/{repo_slug}/pull/{number}"), + repo_slug, + number, + }) +} + +fn classify_check(check: &GhStatusCheck) -> &'static str { + let conclusion = check.conclusion.as_deref().unwrap_or("").to_uppercase(); + let status = check.status.as_deref().unwrap_or("").to_uppercase(); + let state = check.state.as_deref().unwrap_or("").to_uppercase(); + + if matches!(conclusion.as_str(), "SUCCESS" | "NEUTRAL" | "SKIPPED") { + "SUCCESS" + } else if matches!( + conclusion.as_str(), + "FAILURE" | "CANCELLED" | "TIMED_OUT" | "ACTION_REQUIRED" + ) || matches!(state.as_str(), "FAILURE" | "ERROR") + { + "FAILURE" + } else if status == "COMPLETED" || state == "SUCCESS" { + "SUCCESS" + } else { + "PENDING" + } +} + +fn summarize_checks(checks: Option<&[GhStatusCheck]>) -> Option { + let checks = checks?; + if checks.is_empty() { + return None; + } + + let classifications = checks.iter().map(classify_check).collect::>(); + if classifications.contains(&"FAILURE") { + Some("FAILURE".to_string()) + } else if classifications.iter().all(|state| *state == "SUCCESS") { + Some("SUCCESS".to_string()) + } else { + Some("PENDING".to_string()) + } +} + +fn fallback_summary(reference: &PullRequestRef) -> PullRequestSummary { + PullRequestSummary { + url: reference.url.clone(), + repo_slug: reference.repo_slug.clone(), + number: reference.number, + title: None, + state: None, + is_draft: None, + checks_status: None, + } +} + +async fn fetch_summary( + reference: PullRequestRef, + cwd: &Path, + env: Option<&HashMap>, +) -> PullRequestSummary { + let mut command = Command::new("gh"); + command + .args([ + "pr", + "view", + reference.url.as_str(), + "--json", + "title,state,isDraft,statusCheckRollup", + ]) + .current_dir(cwd) + .env("GH_PROMPT_DISABLED", "1") + .kill_on_drop(true); + if let Some(env) = env { + command.env_clear().envs(env).env("GH_PROMPT_DISABLED", "1"); + } + + let output = match timeout(GH_COMMAND_TIMEOUT, command.output()).await { + Ok(Ok(output)) if output.status.success() => output, + Ok(Ok(output)) => { + log::debug!( + "gh pr view failed for {}: {}", + reference.url, + String::from_utf8_lossy(&output.stderr).trim() + ); + return fallback_summary(&reference); + } + Ok(Err(error)) => { + log::debug!("Could not run gh for {}: {error}", reference.url); + return fallback_summary(&reference); + } + Err(_) => { + log::debug!("gh pr view timed out for {}", reference.url); + return fallback_summary(&reference); + } + }; + + let response = match serde_json::from_slice::(&output.stdout) { + Ok(response) => response, + Err(error) => { + log::debug!("Could not parse gh response for {}: {error}", reference.url); + return fallback_summary(&reference); + } + }; + + PullRequestSummary { + url: reference.url, + repo_slug: reference.repo_slug, + number: reference.number, + title: response.title.filter(|title| !title.trim().is_empty()), + state: response.state, + is_draft: response.is_draft, + checks_status: summarize_checks(response.status_check_rollup.as_deref()), + } +} + +#[tauri::command] +pub async fn get_pull_request_summaries( + urls: Vec, + path: Option, +) -> Result, String> { + let mut seen = HashSet::new(); + let references = urls + .iter() + .filter_map(|url| parse_github_pull_request_url(url)) + .filter(|reference| seen.insert(reference.url.to_lowercase())) + .take(MAX_PULL_REQUESTS) + .collect::>(); + if references.is_empty() { + return Ok(Vec::new()); + } + + let cwd = path + .map(PathBuf::from) + .filter(|path| path.is_dir()) + .or_else(dirs::home_dir) + .ok_or_else(|| "Could not resolve a directory for GitHub CLI".to_string())?; + let env = dir_env::capture_dir_env(&cwd, ENV_CAPTURE_TIMEOUT).await; + + Ok(stream::iter(references.into_iter().map(|reference| { + let cwd = cwd.clone(); + let env = env.clone(); + async move { fetch_summary(reference, &cwd, env.as_ref()).await } + })) + .buffered(GH_CONCURRENCY) + .collect() + .await) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn check(conclusion: Option<&str>, status: Option<&str>, state: Option<&str>) -> GhStatusCheck { + GhStatusCheck { + conclusion: conclusion.map(str::to_string), + status: status.map(str::to_string), + state: state.map(str::to_string), + } + } + + #[test] + fn parses_and_canonicalizes_github_pull_request_urls() { + assert_eq!( + parse_github_pull_request_url("https://github.com/squareup/berd/pull/42/files"), + Some(PullRequestRef { + url: "https://github.com/squareup/berd/pull/42".to_string(), + repo_slug: "squareup/berd".to_string(), + number: 42, + }) + ); + assert!(parse_github_pull_request_url("http://github.com/a/b/pull/1").is_none()); + assert!(parse_github_pull_request_url("https://example.com/a/b/pull/1").is_none()); + } + + #[test] + fn summarizes_check_rollups() { + let passing = vec![ + check(Some("SUCCESS"), Some("COMPLETED"), None), + check(Some("NEUTRAL"), Some("COMPLETED"), None), + ]; + assert_eq!( + summarize_checks(Some(&passing)), + Some("SUCCESS".to_string()) + ); + + let pending = vec![ + check(Some("SUCCESS"), Some("COMPLETED"), None), + check(None, Some("IN_PROGRESS"), None), + ]; + assert_eq!( + summarize_checks(Some(&pending)), + Some("PENDING".to_string()) + ); + + let failing = vec![check(Some("FAILURE"), Some("COMPLETED"), None)]; + assert_eq!( + summarize_checks(Some(&failing)), + Some("FAILURE".to_string()) + ); + assert_eq!(summarize_checks(Some(&[])), None); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bcb5ffee2..5671189e7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -545,6 +545,7 @@ pub fn run() { commands::git::git_delete_branch, commands::git::git_create_worktree, commands::git::git_remove_worktree, + commands::pull_requests::get_pull_request_summaries, commands::home_widget_media::import_home_widget_photo, commands::installation::get_installation_cohort, commands::layout::get_layout, diff --git a/src/features/chat/lib/pullRequests.test.ts b/src/features/chat/lib/pullRequests.test.ts new file mode 100644 index 000000000..5cd0c6a2f --- /dev/null +++ b/src/features/chat/lib/pullRequests.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import type { Message } from "@/shared/types/messages"; +import { findRelatedPullRequests } from "./pullRequests"; + +function message(role: Message["role"], content: Message["content"]): Message { + return { id: crypto.randomUUID(), role, created: 1, content }; +} + +describe("findRelatedPullRequests", () => { + it("finds GitHub links in chat text and normalizes Graphite links", () => { + const messages = [ + message("user", [ + { + type: "text", + text: "Please review https://github.com/squareup/berd/pull/941", + }, + ]), + message("assistant", [ + { + type: "text", + text: "Stack: https://app.graphite.com/github/pr/squareup/nexus/251", + }, + ]), + ]; + + expect(findRelatedPullRequests(messages)).toEqual([ + { + url: "https://github.com/squareup/berd/pull/941", + repoSlug: "squareup/berd", + number: 941, + }, + { + url: "https://github.com/squareup/nexus/pull/251", + repoSlug: "squareup/nexus", + number: 251, + }, + ]); + }); + + it("finds links in tool requests and results", () => { + const messages = [ + message("assistant", [ + { + type: "toolRequest", + id: "call-1", + name: "shell", + arguments: { + command: "gh pr view https://github.com/squareup/berd/pull/900", + }, + status: "completed", + }, + { + type: "toolResponse", + id: "call-1", + name: "shell", + result: "Created https://github.com/squareup/berd/pull/901", + isError: false, + }, + ]), + ]; + + expect(findRelatedPullRequests(messages).map((pr) => pr.number)).toEqual([ + 900, 901, + ]); + }); + + it("deduplicates equivalent links and observes the limit", () => { + const messages = [ + message("assistant", [ + { + type: "text", + text: [ + "https://app.graphite.dev/github/pr/SquareUp/Berd/42", + "https://github.com/squareup/berd/pull/42", + "https://github.com/squareup/berd/pull/43", + ].join(" "), + }, + ]), + ]; + + expect(findRelatedPullRequests(messages, 1)).toEqual([ + { + url: "https://github.com/SquareUp/Berd/pull/42", + repoSlug: "SquareUp/Berd", + number: 42, + }, + ]); + }); +}); diff --git a/src/features/chat/lib/pullRequests.ts b/src/features/chat/lib/pullRequests.ts new file mode 100644 index 000000000..663c49e33 --- /dev/null +++ b/src/features/chat/lib/pullRequests.ts @@ -0,0 +1,66 @@ +import type { Message } from "@/shared/types/messages"; + +export const MAX_RELATED_PULL_REQUESTS = 12; + +export interface DetectedPullRequest { + url: string; + repoSlug: string; + number: number; +} + +const PULL_REQUEST_URL_PATTERN = + /https?:\/\/(?:github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)|app\.graphite\.(?:com|dev)\/github\/pr\/([\w.-]+)\/([\w.-]+)\/(\d+))/gi; + +function searchableMessageContent(message: Message): string[] { + const searchable: string[] = []; + + for (const content of message.content) { + if (content.type === "text") { + searchable.push(content.text); + } else if (content.type === "toolResponse") { + searchable.push(content.result); + } else if (content.type === "toolRequest") { + searchable.push(JSON.stringify(content.arguments)); + } + } + + return searchable; +} + +export function findRelatedPullRequests( + messages: Message[], + limit = MAX_RELATED_PULL_REQUESTS, +): DetectedPullRequest[] { + if (limit <= 0) return []; + + const results: DetectedPullRequest[] = []; + const seen = new Set(); + + for (const message of messages) { + for (const text of searchableMessageContent(message)) { + for (const match of text.matchAll(PULL_REQUEST_URL_PATTERN)) { + const owner = match[1] ?? match[4]; + const repo = match[2] ?? match[5]; + const numberText = match[3] ?? match[6]; + const number = Number(numberText); + if (!owner || !repo || !Number.isSafeInteger(number) || number <= 0) { + continue; + } + + const repoSlug = `${owner}/${repo}`; + const key = `${repoSlug.toLowerCase()}#${number}`; + if (seen.has(key)) continue; + + seen.add(key); + results.push({ + url: `https://github.com/${repoSlug}/pull/${number}`, + repoSlug, + number, + }); + if (results.length >= limit) return results; + } + } + } + + return results; +} diff --git a/src/features/chat/ui/ContextPanel.tsx b/src/features/chat/ui/ContextPanel.tsx index ae5e1ab89..920e25bf5 100644 --- a/src/features/chat/ui/ContextPanel.tsx +++ b/src/features/chat/ui/ContextPanel.tsx @@ -68,6 +68,7 @@ import { useWorkspaceRepository } from "@/features/workspaces/workspaceRepositor import { useChangeSessionFolder } from "@/features/chat/hooks/useChangeSessionFolder"; import { supersedePendingSessionWorkspaceActivation } from "@/features/chat/lib/sessionWorkspaceActivation"; import { useChatStore } from "../stores/chatStore"; +import { SessionPullRequestsWidget } from "./widgets/PullRequestsWidget"; import type { CreatedWorkspaceWorktreeContext } from "./widgets/WorkspaceCreateDialog"; import type { WorkspaceRemovalPlan } from "./widgets/WorkspaceRowActionsMenu"; @@ -96,7 +97,11 @@ interface PendingCreatedWorktree { } type ContextPanelTab = "details" | "changes" | "files"; -type ContextPanelSection = "workspace" | "changes" | "artifacts"; +type ContextPanelSection = + | "workspace" + | "pullRequests" + | "changes" + | "artifacts"; const TAB_CONTENT_CLASS = "scrollbar-none w-full min-h-0 flex-1 overflow-y-auto px-4 pb-4 pt-4"; type ContextPanelSectionVisibility = Record; @@ -104,6 +109,7 @@ type ContextPanelSectionVisibility = Record; const SECTION_VISIBILITY_STORAGE_KEY = "goose:context-panel:section-visibility"; const DEFAULT_SECTION_VISIBILITY: ContextPanelSectionVisibility = { workspace: true, + pullRequests: true, changes: true, artifacts: true, }; @@ -119,6 +125,10 @@ function validateSectionVisibility( typeof parsed.workspace === "boolean" ? parsed.workspace : defaults.workspace, + pullRequests: + typeof parsed.pullRequests === "boolean" + ? parsed.pullRequests + : defaults.pullRequests, changes: typeof parsed.changes === "boolean" ? parsed.changes : defaults.changes, artifacts: @@ -1126,6 +1136,12 @@ export function ContextPanel({ onToggleTerminal={onToggleTerminal} /> )} + toggleSection("pullRequests")} + /> {shouldShowArtifacts && ( ({ + getPullRequestSummaries: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-opener", () => ({ + openUrl: vi.fn(), +})); + +describe("PullRequestsWidget", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(openUrl).mockResolvedValue(undefined); + }); + + it("renders live PR metadata and opens GitHub", async () => { + vi.mocked(getPullRequestSummaries).mockResolvedValue([ + { + url: "https://github.com/squareup/berd/pull/42", + repoSlug: "squareup/berd", + number: 42, + title: "Show related pull requests", + state: "OPEN", + isDraft: false, + checksStatus: "SUCCESS", + }, + ]); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + render( + + {}} + /> + , + ); + + expect(await screen.findByText("Show related pull requests")).toBeVisible(); + expect(screen.getByText("Open")).toBeVisible(); + expect(screen.getByText("Checks passed")).toBeVisible(); + + fireEvent.click( + screen.getByRole("button", { + name: "Open squareup/berd pull request #42 on GitHub", + }), + ); + expect(openUrl).toHaveBeenCalledWith( + "https://github.com/squareup/berd/pull/42", + ); + }); +}); diff --git a/src/features/chat/ui/widgets/PullRequestsWidget.tsx b/src/features/chat/ui/widgets/PullRequestsWidget.tsx new file mode 100644 index 000000000..8d57e09be --- /dev/null +++ b/src/features/chat/ui/widgets/PullRequestsWidget.tsx @@ -0,0 +1,176 @@ +import { useDeferredValue, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + IconExternalLink, + IconGitPullRequest, + IconLoader2, +} from "@tabler/icons-react"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { useTranslation } from "react-i18next"; +import { + findRelatedPullRequests, + type DetectedPullRequest, +} from "../../lib/pullRequests"; +import { useChatStore } from "../../stores/chatStore"; +import type { Message } from "@/shared/types/messages"; +import { + getPullRequestSummaries, + type PullRequestChecksStatus, + type PullRequestState, +} from "@/shared/api/pullRequests"; +import { cn } from "@/shared/lib/cn"; +import { Widget } from "./Widget"; + +interface PullRequestsWidgetProps { + pullRequests: DetectedPullRequest[]; + workspacePath?: string | null; + isOpen: boolean; + onToggleOpen: () => void; +} + +interface SessionPullRequestsWidgetProps + extends Omit { + sessionId: string; +} + +const EMPTY_MESSAGES: Message[] = []; + +const STATE_DOT_CLASS: Record = { + OPEN: "bg-success", + MERGED: "bg-primary", + CLOSED: "bg-destructive", +}; + +const CHECKS_DOT_CLASS: Record = { + SUCCESS: "bg-success", + PENDING: "bg-warning", + FAILURE: "bg-destructive", +}; + +export function SessionPullRequestsWidget({ + sessionId, + ...props +}: SessionPullRequestsWidgetProps) { + const messages = useChatStore((state) => state.messagesBySession[sessionId]); + const deferredMessages = useDeferredValue(messages ?? EMPTY_MESSAGES); + const pullRequests = useMemo( + () => findRelatedPullRequests(deferredMessages), + [deferredMessages], + ); + + return ; +} + +export function PullRequestsWidget({ + pullRequests, + workspacePath, + isOpen, + onToggleOpen, +}: PullRequestsWidgetProps) { + const { t } = useTranslation("chat"); + const urls = useMemo( + () => pullRequests.map((pullRequest) => pullRequest.url), + [pullRequests], + ); + const { data: summaries = [], isFetching } = useQuery({ + queryKey: ["pull-request-summaries", workspacePath ?? null, urls], + queryFn: () => getPullRequestSummaries(urls, workspacePath), + enabled: urls.length > 0, + retry: false, + staleTime: 2 * 60 * 1000, + refetchOnWindowFocus: "always", + }); + const summaryByUrl = useMemo( + () => new Map(summaries.map((summary) => [summary.url, summary])), + [summaries], + ); + + if (pullRequests.length === 0) return null; + + return ( + } + isOpen={isOpen} + onToggleOpen={onToggleOpen} + action={ + + {isFetching ? ( + + } + flush + > +
+ {pullRequests.map((pullRequest) => { + const summary = summaryByUrl.get(pullRequest.url); + const state = summary?.state ?? null; + const checksStatus = summary?.checksStatus ?? null; + const title = + summary?.title ?? + t("contextPanel.pullRequests.fallbackTitle", { + number: pullRequest.number, + }); + + return ( + + ); + })} +
+
+ ); +} diff --git a/src/shared/api/pullRequests.test.ts b/src/shared/api/pullRequests.test.ts new file mode 100644 index 000000000..dba19e999 --- /dev/null +++ b/src/shared/api/pullRequests.test.ts @@ -0,0 +1,23 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; +import { getPullRequestSummaries } from "./pullRequests"; + +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); + +describe("pull request API", () => { + beforeEach(() => vi.clearAllMocks()); + + it("requests summaries with the workspace path", async () => { + vi.mocked(invoke).mockResolvedValue([]); + + await getPullRequestSummaries( + ["https://github.com/squareup/berd/pull/42"], + "/repo", + ); + + expect(invoke).toHaveBeenCalledWith("get_pull_request_summaries", { + urls: ["https://github.com/squareup/berd/pull/42"], + path: "/repo", + }); + }); +}); diff --git a/src/shared/api/pullRequests.ts b/src/shared/api/pullRequests.ts new file mode 100644 index 000000000..a97be49d0 --- /dev/null +++ b/src/shared/api/pullRequests.ts @@ -0,0 +1,24 @@ +import { invoke } from "@tauri-apps/api/core"; + +export type PullRequestState = "OPEN" | "CLOSED" | "MERGED"; +export type PullRequestChecksStatus = "SUCCESS" | "PENDING" | "FAILURE"; + +export interface PullRequestSummary { + url: string; + repoSlug: string; + number: number; + title: string | null; + state: PullRequestState | null; + isDraft: boolean | null; + checksStatus: PullRequestChecksStatus | null; +} + +export async function getPullRequestSummaries( + urls: string[], + path?: string | null, +): Promise { + return invoke("get_pull_request_summaries", { + urls, + path: path ?? null, + }); +} diff --git a/src/shared/i18n/locales/en/chat.json b/src/shared/i18n/locales/en/chat.json index aa4d49ffa..c867305da 100644 --- a/src/shared/i18n/locales/en/chat.json +++ b/src/shared/i18n/locales/en/chat.json @@ -282,6 +282,21 @@ "changes_one": "{{count}} change", "changes_other": "{{count}} changes" }, + "pullRequests": { + "fallbackTitle": "Pull request #{{number}}", + "open": "Open {{repo}} pull request #{{number}} on GitHub", + "state": { + "OPEN": "Open", + "CLOSED": "Closed", + "MERGED": "Merged", + "DRAFT": "Draft" + }, + "checks": { + "SUCCESS": "Checks passed", + "PENDING": "Checks running", + "FAILURE": "Checks failed" + } + }, "tabs": { "details": "Context", "changes": "Changes", @@ -292,6 +307,7 @@ "changes": "Changes", "changesOnBranch": "on", "includedWorkspaces": "Included workspaces", + "pullRequests": "Pull requests", "workspace": "Active worktree" } }, diff --git a/src/shared/i18n/locales/es/chat.json b/src/shared/i18n/locales/es/chat.json index 65f824346..beb0be99f 100644 --- a/src/shared/i18n/locales/es/chat.json +++ b/src/shared/i18n/locales/es/chat.json @@ -281,6 +281,21 @@ "changes_one": "{{count}} cambio", "changes_other": "{{count}} cambios" }, + "pullRequests": { + "fallbackTitle": "Pull request #{{number}}", + "open": "Abrir el pull request #{{number}} de {{repo}} en GitHub", + "state": { + "OPEN": "Abierto", + "CLOSED": "Cerrado", + "MERGED": "Fusionado", + "DRAFT": "Borrador" + }, + "checks": { + "SUCCESS": "Verificaciones aprobadas", + "PENDING": "Verificaciones en curso", + "FAILURE": "Verificaciones fallidas" + } + }, "tabs": { "details": "Contexto", "changes": "Cambios", @@ -291,6 +306,7 @@ "changes": "Cambios", "changesOnBranch": "en", "includedWorkspaces": "Espacios de trabajo incluidos", + "pullRequests": "Pull requests", "workspace": "Espacio de trabajo" } }, From 34bf6ed31d4c4c8f202157740485e477d7e24d2c Mon Sep 17 00:00:00 2001 From: Jon Tirsen Date: Fri, 31 Jul 2026 12:13:51 +0200 Subject: [PATCH 02/13] Keep related pull requests visible across context tabs Co-authored-by: Jon Tirsen Signed-off-by: Jon Tirsen --- src/features/chat/ui/ContextPanel.tsx | 122 ++++++++++++------ .../chat/ui/__tests__/ContextPanel.test.tsx | 38 +++++- 2 files changed, 122 insertions(+), 38 deletions(-) diff --git a/src/features/chat/ui/ContextPanel.tsx b/src/features/chat/ui/ContextPanel.tsx index 920e25bf5..1e10a25d4 100644 --- a/src/features/chat/ui/ContextPanel.tsx +++ b/src/features/chat/ui/ContextPanel.tsx @@ -1152,52 +1152,100 @@ export function ContextPanel({ - {shouldShowChanges ? ( - hasWorkspaceAttachments ? ( - + toggleSection("pullRequests")} + /> + {shouldShowChanges ? ( + hasWorkspaceAttachments ? ( + + ) : ( + toggleSection("changes")} + /> + ) + ) : isChangesProbeLoading ? ( + + ) : changesProbeError ? ( + ) : ( - + )} + + + + +
+ toggleSection("pullRequests")} + /> + +
} - isLoadingError={isFallbackChangedFilesLoadingError} - currentBranch={fallbackGitState?.currentBranch ?? null} - dirtyFileCount={fallbackGitState?.dirtyFileCount ?? 0} - repoPath={gitTargetPath ?? ""} - onOpenFile={handleOpenChangedFile} - isOpen={sectionVisibility.changes} - onToggleOpen={() => toggleSection("changes")} /> - ) - ) : isChangesProbeLoading ? ( - - ) : changesProbeError ? ( - - ) : ( - - )} + ) : ( + + )} +
+<<<<<<< HEAD +||||||| parent of ca0943ec (Keep related pull requests visible across context tabs) + + +======= + +
+ toggleSection("pullRequests")} + /> + +
+>>>>>>> ca0943ec (Keep related pull requests visible across context tabs)
); diff --git a/src/features/chat/ui/__tests__/ContextPanel.test.tsx b/src/features/chat/ui/__tests__/ContextPanel.test.tsx index 676f80488..620cb050a 100644 --- a/src/features/chat/ui/__tests__/ContextPanel.test.tsx +++ b/src/features/chat/ui/__tests__/ContextPanel.test.tsx @@ -124,6 +124,10 @@ vi.mock("@/shared/api/git", () => ({ listenGitStateChanged: mockListenGitStateChanged, })); +vi.mock("@/shared/api/pullRequests", () => ({ + getPullRequestSummaries: vi.fn().mockResolvedValue([]), +})); + vi.mock("../../hooks/ArtifactPolicyContext", () => ({ useArtifactActionsContext: () => ({ openResolvedPath: vi.fn(), @@ -343,7 +347,7 @@ describe("ContextPanel", () => { gitStateChangedHandlers.length = 0; window.localStorage.clear(); setMultiWorkspaceEnabled(true); - useChatStore.setState({ sessionStateById: {} }); + useChatStore.setState({ messagesBySession: {}, sessionStateById: {} }); useChatSessionStore.setState({ sessions: [], activeSessionId: null, @@ -783,6 +787,38 @@ describe("ContextPanel", () => { expect(screen.getAllByText("goose2").length).toBeGreaterThan(0); }); + it("keeps session pull requests visible across context panel tabs", async () => { + const user = userEvent.setup(); + const sessionId = "test-session-related-pr"; + useChatStore.setState({ + messagesBySession: { + [sessionId]: [ + { + id: "assistant-pr-link", + role: "assistant", + created: 1, + content: [ + { + type: "text", + text: "https://github.com/squareup/berd/pull/891", + }, + ], + }, + ], + }, + }); + + renderContextPanel({ sessionId }); + + expect(await screen.findByText("Pull requests")).toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: /changes/i })); + expect(screen.getByText("Pull requests")).toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: /files/i })); + expect(screen.getByText("Pull requests")).toBeInTheDocument(); + }); + it("renders repo-relative titles for project subdirectories", () => { mockUseGitState.mockReturnValue({ data: { From 3715a1c00448682a9e70895a477ebccdd54268d3 Mon Sep 17 00:00:00 2001 From: Jon Tirsen Date: Fri, 31 Jul 2026 12:25:21 +0200 Subject: [PATCH 03/13] Remove related pull requests from context tab Co-authored-by: Jon Tirsen Signed-off-by: Jon Tirsen --- src/features/chat/ui/ContextPanel.tsx | 6 ------ src/features/chat/ui/__tests__/ContextPanel.test.tsx | 9 ++++++--- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/features/chat/ui/ContextPanel.tsx b/src/features/chat/ui/ContextPanel.tsx index 1e10a25d4..d92d974ce 100644 --- a/src/features/chat/ui/ContextPanel.tsx +++ b/src/features/chat/ui/ContextPanel.tsx @@ -1136,12 +1136,6 @@ export function ContextPanel({ onToggleTerminal={onToggleTerminal} /> )} - toggleSection("pullRequests")} - /> {shouldShowArtifacts && ( { expect(screen.getAllByText("goose2").length).toBeGreaterThan(0); }); - it("keeps session pull requests visible across context panel tabs", async () => { + it("shows session pull requests outside the context tab", async () => { const user = userEvent.setup(); const sessionId = "test-session-related-pr"; useChatStore.setState({ @@ -810,13 +810,16 @@ describe("ContextPanel", () => { renderContextPanel({ sessionId }); - expect(await screen.findByText("Pull requests")).toBeInTheDocument(); + expect(screen.queryByText("Pull requests")).not.toBeInTheDocument(); await user.click(screen.getByRole("tab", { name: /changes/i })); - expect(screen.getByText("Pull requests")).toBeInTheDocument(); + expect(await screen.findByText("Pull requests")).toBeInTheDocument(); await user.click(screen.getByRole("tab", { name: /files/i })); expect(screen.getByText("Pull requests")).toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: /context/i })); + expect(screen.queryByText("Pull requests")).not.toBeInTheDocument(); }); it("renders repo-relative titles for project subdirectories", () => { From 6a4395d6ad51527856f537a19d6815cd9228340e Mon Sep 17 00:00:00 2001 From: Jon Tirsen Date: Fri, 31 Jul 2026 12:53:12 +0200 Subject: [PATCH 04/13] Index related pull requests incrementally Co-authored-by: Jon Tirsen Signed-off-by: Jon Tirsen --- src/features/chat/lib/pullRequests.test.ts | 93 ++++++++++++++++++- src/features/chat/lib/pullRequests.ts | 78 ++++++++++++++++ .../chat/ui/widgets/PullRequestsWidget.tsx | 44 +++++++-- 3 files changed, 207 insertions(+), 8 deletions(-) diff --git a/src/features/chat/lib/pullRequests.test.ts b/src/features/chat/lib/pullRequests.test.ts index 5cd0c6a2f..3fae64a5b 100644 --- a/src/features/chat/lib/pullRequests.test.ts +++ b/src/features/chat/lib/pullRequests.test.ts @@ -1,11 +1,24 @@ import { describe, expect, it } from "vitest"; import type { Message } from "@/shared/types/messages"; -import { findRelatedPullRequests } from "./pullRequests"; +import { + advanceRelatedPullRequestScan, + EMPTY_RELATED_PULL_REQUEST_SCAN, + findRelatedPullRequests, +} from "./pullRequests"; function message(role: Message["role"], content: Message["content"]): Message { return { id: crypto.randomUUID(), role, created: 1, content }; } +function textMessage(id: string, text: string): Message { + return { + id, + role: "assistant", + created: 1, + content: [{ type: "text", text }], + }; +} + describe("findRelatedPullRequests", () => { it("finds GitHub links in chat text and normalizes Graphite links", () => { const messages = [ @@ -87,3 +100,81 @@ describe("findRelatedPullRequests", () => { ]); }); }); + +describe("advanceRelatedPullRequestScan", () => { + it("bootstraps after replay and only scans newly completed messages", () => { + const historical = textMessage( + "historical", + "https://github.com/squareup/berd/pull/10", + ); + const streaming = textMessage( + "streaming", + "https://github.com/squareup/berd/pull/20", + ); + + const loading = advanceRelatedPullRequestScan( + EMPTY_RELATED_PULL_REQUEST_SCAN, + [historical], + null, + true, + ); + expect(loading).toBe(EMPTY_RELATED_PULL_REQUEST_SCAN); + + const bootstrapped = advanceRelatedPullRequestScan( + loading, + [historical, streaming], + streaming.id, + false, + ); + expect(bootstrapped.pullRequests.map((pr) => pr.number)).toEqual([10]); + expect(bootstrapped.processedMessageCount).toBe(1); + + const streamingUpdate = textMessage( + "streaming", + "https://github.com/squareup/berd/pull/21", + ); + expect( + advanceRelatedPullRequestScan( + bootstrapped, + [historical, streamingUpdate], + streamingUpdate.id, + false, + ), + ).toBe(bootstrapped); + + const completed = advanceRelatedPullRequestScan( + bootstrapped, + [historical, streamingUpdate], + null, + false, + ); + expect(completed.pullRequests.map((pr) => pr.number)).toEqual([10, 21]); + expect(completed.processedMessageCount).toBe(2); + }); + + it("rebuilds the index when processed history is replaced", () => { + const original = textMessage( + "original", + "https://github.com/squareup/berd/pull/10", + ); + const initial = advanceRelatedPullRequestScan( + EMPTY_RELATED_PULL_REQUEST_SCAN, + [original], + null, + false, + ); + const replacement = textMessage( + "replacement", + "https://github.com/squareup/berd/pull/30", + ); + + const rebuilt = advanceRelatedPullRequestScan( + initial, + [replacement], + null, + false, + ); + + expect(rebuilt.pullRequests.map((pr) => pr.number)).toEqual([30]); + }); +}); diff --git a/src/features/chat/lib/pullRequests.ts b/src/features/chat/lib/pullRequests.ts index 663c49e33..83091e901 100644 --- a/src/features/chat/lib/pullRequests.ts +++ b/src/features/chat/lib/pullRequests.ts @@ -8,6 +8,20 @@ export interface DetectedPullRequest { number: number; } +export interface RelatedPullRequestScan { + initialized: boolean; + processedMessageCount: number; + lastProcessedMessageId: string | null; + pullRequests: DetectedPullRequest[]; +} + +export const EMPTY_RELATED_PULL_REQUEST_SCAN: RelatedPullRequestScan = { + initialized: false, + processedMessageCount: 0, + lastProcessedMessageId: null, + pullRequests: [], +}; + const PULL_REQUEST_URL_PATTERN = /https?:\/\/(?:github\.com\/([\w.-]+)\/([\w.-]+)\/pull\/(\d+)|app\.graphite\.(?:com|dev)\/github\/pr\/([\w.-]+)\/([\w.-]+)\/(\d+))/gi; @@ -64,3 +78,67 @@ export function findRelatedPullRequests( return results; } + +function mergePullRequests( + existing: DetectedPullRequest[], + additions: DetectedPullRequest[], + limit: number, +): DetectedPullRequest[] { + if (existing.length >= limit || additions.length === 0) return existing; + + const merged = [...existing]; + const seen = new Set( + existing.map( + (pullRequest) => + `${pullRequest.repoSlug.toLowerCase()}#${pullRequest.number}`, + ), + ); + + for (const pullRequest of additions) { + const key = `${pullRequest.repoSlug.toLowerCase()}#${pullRequest.number}`; + if (seen.has(key)) continue; + seen.add(key); + merged.push(pullRequest); + if (merged.length >= limit) break; + } + + return merged; +} + +export function advanceRelatedPullRequestScan( + scan: RelatedPullRequestScan, + messages: Message[], + streamingMessageId: string | null, + isLoading: boolean, + limit = MAX_RELATED_PULL_REQUESTS, +): RelatedPullRequestScan { + if (isLoading) return EMPTY_RELATED_PULL_REQUEST_SCAN; + + const prefixChanged = + scan.processedMessageCount > messages.length || + (scan.processedMessageCount > 0 && + messages[scan.processedMessageCount - 1]?.id !== + scan.lastProcessedMessageId); + const start = + !scan.initialized || prefixChanged ? 0 : scan.processedMessageCount; + + let end = start; + while (end < messages.length && messages[end]?.id !== streamingMessageId) { + end += 1; + } + + if (scan.initialized && !prefixChanged && end === start) return scan; + + const additions = findRelatedPullRequests(messages.slice(start, end), limit); + const pullRequests = + start === 0 + ? additions + : mergePullRequests(scan.pullRequests, additions, limit); + + return { + initialized: true, + processedMessageCount: end, + lastProcessedMessageId: end > 0 ? (messages[end - 1]?.id ?? null) : null, + pullRequests, + }; +} diff --git a/src/features/chat/ui/widgets/PullRequestsWidget.tsx b/src/features/chat/ui/widgets/PullRequestsWidget.tsx index 8d57e09be..55eb36642 100644 --- a/src/features/chat/ui/widgets/PullRequestsWidget.tsx +++ b/src/features/chat/ui/widgets/PullRequestsWidget.tsx @@ -1,4 +1,4 @@ -import { useDeferredValue, useMemo } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { IconExternalLink, @@ -8,8 +8,9 @@ import { import { openUrl } from "@tauri-apps/plugin-opener"; import { useTranslation } from "react-i18next"; import { - findRelatedPullRequests, + advanceRelatedPullRequestScan, type DetectedPullRequest, + EMPTY_RELATED_PULL_REQUEST_SCAN, } from "../../lib/pullRequests"; import { useChatStore } from "../../stores/chatStore"; import type { Message } from "@/shared/types/messages"; @@ -51,12 +52,41 @@ export function SessionPullRequestsWidget({ sessionId, ...props }: SessionPullRequestsWidgetProps) { - const messages = useChatStore((state) => state.messagesBySession[sessionId]); - const deferredMessages = useDeferredValue(messages ?? EMPTY_MESSAGES); - const pullRequests = useMemo( - () => findRelatedPullRequests(deferredMessages), - [deferredMessages], + const messages = useChatStore( + (state) => state.messagesBySession[sessionId] ?? EMPTY_MESSAGES, ); + const streamingMessageId = useChatStore( + (state) => state.sessionStateById[sessionId]?.streamingMessageId ?? null, + ); + const isLoading = useChatStore((state) => + state.loadingSessionIds.has(sessionId), + ); + const [sessionScan, setSessionScan] = useState(() => ({ + sessionId, + scan: EMPTY_RELATED_PULL_REQUEST_SCAN, + })); + + useEffect(() => { + setSessionScan((current) => { + const scan = advanceRelatedPullRequestScan( + current.sessionId === sessionId + ? current.scan + : EMPTY_RELATED_PULL_REQUEST_SCAN, + messages, + streamingMessageId, + isLoading, + ); + if (current.sessionId === sessionId && scan === current.scan) { + return current; + } + return { sessionId, scan }; + }); + }, [isLoading, messages, sessionId, streamingMessageId]); + + const pullRequests = + !isLoading && sessionScan.sessionId === sessionId + ? sessionScan.scan.pullRequests + : EMPTY_RELATED_PULL_REQUEST_SCAN.pullRequests; return ; } From ed8ee1abb32df117ef5022f0d6f2aa8a16127a94 Mon Sep 17 00:00:00 2001 From: Jon Tirsen Date: Mon, 3 Aug 2026 10:36:23 +0200 Subject: [PATCH 05/13] Gate related pull requests behind experiment Co-authored-by: Jon Tirsen Signed-off-by: Jon Tirsen --- src/features/chat/ui/ContextPanel.tsx | 64 +++++++------------ .../chat/ui/__tests__/ContextPanel.test.tsx | 37 ++++++++++- .../experiments/experimentDefinitions.ts | 6 ++ src/shared/i18n/locales/en/settings.json | 4 ++ src/shared/i18n/locales/es/settings.json | 4 ++ 5 files changed, 72 insertions(+), 43 deletions(-) diff --git a/src/features/chat/ui/ContextPanel.tsx b/src/features/chat/ui/ContextPanel.tsx index d92d974ce..98d89bd88 100644 --- a/src/features/chat/ui/ContextPanel.tsx +++ b/src/features/chat/ui/ContextPanel.tsx @@ -69,6 +69,8 @@ import { useChangeSessionFolder } from "@/features/chat/hooks/useChangeSessionFo import { supersedePendingSessionWorkspaceActivation } from "@/features/chat/lib/sessionWorkspaceActivation"; import { useChatStore } from "../stores/chatStore"; import { SessionPullRequestsWidget } from "./widgets/PullRequestsWidget"; +import { RELATED_PULL_REQUESTS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; +import { useExperiment } from "@/features/experiments/experimentPreferences"; import type { CreatedWorkspaceWorktreeContext } from "./widgets/WorkspaceCreateDialog"; import type { WorkspaceRemovalPlan } from "./widgets/WorkspaceRowActionsMenu"; @@ -274,6 +276,12 @@ export function ContextPanel({ onOpenTerminalAtPath, }: ContextPanelProps) { const { t } = useTranslation("chat"); + const relatedPullRequestsExperiment = useExperiment( + RELATED_PULL_REQUESTS_EXPERIMENT_ID, + ); + const relatedPullRequestsEnabled = Boolean( + relatedPullRequestsExperiment?.enabled, + ); const workspaceRepository = useWorkspaceRepository(); const [activeTab, setActiveTab] = useState("details"); const [isAddWorkspaceOpen, setIsAddWorkspaceOpen] = useState(false); @@ -1147,12 +1155,14 @@ export function ContextPanel({
- toggleSection("pullRequests")} - /> + {relatedPullRequestsEnabled && ( + toggleSection("pullRequests")} + /> + )} {shouldShowChanges ? ( hasWorkspaceAttachments ? (
- toggleSection("pullRequests")} - /> - -
- } + {relatedPullRequestsEnabled && ( + toggleSection("pullRequests")} /> - ) : ( - )} -
-
- -<<<<<<< HEAD - - -||||||| parent of ca0943ec (Keep related pull requests visible across context tabs) - - -======= - -
- toggleSection("pullRequests")} - />
->>>>>>> ca0943ec (Keep related pull requests visible across context tabs)
); diff --git a/src/features/chat/ui/__tests__/ContextPanel.test.tsx b/src/features/chat/ui/__tests__/ContextPanel.test.tsx index 5a7954cb1..e4a45a4f3 100644 --- a/src/features/chat/ui/__tests__/ContextPanel.test.tsx +++ b/src/features/chat/ui/__tests__/ContextPanel.test.tsx @@ -15,6 +15,8 @@ import { useChatStore } from "../../stores/chatStore"; import { getWorkspaceGitContext } from "../widgets/WorkspaceIdentity"; import { ContextPanel, ContextPanelWorktreeTracker } from "../ContextPanel"; import { setMultiWorkspaceEnabled } from "@/features/workspaces/multiWorkspacePreference"; +import { RELATED_PULL_REQUESTS_EXPERIMENT_ID } from "@/features/experiments/experimentDefinitions"; +import { setExperimentEnabled } from "@/features/experiments/experimentPreferences"; const { mockUseGitState, @@ -30,6 +32,7 @@ const { mockToastError, mockToastSuccess, mockListenGitStateChanged, + mockGetPullRequestSummaries, gitStateChangedHandlers, } = vi.hoisted(() => { const gitStateChangedHandlers: Array< @@ -55,6 +58,7 @@ const { return Promise.resolve(() => {}); }, ), + mockGetPullRequestSummaries: vi.fn().mockResolvedValue([]), gitStateChangedHandlers, }; }); @@ -125,7 +129,7 @@ vi.mock("@/shared/api/git", () => ({ })); vi.mock("@/shared/api/pullRequests", () => ({ - getPullRequestSummaries: vi.fn().mockResolvedValue([]), + getPullRequestSummaries: mockGetPullRequestSummaries, })); vi.mock("../../hooks/ArtifactPolicyContext", () => ({ @@ -822,6 +826,37 @@ describe("ContextPanel", () => { expect(screen.queryByText("Pull requests")).not.toBeInTheDocument(); }); + it("does not scan session messages when related pull requests are disabled", async () => { + const user = userEvent.setup(); + const sessionId = "test-session-disabled-related-pr"; + setExperimentEnabled(RELATED_PULL_REQUESTS_EXPERIMENT_ID, false); + useChatStore.setState({ + messagesBySession: { + [sessionId]: [ + { + id: "assistant-pr-link", + role: "assistant", + created: 1, + content: [ + { + type: "text", + text: "https://github.com/squareup/berd/pull/891", + }, + ], + }, + ], + }, + }); + + renderContextPanel({ sessionId }); + + await user.click(screen.getByRole("tab", { name: /changes/i })); + expect(screen.queryByText("Pull requests")).not.toBeInTheDocument(); + await user.click(screen.getByRole("tab", { name: /files/i })); + expect(screen.queryByText("Pull requests")).not.toBeInTheDocument(); + expect(mockGetPullRequestSummaries).not.toHaveBeenCalled(); + }); + it("renders repo-relative titles for project subdirectories", () => { mockUseGitState.mockReturnValue({ data: { diff --git a/src/features/experiments/experimentDefinitions.ts b/src/features/experiments/experimentDefinitions.ts index cc8235dea..e0cd0dc53 100644 --- a/src/features/experiments/experimentDefinitions.ts +++ b/src/features/experiments/experimentDefinitions.ts @@ -63,6 +63,7 @@ export const BERDY_ONBOARDING_EXPERIMENT_ID = "berdy-onboarding"; export const SKILL_DISCOVERY_EXPERIMENT_ID = "skill-discovery"; +export const RELATED_PULL_REQUESTS_EXPERIMENT_ID = "related-pull-requests"; export const EXPERIMENT_DEFINITIONS = [ { id: BUILDERBOT_SURFACE_EXPERIMENT_ID, @@ -111,4 +112,9 @@ export const EXPERIMENT_DEFINITIONS = [ descriptionKey: "experiments.berdyOnboarding.description", settingsVisibility: "dev", }, + { + id: RELATED_PULL_REQUESTS_EXPERIMENT_ID, + titleKey: "experiments.relatedPullRequests.title", + descriptionKey: "experiments.relatedPullRequests.description", + }, ] as const satisfies readonly ExperimentDefinition[]; diff --git a/src/shared/i18n/locales/en/settings.json b/src/shared/i18n/locales/en/settings.json index 829d92d85..04fd23eee 100644 --- a/src/shared/i18n/locales/en/settings.json +++ b/src/shared/i18n/locales/en/settings.json @@ -215,6 +215,10 @@ "description": "Show the Builderbot tasks and automations surface.", "title": "Builderbot" }, + "relatedPullRequests": { + "description": "Find pull requests linked in a chat and show their current status in the context panel.", + "title": "Related pull requests" + }, "defaultLabel": "default", "description": "Opt into in-progress Berd features on this device. Experiments can change, break, or disappear.", "emptyDescription": "New experiments will appear here when they are ready for opt-in testing.", diff --git a/src/shared/i18n/locales/es/settings.json b/src/shared/i18n/locales/es/settings.json index 6ff98d4fa..d2ec0ef78 100644 --- a/src/shared/i18n/locales/es/settings.json +++ b/src/shared/i18n/locales/es/settings.json @@ -215,6 +215,10 @@ "description": "Muestra la superficie de tareas y automatizaciones de Builderbot.", "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" + }, "defaultLabel": "predeterminado", "description": "Activa funciones de Berd en desarrollo en este dispositivo. Los experimentos pueden cambiar, fallar o desaparecer.", "emptyDescription": "Los nuevos experimentos aparecerán aquí cuando estén listos para probarse.", From 23650763f647c39727600aeddb73908bb9aaf4f0 Mon Sep 17 00:00:00 2001 From: Jon Tirsen Date: Tue, 4 Aug 2026 09:55:06 +0200 Subject: [PATCH 06/13] Rescan patched pull request messages Co-authored-by: Jon Tirsen Signed-off-by: Jon Tirsen --- src/features/chat/lib/pullRequests.test.ts | 48 ++++++++++++++++++++++ src/features/chat/lib/pullRequests.ts | 13 +++--- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/features/chat/lib/pullRequests.test.ts b/src/features/chat/lib/pullRequests.test.ts index 3fae64a5b..1a38b1e31 100644 --- a/src/features/chat/lib/pullRequests.test.ts +++ b/src/features/chat/lib/pullRequests.test.ts @@ -177,4 +177,52 @@ describe("advanceRelatedPullRequestScan", () => { expect(rebuilt.pullRequests.map((pr) => pr.number)).toEqual([30]); }); + + it("rebuilds when a same-id historical message is patched", () => { + const toolMessage: Message = { + id: "tool-message", + role: "assistant", + created: 1, + content: [ + { + type: "toolRequest", + id: "tool-call", + name: "shell", + arguments: {}, + status: "in_progress", + }, + ], + }; + const streaming = textMessage("streaming", "Still working"); + const initial = advanceRelatedPullRequestScan( + EMPTY_RELATED_PULL_REQUEST_SCAN, + [toolMessage, streaming], + streaming.id, + false, + ); + const patchedToolMessage: Message = { + ...toolMessage, + content: [ + ...toolMessage.content, + { + type: "toolResponse", + id: "tool-call", + name: "shell", + result: "Opened https://github.com/squareup/berd/pull/40", + isError: false, + }, + ], + }; + + const rebuilt = advanceRelatedPullRequestScan( + initial, + [patchedToolMessage, streaming], + streaming.id, + false, + ); + + expect(rebuilt.pullRequests.map((pr) => pr.number)).toEqual([40]); + expect(rebuilt.processedMessageCount).toBe(1); + expect(rebuilt.processedMessages).toEqual([patchedToolMessage]); + }); }); diff --git a/src/features/chat/lib/pullRequests.ts b/src/features/chat/lib/pullRequests.ts index 83091e901..92849869f 100644 --- a/src/features/chat/lib/pullRequests.ts +++ b/src/features/chat/lib/pullRequests.ts @@ -11,14 +11,15 @@ export interface DetectedPullRequest { export interface RelatedPullRequestScan { initialized: boolean; processedMessageCount: number; - lastProcessedMessageId: string | null; + /** Immutable message references reveal same-id patches to scanned history. */ + processedMessages: readonly Message[]; pullRequests: DetectedPullRequest[]; } export const EMPTY_RELATED_PULL_REQUEST_SCAN: RelatedPullRequestScan = { initialized: false, processedMessageCount: 0, - lastProcessedMessageId: null, + processedMessages: [], pullRequests: [], }; @@ -116,9 +117,9 @@ export function advanceRelatedPullRequestScan( const prefixChanged = scan.processedMessageCount > messages.length || - (scan.processedMessageCount > 0 && - messages[scan.processedMessageCount - 1]?.id !== - scan.lastProcessedMessageId); + scan.processedMessages.some( + (processedMessage, index) => messages[index] !== processedMessage, + ); const start = !scan.initialized || prefixChanged ? 0 : scan.processedMessageCount; @@ -138,7 +139,7 @@ export function advanceRelatedPullRequestScan( return { initialized: true, processedMessageCount: end, - lastProcessedMessageId: end > 0 ? (messages[end - 1]?.id ?? null) : null, + processedMessages: messages.slice(0, end), pullRequests, }; } From 656eba6432e6504df0509f964e92cc58fe552a57 Mon Sep 17 00:00:00 2001 From: Jon Tirsen Date: Tue, 4 Aug 2026 10:35:57 +0200 Subject: [PATCH 07/13] Show related pull requests only in Changes Co-authored-by: Jon Tirsen Signed-off-by: Jon Tirsen --- src/features/chat/ui/ContextPanel.tsx | 12 +----------- src/features/chat/ui/__tests__/ContextPanel.test.tsx | 4 ++-- 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/src/features/chat/ui/ContextPanel.tsx b/src/features/chat/ui/ContextPanel.tsx index 98d89bd88..855d0aac3 100644 --- a/src/features/chat/ui/ContextPanel.tsx +++ b/src/features/chat/ui/ContextPanel.tsx @@ -1209,17 +1209,7 @@ export function ContextPanel({
-
- {relatedPullRequestsEnabled && ( - toggleSection("pullRequests")} - /> - )} - -
+
); diff --git a/src/features/chat/ui/__tests__/ContextPanel.test.tsx b/src/features/chat/ui/__tests__/ContextPanel.test.tsx index e4a45a4f3..4811162e6 100644 --- a/src/features/chat/ui/__tests__/ContextPanel.test.tsx +++ b/src/features/chat/ui/__tests__/ContextPanel.test.tsx @@ -791,7 +791,7 @@ describe("ContextPanel", () => { expect(screen.getAllByText("goose2").length).toBeGreaterThan(0); }); - it("shows session pull requests outside the context tab", async () => { + it("shows session pull requests only in the changes tab", async () => { const user = userEvent.setup(); const sessionId = "test-session-related-pr"; useChatStore.setState({ @@ -820,7 +820,7 @@ describe("ContextPanel", () => { expect(await screen.findByText("Pull requests")).toBeInTheDocument(); await user.click(screen.getByRole("tab", { name: /files/i })); - expect(screen.getByText("Pull requests")).toBeInTheDocument(); + expect(screen.queryByText("Pull requests")).not.toBeInTheDocument(); await user.click(screen.getByRole("tab", { name: /context/i })); expect(screen.queryByText("Pull requests")).not.toBeInTheDocument(); From f1b919e6ac24569de2dbd04524bf8b1d75d53c1f Mon Sep 17 00:00:00 2001 From: Jon Tirsen Date: Tue, 4 Aug 2026 11:56:41 +0200 Subject: [PATCH 08/13] Classify terminal check failures correctly Co-authored-by: Jon Tirsen Signed-off-by: Jon Tirsen --- src-tauri/src/commands/pull_requests.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands/pull_requests.rs b/src-tauri/src/commands/pull_requests.rs index 351501483..a567a8c31 100644 --- a/src-tauri/src/commands/pull_requests.rs +++ b/src-tauri/src/commands/pull_requests.rs @@ -84,7 +84,7 @@ fn classify_check(check: &GhStatusCheck) -> &'static str { "SUCCESS" } else if matches!( conclusion.as_str(), - "FAILURE" | "CANCELLED" | "TIMED_OUT" | "ACTION_REQUIRED" + "FAILURE" | "CANCELLED" | "TIMED_OUT" | "ACTION_REQUIRED" | "STARTUP_FAILURE" | "STALE" ) || matches!(state.as_str(), "FAILURE" | "ERROR") { "FAILURE" @@ -262,7 +262,11 @@ mod tests { Some("PENDING".to_string()) ); - let failing = vec![check(Some("FAILURE"), Some("COMPLETED"), None)]; + let failing = vec![ + check(Some("FAILURE"), Some("COMPLETED"), None), + check(Some("STARTUP_FAILURE"), Some("COMPLETED"), None), + check(Some("STALE"), Some("COMPLETED"), None), + ]; assert_eq!( summarize_checks(Some(&failing)), Some("FAILURE".to_string()) From bb8204d5e958a864fa99866c3e34db5739579364 Mon Sep 17 00:00:00 2001 From: Jon Tirsen Date: Tue, 4 Aug 2026 12:01:31 +0200 Subject: [PATCH 09/13] Update experiment registry contract Co-authored-by: Jon Tirsen Signed-off-by: Jon Tirsen --- src/features/experiments/__tests__/ExperimentsSettings.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/features/experiments/__tests__/ExperimentsSettings.test.tsx b/src/features/experiments/__tests__/ExperimentsSettings.test.tsx index c61632d66..a20e3daa2 100644 --- a/src/features/experiments/__tests__/ExperimentsSettings.test.tsx +++ b/src/features/experiments/__tests__/ExperimentsSettings.test.tsx @@ -7,6 +7,7 @@ import { BERDY_ONBOARDING_EXPERIMENT_ID, BUILDERBOT_SURFACE_EXPERIMENT_ID, EXPERIMENT_DEFINITIONS, + RELATED_PULL_REQUESTS_EXPERIMENT_ID, SKILL_DISCOVERY_EXPERIMENT_ID, STARTER_TASKS_EXPERIMENT_ID, TRANSCRIPT_VIRTUAL_RENDERER_EXPERIMENT_ID, @@ -136,6 +137,7 @@ describe("ExperimentsSettings", () => { VOICE_CONVERSATION_EXPERIMENT_ID, AVATAR_COLLECTION_PAGE_EXPERIMENT_ID, BERDY_ONBOARDING_EXPERIMENT_ID, + RELATED_PULL_REQUESTS_EXPERIMENT_ID, ]); }); From 4d2efade31e3394a9a0c28fdae81067199e6c5d7 Mon Sep 17 00:00:00 2001 From: Jon Tirsen Date: Wed, 19 Aug 2026 18:58:07 +0200 Subject: [PATCH 10/13] Share pull request list item UI Extract the PR identity, status, and external-link treatment so the session rail and tracker can render the same recognizable row. Co-authored-by: Jon Tirsen Signed-off-by: Jon Tirsen --- .../chat/ui/widgets/PullRequestsWidget.tsx | 105 +++++++----------- .../ui/PullRequestListItem.test.tsx | 37 ++++++ .../pull-requests/ui/PullRequestListItem.tsx | 100 +++++++++++++++++ 3 files changed, 178 insertions(+), 64 deletions(-) create mode 100644 src/features/pull-requests/ui/PullRequestListItem.test.tsx create mode 100644 src/features/pull-requests/ui/PullRequestListItem.tsx diff --git a/src/features/chat/ui/widgets/PullRequestsWidget.tsx b/src/features/chat/ui/widgets/PullRequestsWidget.tsx index 55eb36642..a5f8315f4 100644 --- a/src/features/chat/ui/widgets/PullRequestsWidget.tsx +++ b/src/features/chat/ui/widgets/PullRequestsWidget.tsx @@ -1,12 +1,12 @@ import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { - IconExternalLink, - IconGitPullRequest, - IconLoader2, -} from "@tabler/icons-react"; +import { IconGitPullRequest, IconLoader2 } from "@tabler/icons-react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { useTranslation } from "react-i18next"; +import { + PullRequestListItem, + type PullRequestListItemStatus, +} from "@/features/pull-requests/ui/PullRequestListItem"; import { advanceRelatedPullRequestScan, type DetectedPullRequest, @@ -19,7 +19,6 @@ import { type PullRequestChecksStatus, type PullRequestState, } from "@/shared/api/pullRequests"; -import { cn } from "@/shared/lib/cn"; import { Widget } from "./Widget"; interface PullRequestsWidgetProps { @@ -36,16 +35,20 @@ interface SessionPullRequestsWidgetProps const EMPTY_MESSAGES: Message[] = []; -const STATE_DOT_CLASS: Record = { - OPEN: "bg-success", - MERGED: "bg-primary", - CLOSED: "bg-destructive", -}; +const STATE_TONE: Record = + { + OPEN: "success", + MERGED: "primary", + CLOSED: "danger", + }; -const CHECKS_DOT_CLASS: Record = { - SUCCESS: "bg-success", - PENDING: "bg-warning", - FAILURE: "bg-destructive", +const CHECKS_TONE: Record< + PullRequestChecksStatus, + PullRequestListItemStatus["tone"] +> = { + SUCCESS: "success", + PENDING: "warning", + FAILURE: "danger", }; export function SessionPullRequestsWidget({ @@ -143,61 +146,35 @@ export function PullRequestsWidget({ t("contextPanel.pullRequests.fallbackTitle", { number: pullRequest.number, }); + const statuses: PullRequestListItemStatus[] = []; + if (state) { + statuses.push({ + tone: STATE_TONE[state], + label: summary?.isDraft + ? t("contextPanel.pullRequests.state.DRAFT") + : t(`contextPanel.pullRequests.state.${state}`), + }); + } + if (checksStatus) { + statuses.push({ + tone: CHECKS_TONE[checksStatus], + label: t(`contextPanel.pullRequests.checks.${checksStatus}`), + }); + } return ( - + onOpen={() => void openUrl(pullRequest.url)} + /> ); })} diff --git a/src/features/pull-requests/ui/PullRequestListItem.test.tsx b/src/features/pull-requests/ui/PullRequestListItem.test.tsx new file mode 100644 index 000000000..2826d4d3b --- /dev/null +++ b/src/features/pull-requests/ui/PullRequestListItem.test.tsx @@ -0,0 +1,37 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { PullRequestListItem } from "./PullRequestListItem"; + +describe("PullRequestListItem", () => { + it("renders pull request identity and statuses and opens the item", () => { + const onOpen = vi.fn(); + + render( + , + ); + + expect(screen.getByText("block/berd #42")).toBeVisible(); + expect(screen.getByText("Share pull request rows")).toBeVisible(); + expect(screen.getByText("Open")).toBeVisible(); + expect(screen.getByText("Checks pending")).toBeVisible(); + expect(screen.getByText("Aug 19")).toBeVisible(); + + fireEvent.click( + screen.getByRole("button", { + name: "Open block/berd pull request #42 on GitHub", + }), + ); + expect(onOpen).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/features/pull-requests/ui/PullRequestListItem.tsx b/src/features/pull-requests/ui/PullRequestListItem.tsx new file mode 100644 index 000000000..67218ab8e --- /dev/null +++ b/src/features/pull-requests/ui/PullRequestListItem.tsx @@ -0,0 +1,100 @@ +import { IconExternalLink, IconGitPullRequest } from "@tabler/icons-react"; +import { cn } from "@/shared/lib/cn"; + +export type PullRequestListItemTone = + | "muted" + | "primary" + | "success" + | "warning" + | "danger"; + +export interface PullRequestListItemStatus { + label: string; + tone: PullRequestListItemTone; +} + +interface PullRequestListItemProps { + repo: string; + number: number | string; + title: string; + statuses?: readonly PullRequestListItemStatus[]; + timestamp?: string | null; + ariaLabel: string; + onOpen: () => void; + className?: string; +} + +const STATUS_DOT_CLASS: Record = { + muted: "bg-muted-foreground", + primary: "bg-primary", + success: "bg-success", + warning: "bg-warning", + danger: "bg-destructive", +}; + +export function PullRequestListItem({ + repo, + number, + title, + statuses = [], + timestamp, + ariaLabel, + onOpen, + className, +}: PullRequestListItemProps) { + const normalizedNumber = String(number).replace(/^#/, ""); + + return ( + + ); +} From e9adec3536ae358769b318901d9d8797c4562910 Mon Sep 17 00:00:00 2001 From: kennylauren Date: Thu, 6 Aug 2026 13:34:24 -0700 Subject: [PATCH 11/13] 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 1545fab10..67c5972ba 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -39,6 +39,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 5671189e7..d47f2caa3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -443,6 +443,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_image, commands::agents::read_agent_source_file, 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 eaca39d3f..d30bae7ef 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 a20e3daa2..5c35e82a4 100644 --- a/src/features/experiments/__tests__/ExperimentsSettings.test.tsx +++ b/src/features/experiments/__tests__/ExperimentsSettings.test.tsx @@ -12,6 +12,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"; @@ -128,11 +129,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, AVATAR_COLLECTION_PAGE_EXPERIMENT_ID, diff --git a/src/features/experiments/experimentDefinitions.ts b/src/features/experiments/experimentDefinitions.ts index e0cd0dc53..2193d0245 100644 --- a/src/features/experiments/experimentDefinitions.ts +++ b/src/features/experiments/experimentDefinitions.ts @@ -64,6 +64,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, @@ -84,6 +86,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 04fd23eee..96fea0823 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 d2ec0ef78..d2df53050 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"; } }