Skip to content

Commit ec32992

Browse files
feat(seer explorer): Initial UI (#95237)
Creates the initial UI for Seer Explorer, feature flagged internally for now for testing. https://github.com/user-attachments/assets/a8445317-1cb9-4ffc-84a0-4279b3a0c668 --------- Co-authored-by: getsantry[bot] <66042841+getsantry[bot]@users.noreply.github.com>
1 parent 2a9958a commit ec32992

16 files changed

+2311
-0
lines changed

static/app/views/app/index.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import {AsyncSDKIntegrationContextProvider} from 'sentry/views/app/asyncSDKInteg
3838
import LastKnownRouteContextProvider from 'sentry/views/lastKnownRouteContextProvider';
3939
import {OrganizationContextProvider} from 'sentry/views/organizationContext';
4040
import RouteAnalyticsContextProvider from 'sentry/views/routeAnalyticsContextProvider';
41+
import ExplorerPanel from 'sentry/views/seerExplorer/explorerPanel';
42+
import {useExplorerPanel} from 'sentry/views/seerExplorer/useExplorerPanel';
4143

4244
type Props = {
4345
children: React.ReactNode;
@@ -74,6 +76,24 @@ function App({children, params}: Props) {
7476

7577
useHotkeys(commandPaletteHotkeys);
7678

79+
// Seer explorer panel hook and hotkeys
80+
const {isOpen: isExplorerPanelOpen, toggleExplorerPanel} = useExplorerPanel();
81+
82+
const explorerPanelHotkeys = useMemo(() => {
83+
if (isModalOpen) {
84+
return [];
85+
}
86+
return [
87+
{
88+
match: ['command+/', 'ctrl+/'],
89+
callback: () => toggleExplorerPanel(),
90+
includeInputs: true,
91+
},
92+
];
93+
}, [isModalOpen, toggleExplorerPanel]);
94+
95+
useHotkeys(explorerPanelHotkeys);
96+
7797
/**
7898
* Loads the users organization list into the OrganizationsStore
7999
*/
@@ -259,6 +279,7 @@ function App({children, params}: Props) {
259279
<MainContainer tabIndex={-1} ref={mainContainerRef}>
260280
<DemoToursProvider>
261281
<GlobalModal onClose={handleModalClose} />
282+
<ExplorerPanel isVisible={isExplorerPanelOpen} />
262283
<Indicators className="indicators-container" />
263284
<ErrorBoundary>{renderBody()}</ErrorBoundary>
264285
</DemoToursProvider>
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
2+
import {textWithMarkupMatcher} from 'sentry-test/utils';
3+
4+
import BlockComponent from './blockComponents';
5+
import type {Block} from './types';
6+
7+
describe('BlockComponent', () => {
8+
const mockOnClick = jest.fn();
9+
10+
const createUserInputBlock = (overrides?: Partial<Block>): Block => ({
11+
id: 'user-1',
12+
message: {
13+
role: 'user',
14+
content: 'What is this error about?',
15+
},
16+
timestamp: '2024-01-01T00:00:00Z',
17+
loading: false,
18+
...overrides,
19+
});
20+
21+
const createResponseBlock = (overrides?: Partial<Block>): Block => ({
22+
id: 'response-1',
23+
message: {
24+
role: 'assistant',
25+
content: 'This error indicates a null pointer exception.',
26+
},
27+
timestamp: '2024-01-01T00:01:00Z',
28+
loading: false,
29+
...overrides,
30+
});
31+
32+
beforeEach(() => {
33+
mockOnClick.mockClear();
34+
});
35+
36+
describe('User Input Blocks', () => {
37+
it('renders user input block with correct content', () => {
38+
const block = createUserInputBlock();
39+
render(<BlockComponent block={block} onClick={mockOnClick} />);
40+
41+
expect(screen.getByText('What is this error about?')).toBeInTheDocument();
42+
});
43+
44+
it('calls onClick when user input block is clicked', async () => {
45+
const block = createUserInputBlock();
46+
render(<BlockComponent block={block} onClick={mockOnClick} />);
47+
48+
await userEvent.click(screen.getByText('What is this error about?'));
49+
expect(mockOnClick).toHaveBeenCalledTimes(1);
50+
});
51+
});
52+
53+
describe('Response Blocks', () => {
54+
it('renders response block with correct content', () => {
55+
const block = createResponseBlock();
56+
render(<BlockComponent block={block} onClick={mockOnClick} />);
57+
58+
expect(
59+
screen.getByText('This error indicates a null pointer exception.')
60+
).toBeInTheDocument();
61+
});
62+
63+
it('renders response block with loading state', () => {
64+
const block = createResponseBlock({
65+
loading: true,
66+
message: {
67+
role: 'assistant',
68+
content: 'Thinking...',
69+
},
70+
});
71+
render(<BlockComponent block={block} onClick={mockOnClick} />);
72+
73+
expect(screen.getByText('Thinking...')).toBeInTheDocument();
74+
});
75+
76+
it('calls onClick when response block is clicked', async () => {
77+
const block = createResponseBlock();
78+
render(<BlockComponent block={block} onClick={mockOnClick} />);
79+
80+
await userEvent.click(
81+
screen.getByText('This error indicates a null pointer exception.')
82+
);
83+
expect(mockOnClick).toHaveBeenCalledTimes(1);
84+
});
85+
});
86+
87+
describe('Focus State', () => {
88+
it('shows delete hint when isFocused=true', () => {
89+
const block = createUserInputBlock();
90+
render(<BlockComponent block={block} isFocused onClick={mockOnClick} />);
91+
92+
expect(
93+
screen.getByText(textWithMarkupMatcher('Rethink from here ⌫'))
94+
).toBeInTheDocument();
95+
});
96+
97+
it('does not show delete hint when isFocused=false', () => {
98+
const block = createUserInputBlock();
99+
render(<BlockComponent block={block} isFocused={false} onClick={mockOnClick} />);
100+
101+
expect(
102+
screen.queryByText(textWithMarkupMatcher('Rethink from here ⌫'))
103+
).not.toBeInTheDocument();
104+
});
105+
});
106+
107+
describe('Markdown Content', () => {
108+
it('renders markdown content in response blocks', () => {
109+
const block = createResponseBlock({
110+
message: {
111+
role: 'assistant',
112+
content:
113+
'# Heading\n\nThis is **bold** text with a [link](https://example.com)',
114+
},
115+
});
116+
render(<BlockComponent block={block} onClick={mockOnClick} />);
117+
118+
expect(screen.getByText('Heading')).toBeInTheDocument();
119+
expect(screen.getByText('bold')).toBeInTheDocument();
120+
expect(screen.getByText('link')).toBeInTheDocument();
121+
});
122+
});
123+
});
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import styled from '@emotion/styled';
2+
import {AnimatePresence, motion} from 'framer-motion';
3+
4+
import {IconChevron} from 'sentry/icons';
5+
import {space} from 'sentry/styles/space';
6+
import {MarkedText} from 'sentry/utils/marked/markedText';
7+
8+
import type {Block} from './types';
9+
10+
interface BlockProps {
11+
block: Block;
12+
isFocused?: boolean;
13+
isLast?: boolean;
14+
onClick?: () => void;
15+
ref?: React.Ref<HTMLDivElement>;
16+
}
17+
18+
function BlockComponent({block, isLast, isFocused, onClick, ref}: BlockProps) {
19+
return (
20+
<Block ref={ref} isLast={isLast} onClick={onClick}>
21+
<AnimatePresence>
22+
<motion.div
23+
initial={{opacity: 0, y: 10}}
24+
animate={{opacity: 1, y: 0}}
25+
exit={{opacity: 0, y: 10}}
26+
>
27+
{block.message.role === 'user' ? (
28+
<BlockRow>
29+
<BlockChevronIcon direction="right" size="sm" />
30+
<UserBlockContent>{block.message.content}</UserBlockContent>
31+
</BlockRow>
32+
) : (
33+
<BlockRow>
34+
<ResponseDot isLoading={block.loading} />
35+
<BlockContent text={block.message.content} />
36+
</BlockRow>
37+
)}
38+
{isFocused && <FocusIndicator />}
39+
{isFocused && <DeleteHint>Rethink from here ⌫</DeleteHint>}
40+
</motion.div>
41+
</AnimatePresence>
42+
</Block>
43+
);
44+
}
45+
46+
BlockComponent.displayName = 'BlockComponent';
47+
48+
export default BlockComponent;
49+
50+
const Block = styled('div')<{isLast?: boolean}>`
51+
width: 100%;
52+
border-bottom: ${p => (p.isLast ? 'none' : `1px solid ${p.theme.border}`)};
53+
min-height: 40px;
54+
position: relative;
55+
flex-shrink: 0; /* Prevent blocks from shrinking */
56+
cursor: pointer;
57+
`;
58+
59+
const BlockRow = styled('div')`
60+
display: flex;
61+
align-items: flex-start;
62+
width: 100%;
63+
`;
64+
65+
const BlockChevronIcon = styled(IconChevron)`
66+
color: ${p => p.theme.subText};
67+
margin-top: 18px;
68+
margin-left: ${space(2)};
69+
margin-right: ${space(1)};
70+
flex-shrink: 0;
71+
`;
72+
73+
const ResponseDot = styled('div')<{isLoading?: boolean}>`
74+
width: 8px;
75+
height: 8px;
76+
border-radius: 50%;
77+
margin-top: 22px;
78+
margin-left: ${space(2)};
79+
flex-shrink: 0;
80+
background: ${p => (p.isLoading ? p.theme.pink400 : p.theme.purple400)};
81+
82+
${p =>
83+
p.isLoading &&
84+
`
85+
animation: blink 1s infinite;
86+
87+
@keyframes blink {
88+
0%, 50% { opacity: 1; }
89+
51%, 100% { opacity: 0.3; }
90+
}
91+
`}
92+
`;
93+
94+
const BlockContent = styled(MarkedText)`
95+
width: 100%;
96+
padding: ${space(2)};
97+
color: ${p => p.theme.textColor};
98+
white-space: pre-wrap;
99+
word-wrap: break-word;
100+
101+
p,
102+
li,
103+
ul {
104+
margin: -${space(1)} 0;
105+
}
106+
107+
h1,
108+
h2,
109+
h3,
110+
h4,
111+
h5,
112+
h6 {
113+
margin: 0;
114+
font-size: ${p => p.theme.fontSize.lg};
115+
}
116+
117+
p:first-child,
118+
li:first-child,
119+
ul:first-child,
120+
h1:first-child,
121+
h2:first-child,
122+
h3:first-child,
123+
h4:first-child,
124+
h5:first-child,
125+
h6:first-child {
126+
margin-top: 0;
127+
}
128+
`;
129+
130+
const UserBlockContent = styled('div')`
131+
width: 100%;
132+
padding: ${space(2)} ${space(2)} ${space(2)} 0;
133+
white-space: pre-wrap;
134+
word-wrap: break-word;
135+
color: ${p => p.theme.subText};
136+
`;
137+
138+
const FocusIndicator = styled('div')`
139+
position: absolute;
140+
top: 0;
141+
right: 0;
142+
bottom: 0;
143+
width: 3px;
144+
background: ${p => p.theme.pink400};
145+
`;
146+
147+
const DeleteHint = styled('div')`
148+
position: absolute;
149+
bottom: ${space(0.5)};
150+
right: ${space(1)};
151+
padding: ${space(0.25)} ${space(0.5)};
152+
font-size: 10px;
153+
color: ${p => p.theme.subText};
154+
box-shadow: ${p => p.theme.dropShadowLight};
155+
pointer-events: none;
156+
white-space: nowrap;
157+
`;
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import styled from '@emotion/styled';
2+
3+
import {IconSeer} from 'sentry/icons';
4+
import {space} from 'sentry/styles/space';
5+
6+
function EmptyState() {
7+
return (
8+
<Container>
9+
<IconSeer size="xl" variant="waiting" />
10+
<Text>
11+
Welcome to Seer Explorer.
12+
<br />
13+
Ask me anything about your application.
14+
</Text>
15+
</Container>
16+
);
17+
}
18+
19+
export default EmptyState;
20+
21+
const Container = styled('div')`
22+
flex: 1;
23+
display: flex;
24+
flex-direction: column;
25+
align-items: center;
26+
justify-content: center;
27+
padding: ${space(4)};
28+
text-align: center;
29+
`;
30+
31+
const Text = styled('div')`
32+
margin-top: ${space(2)};
33+
color: ${p => p.theme.subText};
34+
font-size: ${p => p.theme.fontSize.md};
35+
line-height: 1.4;
36+
max-width: 300px;
37+
`;

0 commit comments

Comments
 (0)