From ec03f403aa5067adbc4085a2489416d10cacabd2 Mon Sep 17 00:00:00 2001 From: Rohan Agarwal Date: Sat, 6 Dec 2025 12:10:36 -0800 Subject: [PATCH 1/5] feat(explorer): add UI for creating PRs --- .../app/views/seerExplorer/explorerMenu.tsx | 154 ++++---- .../app/views/seerExplorer/explorerPanel.tsx | 63 +++- .../seerExplorer/hooks/useSeerExplorer.tsx | 47 ++- static/app/views/seerExplorer/prWidget.tsx | 336 ++++++++++++++++++ static/app/views/seerExplorer/topBar.tsx | 55 ++- static/app/views/seerExplorer/types.tsx | 21 ++ 6 files changed, 579 insertions(+), 97 deletions(-) create mode 100644 static/app/views/seerExplorer/prWidget.tsx diff --git a/static/app/views/seerExplorer/explorerMenu.tsx b/static/app/views/seerExplorer/explorerMenu.tsx index 6e8d9be6730697..264df04fe7846a 100644 --- a/static/app/views/seerExplorer/explorerMenu.tsx +++ b/static/app/views/seerExplorer/explorerMenu.tsx @@ -1,4 +1,4 @@ -import {Activity, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import styled from '@emotion/styled'; import moment from 'moment-timezone'; @@ -6,7 +6,7 @@ import TimeSince from 'sentry/components/timeSince'; import {useFeedbackForm} from 'sentry/utils/useFeedbackForm'; import {useExplorerSessions} from 'sentry/views/seerExplorer/hooks/useExplorerSessions'; -type MenuMode = 'slash-commands-keyboard' | 'session-history' | 'hidden'; +type MenuMode = 'slash-commands-keyboard' | 'session-history' | 'pr-widget' | 'hidden'; interface ExplorerMenuProps { clearInput: () => void; @@ -23,9 +23,12 @@ interface ExplorerMenuProps { textAreaRef: React.RefObject; inputAnchorRef?: React.RefObject; menuAnchorRef?: React.RefObject; + prWidgetAnchorRef?: React.RefObject; + prWidgetFooter?: React.ReactNode; + prWidgetItems?: MenuItemProps[]; } -interface MenuItemProps { +export interface MenuItemProps { description: string | React.ReactNode; handler: () => void; key: string; @@ -43,6 +46,9 @@ export function useExplorerMenu({ onChangeSession, menuAnchorRef, inputAnchorRef, + prWidgetAnchorRef, + prWidgetItems, + prWidgetFooter, }: ExplorerMenuProps) { const [menuMode, setMenuMode] = useState('hidden'); const [menuPosition, setMenuPosition] = useState<{ @@ -74,10 +80,12 @@ export function useExplorerMenu({ return filteredSlashCommands; case 'session-history': return sessionItems; + case 'pr-widget': + return prWidgetItems ?? []; default: return []; } - }, [menuMode, filteredSlashCommands, sessionItems]); + }, [menuMode, filteredSlashCommands, sessionItems, prWidgetItems]); const close = useCallback(() => { setMenuMode('hidden'); @@ -210,7 +218,11 @@ export function useExplorerMenu({ } const anchorRef = - menuMode === 'slash-commands-keyboard' ? inputAnchorRef : menuAnchorRef; + menuMode === 'slash-commands-keyboard' + ? inputAnchorRef + : menuMode === 'pr-widget' + ? prWidgetAnchorRef + : menuAnchorRef; const isSlashCommand = menuMode === 'slash-commands-keyboard'; if (!anchorRef?.current) { @@ -233,50 +245,56 @@ export function useExplorerMenu({ const spacing = 8; const relativeTop = rect.top - panelRect.top; const relativeLeft = rect.left - panelRect.left; + const relativeRight = panelRect.right - rect.right; - setMenuPosition( - isSlashCommand - ? { - bottom: `${panelRect.height - relativeTop + spacing}px`, - left: `${relativeLeft}px`, - } - : { - top: `${relativeTop + rect.height + spacing}px`, - left: `${relativeLeft}px`, - } - ); - }, [isVisible, menuMode, menuAnchorRef, inputAnchorRef]); - - const menu = ( - - - {menuItems.map((item, index) => ( - { - menuItemRefs.current[index] = el; - }} - isSelected={index === selectedIndex} - onClick={() => onSelect(item)} - > - {item.title} - {item.description} - - ))} - {menuMode === 'session-history' && menuItems.length === 0 && ( - - - {isSessionsPending - ? 'Loading sessions...' - : isSessionsError - ? 'Error loading sessions.' - : 'No session history found.'} - - - )} - - - ); + if (isSlashCommand) { + setMenuPosition({ + bottom: `${panelRect.height - relativeTop + spacing}px`, + left: `${relativeLeft}px`, + }); + } else if (menuMode === 'pr-widget') { + // Position below anchor, aligned to right edge + setMenuPosition({ + top: `${relativeTop + rect.height + spacing}px`, + right: `${relativeRight}px`, + }); + } else { + setMenuPosition({ + top: `${relativeTop + rect.height + spacing}px`, + left: `${relativeLeft}px`, + }); + } + }, [isVisible, menuMode, menuAnchorRef, inputAnchorRef, prWidgetAnchorRef]); + + const menu = isVisible ? ( + + {menuItems.map((item: MenuItemProps, index: number) => ( + { + menuItemRefs.current[index] = el; + }} + isSelected={index === selectedIndex} + onClick={() => onSelect(item)} + > + {item.title} + {item.description} + + ))} + {menuMode === 'session-history' && menuItems.length === 0 && ( + + + {isSessionsPending + ? 'Loading sessions...' + : isSessionsError + ? 'Error loading sessions.' + : 'No session history found.'} + + + )} + {menuMode === 'pr-widget' && prWidgetFooter} + + ) : null; // Handler for opening session history from button const openSessionHistory = useCallback(() => { @@ -288,12 +306,22 @@ export function useExplorerMenu({ } }, [menuMode, close, refetchSessions]); + // Handler for opening PR widget from button + const openPRWidget = useCallback(() => { + if (menuMode === 'pr-widget') { + close(); + } else { + setMenuMode('pr-widget'); + } + }, [menuMode, close]); + return { menu, menuMode, isMenuOpen: menuMode !== 'hidden', closeMenu: close, openSessionHistory, + openPRWidget, }; } @@ -370,20 +398,22 @@ function useSessions({ return []; } - return data.data.map(session => ({ - title: session.title, - key: session.run_id.toString(), - description: ( - - ), - handler: () => { - onChangeSession(session.run_id); - }, - })); + return data.data.map( + (session: {last_triggered_at: moment.MomentInput; run_id: number; title: any}) => ({ + title: session.title, + key: session.run_id.toString(), + description: ( + + ), + handler: () => { + onChangeSession(session.run_id); + }, + }) + ); }, [data, isPending, isError, onChangeSession]); return { diff --git a/static/app/views/seerExplorer/explorerPanel.tsx b/static/app/views/seerExplorer/explorerPanel.tsx index d0878ea7a73f89..cd1477fc8cd9a0 100644 --- a/static/app/views/seerExplorer/explorerPanel.tsx +++ b/static/app/views/seerExplorer/explorerPanel.tsx @@ -16,6 +16,7 @@ import InputSection from 'sentry/views/seerExplorer/inputSection'; import PanelContainers, { BlocksContainer, } from 'sentry/views/seerExplorer/panelContainers'; +import {usePRWidgetData} from 'sentry/views/seerExplorer/prWidget'; import TopBar from 'sentry/views/seerExplorer/topBar'; import type {Block, ExplorerPanelProps} from 'sentry/views/seerExplorer/types'; @@ -36,8 +37,8 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { const userScrolledUpRef = useRef(false); const allowHoverFocusChange = useRef(true); const sessionHistoryButtonRef = useRef(null); + const prWidgetButtonRef = useRef(null); - // Custom hooks const {panelSize, handleMaxSize, handleMedSize} = usePanelSizing(); const { sessionData, @@ -49,11 +50,25 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { interruptRequested, setRunId, respondToUserInput, + createPR, } = useSeerExplorer(); + // Extract repo_pr_states from session + const repoPRStates = useMemo( + () => sessionData?.repo_pr_states ?? {}, + [sessionData?.repo_pr_states] + ); + // Get blocks from session data or empty array const blocks = useMemo(() => sessionData?.blocks || [], [sessionData]); + // Get PR widget data for menu + const {menuItems: prWidgetItems, menuFooter: prWidgetFooter} = usePRWidgetData({ + blocks, + repoPRStates, + onCreatePR: createPR, + }); + // Find the index of the last block that has todos (for special rendering) const latestTodoBlockIndex = useMemo(() => { for (let i = blocks.length - 1; i >= 0; i--) { @@ -209,22 +224,26 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { const openFeedbackForm = useFeedbackForm(); - const {menu, isMenuOpen, menuMode, closeMenu, openSessionHistory} = useExplorerMenu({ - clearInput: () => setInputValue(''), - inputValue, - focusInput, - textAreaRef: textareaRef, - panelSize, - panelVisible: isVisible, - slashCommandHandlers: { - onMaxSize: handleMaxSize, - onMedSize: handleMedSize, - onNew: startNewSession, - }, - onChangeSession: setRunId, - menuAnchorRef: sessionHistoryButtonRef, - inputAnchorRef: textareaRef, - }); + const {menu, isMenuOpen, menuMode, closeMenu, openSessionHistory, openPRWidget} = + useExplorerMenu({ + clearInput: () => setInputValue(''), + inputValue, + focusInput, + textAreaRef: textareaRef, + panelSize, + panelVisible: isVisible, + slashCommandHandlers: { + onMaxSize: handleMaxSize, + onMedSize: handleMedSize, + onNew: startNewSession, + }, + onChangeSession: setRunId, + menuAnchorRef: sessionHistoryButtonRef, + inputAnchorRef: textareaRef, + prWidgetAnchorRef: prWidgetButtonRef, + prWidgetItems, + prWidgetFooter, + }); const handlePanelBackgroundClick = useCallback(() => { setIsMinimized(false); @@ -241,10 +260,11 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { const target = event.target as Node; const menuElement = document.querySelector('[data-seer-menu-panel]'); - // Don't close if clicking on the menu itself or the button + // Don't close if clicking on the menu itself or the trigger buttons if ( menuElement?.contains(target) || - sessionHistoryButtonRef.current?.contains(target) + sessionHistoryButtonRef.current?.contains(target) || + prWidgetButtonRef.current?.contains(target) ) { return; } @@ -426,14 +446,19 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) { onUnminimize={handleUnminimize} > {menu} diff --git a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx index a64b644b48b76d..7d5239dc7700ed 100644 --- a/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx +++ b/static/app/views/seerExplorer/hooks/useSeerExplorer.tsx @@ -12,7 +12,7 @@ import useApi from 'sentry/utils/useApi'; import useOrganization from 'sentry/utils/useOrganization'; import {useSessionStorage} from 'sentry/utils/useSessionStorage'; import useAsciiSnapshot from 'sentry/views/seerExplorer/hooks/useAsciiSnapshot'; -import type {Block} from 'sentry/views/seerExplorer/types'; +import type {Block, RepoPRState} from 'sentry/views/seerExplorer/types'; export type PendingUserInput = { data: Record; @@ -26,6 +26,7 @@ export type SeerExplorerResponse = { status: 'processing' | 'completed' | 'error' | 'awaiting_user_input'; updated_at: string; pending_user_input?: PendingUserInput | null; + repo_pr_states?: Record; run_id?: number; } | null; }; @@ -87,11 +88,17 @@ const isPolling = (sessionData: SeerExplorerResponse['session'], runStarted: boo return false; } + // Check if any PR is being created + const anyPRCreating = Object.values(sessionData?.repo_pr_states ?? {}).some( + state => state.pr_creation_status === 'creating' + ); + return ( !sessionData || runStarted || sessionData.status === 'processing' || - sessionData.blocks.some(message => message.loading) + sessionData.blocks.some(message => message.loading) || + anyPRCreating ); }; @@ -297,6 +304,41 @@ export const useSeerExplorer = () => { [api, orgSlug, runId, queryClient] ); + const createPR = useCallback( + async (repoName?: string) => { + if (!orgSlug || !runId) { + return; + } + + try { + await api.requestPromise( + `/organizations/${orgSlug}/seer/explorer-update/${runId}/`, + { + method: 'POST', + data: { + payload: { + type: 'create_pr', + repo_name: repoName, + }, + }, + } + ); + + // Invalidate queries to trigger polling for status updates + queryClient.invalidateQueries({ + queryKey: makeSeerExplorerQueryKey(orgSlug, runId), + }); + } catch (e: any) { + setApiQueryData( + queryClient, + makeSeerExplorerQueryKey(orgSlug, runId), + makeErrorSeerExplorerData(e?.responseJSON?.detail ?? 'Failed to create PR') + ); + } + }, + [api, orgSlug, runId, queryClient] + ); + // Always filter messages based on optimistic state and deletedFromIndex before any other processing const sessionData = apiData?.session ?? null; @@ -432,5 +474,6 @@ export const useSeerExplorer = () => { interruptRun, interruptRequested, respondToUserInput, + createPR, }; }; diff --git a/static/app/views/seerExplorer/prWidget.tsx b/static/app/views/seerExplorer/prWidget.tsx new file mode 100644 index 00000000000000..135a37407c1be2 --- /dev/null +++ b/static/app/views/seerExplorer/prWidget.tsx @@ -0,0 +1,336 @@ +import {useMemo} from 'react'; +import type React from 'react'; +import styled from '@emotion/styled'; + +import {Button} from '@sentry/scraps/button'; +import {Flex} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; + +import LoadingIndicator from 'sentry/components/loadingIndicator'; +import {IconCheckmark, IconOpen, IconUpload} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import type {MenuItemProps} from 'sentry/views/seerExplorer/explorerMenu'; +import type {Block, RepoPRState} from 'sentry/views/seerExplorer/types'; + +interface PRWidgetProps { + blocks: Block[]; + onCreatePR: (repoName?: string) => void; + onToggleMenu: () => void; + repoPRStates: Record; + ref?: React.Ref; +} + +interface RepoStats { + added: number; + removed: number; +} + +interface FileStats { + added: number; + path: string; + removed: number; +} + +interface RepoSyncStatus { + hasPR: boolean; + isOutOfSync: boolean; +} + +export function usePRWidgetData({ + blocks, + repoPRStates, + onCreatePR, +}: { + blocks: Block[]; + onCreatePR: (repoName?: string) => void; + repoPRStates: Record; +}) { + // Compute aggregated stats from all blocks + const {totalAdded, totalRemoved, repoStats, repoFileStats} = useMemo(() => { + const stats: Record = {}; + const fileStats: Record = {}; + let added = 0; + let removed = 0; + + for (const block of blocks) { + if (!block.file_patches) { + continue; + } + for (const filePatch of block.file_patches) { + added += filePatch.patch.added; + removed += filePatch.patch.removed; + if (!stats[filePatch.repo_name]) { + stats[filePatch.repo_name] = {added: 0, removed: 0}; + } + const repoStat = stats[filePatch.repo_name]; + if (repoStat) { + repoStat.added += filePatch.patch.added; + repoStat.removed += filePatch.patch.removed; + } + + // Track file-level stats + if (!fileStats[filePatch.repo_name]) { + fileStats[filePatch.repo_name] = []; + } + const repoFiles = fileStats[filePatch.repo_name]; + if (repoFiles) { + const existingFile = repoFiles.find(f => f.path === filePatch.patch.path); + if (existingFile) { + existingFile.added += filePatch.patch.added; + existingFile.removed += filePatch.patch.removed; + } else { + repoFiles.push({ + added: filePatch.patch.added, + path: filePatch.patch.path, + removed: filePatch.patch.removed, + }); + } + } + } + } + + return { + totalAdded: added, + totalRemoved: removed, + repoStats: stats, + repoFileStats: fileStats, + }; + }, [blocks]); + + // Compute sync status per repo + const repoSyncStatus = useMemo(() => { + const status: Record = {}; + + for (const repoName of Object.keys(repoStats)) { + const prState = repoPRStates[repoName]; + const hasPR = !!(prState?.pr_number && prState?.pr_url); + let isOutOfSync = !hasPR; // No PR means out of sync + + if (hasPR && prState?.commit_sha) { + // Find last block with patches for this repo + for (let i = blocks.length - 1; i >= 0; i--) { + const block = blocks[i]; + if (block?.file_patches?.some(p => p.repo_name === repoName)) { + const blockSha = block.pr_commit_shas?.[repoName]; + isOutOfSync = blockSha !== prState.commit_sha; + break; + } + } + } + + status[repoName] = {hasPR, isOutOfSync}; + } + + return status; + }, [blocks, repoPRStates, repoStats]); + + const repoNames = Object.keys(repoStats); + + // Compute overall sync status + const {allInSync, anyCreating} = useMemo(() => { + let inSync = true; + let creating = false; + + for (const repoName of repoNames) { + const prState = repoPRStates[repoName]; + const syncStatus = repoSyncStatus[repoName]; + + if (prState?.pr_creation_status === 'creating') { + creating = true; + } + if (syncStatus?.isOutOfSync) { + inSync = false; + } + } + + return { + allInSync: inSync && !creating, + anyCreating: creating, + }; + }, [repoNames, repoPRStates, repoSyncStatus]); + + const hasCodeChanges = totalAdded > 0 || totalRemoved > 0; + + // Build menu items for explorer menu + const menuItems: MenuItemProps[] = useMemo(() => { + return repoNames.map(repoName => { + const files = repoFileStats[repoName] || []; + const prState = repoPRStates[repoName]; + const syncStatus = repoSyncStatus[repoName]; + const isCreating = prState?.pr_creation_status === 'creating'; + + return { + key: repoName, + title: repoName, + description: ( + + + {files.map((file, idx) => ( + + + + +{file.added} + + + -{file.removed} + + + + {file.path} + + + ))} + + + {isCreating ? ( + + {t('Pushing...')} + + ) : syncStatus?.hasPR ? ( + + {syncStatus.isOutOfSync ? ( + + {t('Not pushed')} + + ) : ( + + {t('Pushed')} + + )} + e.stopPropagation()} + > + #{prState?.pr_number} + + + ) : ( + + {t('No PR yet')} + + )} + + + ), + handler: () => { + // If repo has a PR, open it + if (syncStatus?.hasPR && prState?.pr_url) { + window.open(prState.pr_url, '_blank'); + } + }, + }; + }); + }, [repoNames, repoFileStats, repoPRStates, repoSyncStatus]); + + // Build footer for explorer menu + const menuFooter = useMemo(() => { + const handleCreateAllPRs = () => { + for (const repoName of repoNames) { + const syncStatus = repoSyncStatus[repoName]; + if (syncStatus?.isOutOfSync) { + onCreatePR(repoName); + } + } + }; + + return ( + + {anyCreating ? ( + + ) : allInSync ? ( + + + + {t('All changes pushed')} + + + ) : ( + + )} + + ); + }, [anyCreating, allInSync, repoNames, repoSyncStatus, onCreatePR]); + + return { + hasCodeChanges, + totalAdded, + totalRemoved, + allInSync, + anyCreating, + menuItems, + menuFooter, + }; +} + +function PRWidget({blocks, repoPRStates, onCreatePR, onToggleMenu, ref}: PRWidgetProps) { + const {hasCodeChanges, totalAdded, totalRemoved, allInSync, anyCreating} = + usePRWidgetData({ + blocks, + repoPRStates, + onCreatePR, + }); + + if (!hasCodeChanges) { + return null; + } + + return ( + + ); +} + +export default PRWidget; + +const PRLink = styled('a')` + display: flex; + align-items: center; + gap: ${p => p.theme.space.xs}; + color: ${p => p.theme.linkColor}; + font-size: ${p => p.theme.fontSize.sm}; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +`; diff --git a/static/app/views/seerExplorer/topBar.tsx b/static/app/views/seerExplorer/topBar.tsx index 3aa044330b2781..e25cf30425d2b5 100644 --- a/static/app/views/seerExplorer/topBar.tsx +++ b/static/app/views/seerExplorer/topBar.tsx @@ -1,3 +1,4 @@ +import {useMemo} from 'react'; import styled from '@emotion/styled'; import {AnimatePresence, motion} from 'framer-motion'; @@ -13,32 +14,57 @@ import { IconTimer, } from 'sentry/icons'; import {t} from 'sentry/locale'; +import PRWidget from 'sentry/views/seerExplorer/prWidget'; +import type {Block, RepoPRState} from 'sentry/views/seerExplorer/types'; interface TopBarProps { + blocks: Block[]; isEmptyState: boolean; isPolling: boolean; isSessionHistoryOpen: boolean; + onCreatePR: (repoName?: string) => void; onFeedbackClick: () => void; onNewChatClick: () => void; + onPRWidgetClick: () => void; onSessionHistoryClick: (buttonRef: React.RefObject) => void; onSizeToggleClick: () => void; panelSize: 'max' | 'med'; + prWidgetButtonRef: React.RefObject; + repoPRStates: Record; sessionHistoryButtonRef: React.RefObject; } function TopBar({ + blocks, isPolling, isEmptyState, isSessionHistoryOpen, + onCreatePR, onFeedbackClick, onNewChatClick, + onPRWidgetClick, onSessionHistoryClick, onSizeToggleClick, panelSize, + prWidgetButtonRef, + repoPRStates, sessionHistoryButtonRef, }: TopBarProps) { + // Check if there are any file patches + const hasCodeChanges = useMemo(() => { + return blocks.some(b => b.file_patches && b.file_patches.length > 0); + }, [blocks]); + return ( - +