Skip to content

Commit a030338

Browse files
feat(seer-explorer): add hook to open explorer (#104540)
Adds a re-usable hook to open the Seer Explorer panel, supporting options to start a new run or open an existing run ID, and also to send an initial message. Update the dynamic grouping explorer button to use this hook instead of requiring copy paste Closes AIML-1966 --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent 47e1580 commit a030338

File tree

6 files changed

+314
-42
lines changed

6 files changed

+314
-42
lines changed

static/app/views/issueList/pages/dynamicGrouping.tsx

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {Fragment, useCallback, useMemo, useState} from 'react';
1+
import {Fragment, useCallback, useMemo, useRef, useState} from 'react';
22
import styled from '@emotion/styled';
33

44
import {Tag} from '@sentry/scraps/badge';
@@ -52,6 +52,7 @@ import useOrganization from 'sentry/utils/useOrganization';
5252
import usePageFilters from 'sentry/utils/usePageFilters';
5353
import {useUser} from 'sentry/utils/useUser';
5454
import {useUserTeams} from 'sentry/utils/useUserTeams';
55+
import {openSeerExplorer} from 'sentry/views/seerExplorer/openSeerExplorer';
5556

5657
const CLUSTERS_PER_PAGE = 20;
5758

@@ -129,29 +130,6 @@ function formatClusterPromptForSeer(cluster: ClusterSummary): string {
129130
return `I'd like to investigate this cluster of issues:\n\n${message}\n\nPlease help me understand the root cause and potential fixes for these related issues.`;
130131
}
131132

132-
/**
133-
* Opens Seer Explorer by simulating the Cmd+/ or Ctrl+/ keyboard shortcut.
134-
* User can then paste with Cmd+V / Ctrl+V.
135-
*/
136-
function openSeerExplorerWithClipboard(): void {
137-
// Simulate keyboard shortcut to open Seer Explorer (Cmd+/ or Ctrl+/)
138-
const isMac = navigator.platform.toUpperCase().includes('MAC');
139-
140-
// Create a KeyboardEvent with the proper keyCode (191 = '/')
141-
// useHotkeys checks evt.keyCode, so we need to set it explicitly
142-
const event = new KeyboardEvent('keydown', {
143-
key: '/',
144-
code: 'Slash',
145-
keyCode: 191,
146-
which: 191,
147-
metaKey: isMac,
148-
ctrlKey: !isMac,
149-
bubbles: true,
150-
} as KeyboardEventInit);
151-
152-
document.dispatchEvent(event);
153-
}
154-
155133
interface TopIssuesResponse {
156134
data: ClusterSummary[];
157135
last_updated?: string;
@@ -373,14 +351,24 @@ function ClusterCard({
373351
const clusterStats = useClusterStats(cluster.group_ids);
374352
const {copy} = useCopyToClipboard();
375353

376-
const handleSendToSeer = () => {
377-
copy(formatClusterPromptForSeer(cluster), {
378-
successMessage: t('Copied to clipboard. Paste into Seer Explorer with Cmd+V'),
379-
});
380-
setTimeout(() => {
381-
openSeerExplorerWithClipboard();
382-
}, 100);
383-
};
354+
// Track the Seer Explorer run ID for this cluster so subsequent clicks reopen the same chat
355+
const seerRunIdRef = useRef<number | null>(null);
356+
357+
const handleSendToSeer = useCallback(() => {
358+
if (seerRunIdRef.current) {
359+
// Reopen existing chat
360+
openSeerExplorer({runId: seerRunIdRef.current});
361+
} else {
362+
// Start a new chat with the cluster prompt
363+
openSeerExplorer({
364+
startNewRun: true,
365+
initialMessage: formatClusterPromptForSeer(cluster),
366+
onRunCreated: runId => {
367+
seerRunIdRef.current = runId;
368+
},
369+
});
370+
}
371+
}, [cluster]);
384372

385373
const handleCopyMarkdown = () => {
386374
copy(formatClusterInfoForClipboard(cluster));

static/app/views/seerExplorer/emptyState.tsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
11
import styled from '@emotion/styled';
22

3+
import LoadingIndicator from 'sentry/components/loadingIndicator';
34
import {IconSeer} from 'sentry/icons';
5+
import {t} from 'sentry/locale';
46
import {space} from 'sentry/styles/space';
57

6-
function EmptyState() {
8+
interface EmptyStateProps {
9+
isLoading?: boolean;
10+
}
11+
12+
function EmptyState({isLoading}: EmptyStateProps) {
713
return (
814
<Container>
9-
<IconSeer size="xl" variant="waiting" />
10-
<Text>Ask Seer anything about your application.</Text>
15+
{isLoading ? (
16+
<LoadingIndicator size={32} />
17+
) : (
18+
<IconSeer size="xl" variant="waiting" />
19+
)}
20+
<Text>{!isLoading && t('Ask Seer anything about your application.')}</Text>
1121
</Container>
1222
);
1323
}

static/app/views/seerExplorer/explorerPanel.spec.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,7 @@ describe('ExplorerPanel', () => {
147147
runId: null,
148148
setRunId: jest.fn(),
149149
respondToUserInput: jest.fn(),
150+
switchToRun: jest.fn(),
150151
createPR: jest.fn(),
151152
});
152153

static/app/views/seerExplorer/explorerPanel.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {usePanelSizing} from 'sentry/views/seerExplorer/hooks/usePanelSizing';
1313
import {usePendingUserInput} from 'sentry/views/seerExplorer/hooks/usePendingUserInput';
1414
import {useSeerExplorer} from 'sentry/views/seerExplorer/hooks/useSeerExplorer';
1515
import InputSection from 'sentry/views/seerExplorer/inputSection';
16+
import {useExternalOpen} from 'sentry/views/seerExplorer/openSeerExplorer';
1617
import PanelContainers, {
1718
BlocksContainer,
1819
} from 'sentry/views/seerExplorer/panelContainers';
@@ -48,11 +49,21 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) {
4849
isPolling,
4950
interruptRun,
5051
interruptRequested,
51-
setRunId,
52+
switchToRun,
5253
respondToUserInput,
5354
createPR,
5455
} = useSeerExplorer();
5556

57+
// Handle external open events (from openSeerExplorer() calls)
58+
const {isWaitingForSessionData} = useExternalOpen({
59+
isVisible,
60+
sendMessage,
61+
startNewSession,
62+
switchToRun,
63+
sessionRunId: sessionData?.run_id,
64+
sessionBlocks: sessionData?.blocks,
65+
});
66+
5667
// Extract repo_pr_states from session
5768
const repoPRStates = useMemo(
5869
() => sessionData?.repo_pr_states ?? {},
@@ -237,7 +248,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) {
237248
onMedSize: handleMedSize,
238249
onNew: startNewSession,
239250
},
240-
onChangeSession: setRunId,
251+
onChangeSession: switchToRun,
241252
menuAnchorRef: sessionHistoryButtonRef,
242253
inputAnchorRef: textareaRef,
243254
prWidgetAnchorRef: prWidgetButtonRef,
@@ -464,7 +475,7 @@ function ExplorerPanel({isVisible = false}: ExplorerPanelProps) {
464475
{menu}
465476
<BlocksContainer ref={scrollContainerRef} onClick={handlePanelBackgroundClick}>
466477
{isEmptyState ? (
467-
<EmptyState />
478+
<EmptyState isLoading={isWaitingForSessionData} />
468479
) : (
469480
<Fragment>
470481
{blocks.map((block: Block, index: number) => (

static/app/views/seerExplorer/hooks/useSeerExplorer.tsx

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,11 +143,14 @@ export const useSeerExplorer = () => {
143143
);
144144

145145
const sendMessage = useCallback(
146-
async (query: string, insertIndex?: number) => {
146+
async (query: string, insertIndex?: number, explicitRunId?: number | null) => {
147147
if (!orgSlug) {
148148
return;
149149
}
150150

151+
// explicitRunId: undefined = use current runId, null = force new run, number = use that run
152+
const effectiveRunId = explicitRunId === undefined ? runId : explicitRunId;
153+
151154
// Capture a coarse ASCII screenshot of the user's screen for extra context
152155
const screenshot = captureAsciiSnapshot?.();
153156

@@ -196,7 +199,7 @@ export const useSeerExplorer = () => {
196199

197200
try {
198201
const response = (await api.requestPromise(
199-
`/organizations/${orgSlug}/seer/explorer-chat/${runId ? `${runId}/` : ''}`,
202+
`/organizations/${orgSlug}/seer/explorer-chat/${effectiveRunId ? `${effectiveRunId}/` : ''}`,
200203
{
201204
method: 'POST',
202205
data: {
@@ -208,7 +211,7 @@ export const useSeerExplorer = () => {
208211
)) as SeerExplorerChatResponse;
209212

210213
// Set run ID if this is a new session
211-
if (!runId) {
214+
if (!effectiveRunId) {
212215
setRunId(response.run_id);
213216
}
214217

@@ -221,7 +224,7 @@ export const useSeerExplorer = () => {
221224
setOptimistic(null);
222225
setApiQueryData<SeerExplorerResponse>(
223226
queryClient,
224-
makeSeerExplorerQueryKey(orgSlug, runId || undefined),
227+
makeSeerExplorerQueryKey(orgSlug, effectiveRunId || undefined),
225228
makeErrorSeerExplorerData(e?.responseJSON?.detail ?? 'An error occurred')
226229
);
227230
}
@@ -480,13 +483,37 @@ export const useSeerExplorer = () => {
480483
interruptRequested,
481484
]);
482485

486+
/** Switches to a different run and fetches its latest state. */
487+
const switchToRun = useCallback(
488+
(newRunId: number) => {
489+
// Clear any optimistic state from previous run
490+
setOptimistic(null);
491+
setDeletedFromIndex(null);
492+
setWaitingForResponse(false);
493+
setInterruptRequested(false);
494+
495+
// Set the new run ID
496+
setRunId(newRunId);
497+
498+
// Invalidate the query to force a fresh fetch
499+
if (orgSlug) {
500+
queryClient.invalidateQueries({
501+
queryKey: makeSeerExplorerQueryKey(orgSlug, newRunId),
502+
});
503+
}
504+
},
505+
[orgSlug, queryClient, setRunId]
506+
);
507+
483508
return {
484509
sessionData: filteredSessionData,
485510
isPolling: isPolling(filteredSessionData, waitingForResponse),
486511
isPending,
487512
sendMessage,
488513
runId,
489514
setRunId,
515+
/** Switches to a different run and fetches its latest state. */
516+
switchToRun,
490517
/** Resets the run id, blocks, and other state. The new session isn't actually created until the user sends a message. */
491518
startNewSession,
492519
deleteFromIndex,

0 commit comments

Comments
 (0)