diff --git a/crates/db/src/models/merge.rs b/crates/db/src/models/merge.rs index 4d9516cb..336eb233 100644 --- a/crates/db/src/models/merge.rs +++ b/crates/db/src/models/merge.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use sqlx::{FromRow, SqlitePool, Type}; @@ -288,6 +290,45 @@ impl Merge { Ok(rows.into_iter().map(Into::into).collect()) } + + /// Get the latest PR status for each workspace (for workspace summaries) + /// Returns a map of workspace_id -> MergeStatus for workspaces that have PRs + pub async fn get_latest_pr_status_for_workspaces( + pool: &SqlitePool, + archived: bool, + ) -> Result, sqlx::Error> { + #[derive(FromRow)] + struct PrStatusRow { + workspace_id: Uuid, + pr_status: Option, + } + + // Get the latest PR for each workspace by using a subquery to find the max created_at + // Only consider PR merges (not direct merges) + let rows = sqlx::query_as::<_, PrStatusRow>( + r#"SELECT + m.workspace_id, + m.pr_status + FROM merges m + INNER JOIN ( + SELECT workspace_id, MAX(created_at) as max_created_at + FROM merges + WHERE merge_type = 'pr' + GROUP BY workspace_id + ) latest ON m.workspace_id = latest.workspace_id + AND m.created_at = latest.max_created_at + INNER JOIN workspaces w ON m.workspace_id = w.id + WHERE m.merge_type = 'pr' AND w.archived = $1"#, + ) + .bind(archived) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .filter_map(|row| row.pr_status.map(|status| (row.workspace_id, status))) + .collect()) + } } // Conversion implementations diff --git a/crates/server/src/routes/task_attempts/workspace_summary.rs b/crates/server/src/routes/task_attempts/workspace_summary.rs index e4e1e8f9..18e1e87d 100644 --- a/crates/server/src/routes/task_attempts/workspace_summary.rs +++ b/crates/server/src/routes/task_attempts/workspace_summary.rs @@ -4,6 +4,7 @@ use axum::{Json, extract::State, response::Json as ResponseJson}; use db::models::{ coding_agent_turn::CodingAgentTurn, execution_process::{ExecutionProcess, ExecutionProcessStatus}, + merge::{Merge, MergeStatus}, workspace::Workspace, workspace_repo::WorkspaceRepo, }; @@ -45,6 +46,8 @@ pub struct WorkspaceSummary { pub has_running_dev_server: bool, /// Does this workspace have unseen coding agent turns? pub has_unseen_turns: bool, + /// PR status for this workspace (if any PR exists) + pub pr_status: Option, } /// Response containing summaries for requested workspaces @@ -103,7 +106,10 @@ pub async fn get_workspace_summaries( // 5. Check which workspaces have unseen coding agent turns let unseen_workspaces = CodingAgentTurn::find_workspaces_with_unseen(pool, archived).await?; - // 6. Compute diff stats for each workspace (in parallel) + // 6. Get PR status for each workspace + let pr_statuses = Merge::get_latest_pr_status_for_workspaces(pool, archived).await?; + + // 7. Compute diff stats for each workspace (in parallel) let diff_futures: Vec<_> = workspaces .iter() .map(|ws| { @@ -126,7 +132,7 @@ pub async fn get_workspace_summaries( futures_util::future::join_all(diff_futures).await; let diff_stats: HashMap = diff_results.into_iter().flatten().collect(); - // 7. Assemble response + // 8. Assemble response let summaries: Vec = workspaces .iter() .map(|ws| { @@ -148,6 +154,7 @@ pub async fn get_workspace_summaries( latest_process_status: latest.map(|p| p.status.clone()), has_running_dev_server: dev_server_workspaces.contains(&id), has_unseen_turns: unseen_workspaces.contains(&id), + pr_status: pr_statuses.get(&id).cloned(), } }) .collect(); diff --git a/frontend/src/components/ui-new/hooks/useWorkspaces.ts b/frontend/src/components/ui-new/hooks/useWorkspaces.ts index a2c1e77a..ae9673a1 100644 --- a/frontend/src/components/ui-new/hooks/useWorkspaces.ts +++ b/frontend/src/components/ui-new/hooks/useWorkspaces.ts @@ -25,6 +25,7 @@ export interface SidebarWorkspace { hasUnseenActivity?: boolean; latestProcessCompletedAt?: string; latestProcessStatus?: 'running' | 'completed' | 'failed' | 'killed'; + prStatus?: 'open' | 'merged' | 'closed' | 'unknown'; } // Keep the old export name for backwards compatibility @@ -67,6 +68,7 @@ function toSidebarWorkspace( hasUnseenActivity: summary?.has_unseen_turns, latestProcessCompletedAt: summary?.latest_process_completed_at ?? undefined, latestProcessStatus: summary?.latest_process_status ?? undefined, + prStatus: summary?.pr_status ?? undefined, }; } diff --git a/frontend/src/components/ui-new/primitives/WorkspaceSummary.tsx b/frontend/src/components/ui-new/primitives/WorkspaceSummary.tsx index 50b61753..f8ba8b51 100644 --- a/frontend/src/components/ui-new/primitives/WorkspaceSummary.tsx +++ b/frontend/src/components/ui-new/primitives/WorkspaceSummary.tsx @@ -6,6 +6,7 @@ import { PlayIcon, FileIcon, CircleIcon, + GitPullRequestIcon, } from '@phosphor-icons/react'; import { useTranslation } from 'react-i18next'; import { cn } from '@/lib/utils'; @@ -27,6 +28,7 @@ interface WorkspaceSummaryProps { hasUnseenActivity?: boolean; latestProcessCompletedAt?: string; latestProcessStatus?: 'running' | 'completed' | 'failed' | 'killed'; + prStatus?: 'open' | 'merged' | 'closed' | 'unknown'; onClick?: () => void; className?: string; summary?: boolean; @@ -48,6 +50,7 @@ export function WorkspaceSummary({ hasUnseenActivity = false, latestProcessCompletedAt, latestProcessStatus, + prStatus, onClick, className, summary = false, @@ -120,6 +123,20 @@ export function WorkspaceSummary({ /> )} + {/* PR status icon */} + {prStatus === 'open' && ( + + )} + {prStatus === 'merged' && ( + + )} + {/* Pin icon */} {isPinned && ( onSelectWorkspace(workspace.id)} /> ))} @@ -124,6 +125,7 @@ export function WorkspacesSidebar({ hasUnseenActivity={workspace.hasUnseenActivity} latestProcessCompletedAt={workspace.latestProcessCompletedAt} latestProcessStatus={workspace.latestProcessStatus} + prStatus={workspace.prStatus} onClick={() => onSelectWorkspace(workspace.id)} /> ))} diff --git a/shared/types.ts b/shared/types.ts index e21fb778..2334887c 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -352,7 +352,11 @@ has_running_dev_server: boolean, /** * Does this workspace have unseen coding agent turns? */ -has_unseen_turns: boolean, }; +has_unseen_turns: boolean, +/** + * PR status for this workspace (if any PR exists) + */ +pr_status: MergeStatus | null, }; export type WorkspaceSummaryResponse = { summaries: Array, };