Skip to content

ai uf summary [experimental / poc - not meant for prod] #89958

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 171 additions & 2 deletions static/app/components/feedback/list/feedbackList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Fragment, useMemo, useRef} from 'react';
import {Fragment, useEffect, useMemo, useRef, useState} from 'react';
import type {ListRowProps} from 'react-virtualized';
import {
AutoSizer,
Expand All @@ -10,17 +10,30 @@ import styled from '@emotion/styled';

import waitingForEventImg from 'sentry-images/spot/waiting-for-event.svg';

import {Tag} from 'sentry/components/core/badge/tag';
import {Button} from 'sentry/components/core/button';
import {CompactSelect} from 'sentry/components/core/compactSelect';
import {Tooltip} from 'sentry/components/core/tooltip';
import ErrorBoundary from 'sentry/components/errorBoundary';
import FeedbackListHeader from 'sentry/components/feedback/list/feedbackListHeader';
import FeedbackListItem from 'sentry/components/feedback/list/feedbackListItem';
import SentimentOverTimeChart from 'sentry/components/feedback/list/sentimentOverTimeChart';
import {
getSentimentIcon,
getSentimentType,
} from 'sentry/components/feedback/list/summaryUtils';
import useListItemCheckboxState from 'sentry/components/feedback/list/useListItemCheckboxState';
import useSentimentKeyword from 'sentry/components/feedback/list/useSentimentKeyword';
import useFeedbackQueryKeys from 'sentry/components/feedback/useFeedbackQueryKeys';
import LoadingIndicator from 'sentry/components/loadingIndicator';
import Placeholder from 'sentry/components/placeholder';
import {IconThumb} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import useFetchInfiniteListData from 'sentry/utils/api/useFetchInfiniteListData';
import type {FeedbackIssueListItem} from 'sentry/utils/feedback/types';
import {useLocation} from 'sentry/utils/useLocation';
import {useNavigate} from 'sentry/utils/useNavigate';
import useVirtualizedList from 'sentry/views/replays/detail/useVirtualizedList';

// Ensure this object is created once as it is an input to
Expand All @@ -40,8 +53,59 @@ function NoFeedback({title, subtitle}: {subtitle: string; title: string}) {
);
}

export default function FeedbackList() {
interface FeedbackListProps {
feedbackSummary: {
error: Error | null;
keySentiments: Array<{
type: 'positive' | 'negative' | 'neutral';
value: string;
}>;
loading: boolean;
summary: string | null;
};
isHelpful: boolean | null;
setIsHelpful: (isHelpful: boolean) => void;
}

export default function FeedbackList({
feedbackSummary,
setIsHelpful,
isHelpful,
}: FeedbackListProps) {
const {listQueryKey} = useFeedbackQueryKeys();
const location = useLocation();
const navigate = useNavigate();

const {summary, keySentiments, loading: summaryLoading} = feedbackSummary;
const [selectedSentiment, setSelectedSentiment] = useState<string | null>(null);
// keyword used for search when a sentiment is selected
const {keyword} = useSentimentKeyword({sentiment: selectedSentiment});
const [selectValue, setSelectValue] = useState<string>('summary');

const prevKeywordRef = useRef(keyword);
const prevSelectedSentimentRef = useRef(selectedSentiment);
const locationRef = useRef({pathname: location.pathname, query: location.query});

useEffect(() => {
locationRef.current = {pathname: location.pathname, query: location.query};
}, [location]);

useEffect(() => {
if (
keyword &&
selectedSentiment &&
(prevSelectedSentimentRef.current !== selectedSentiment ||
prevKeywordRef.current !== keyword)
) {
prevSelectedSentimentRef.current = selectedSentiment;
prevKeywordRef.current = keyword;
navigate({
pathname: locationRef.current.pathname,
query: {...locationRef.current.query, query: keyword},
});
}
}, [keyword, selectedSentiment, navigate]);

const {
isFetchingNextPage,
isFetchingPreviousPage,
Expand Down Expand Up @@ -95,6 +159,71 @@ export default function FeedbackList() {

return (
<Fragment>
<Summary>
<SummaryHeader>
<StyledCompactSelect
options={[
{value: 'summary', label: t('Feedback Summary')},
{value: 'sentimentOverTime', label: t('Sentiment Over Time')},
]}
onChange={option => setSelectValue(String(option.value))}
value={selectValue}
/>
{selectValue === 'summary' && (
<Thumbs>
{
<Button
title={t('This summary is helpful')}
borderless
size="xs"
onClick={() => setIsHelpful(true)}
>
<IconThumb color={isHelpful ? 'green400' : undefined} direction="up" />
</Button>
}
{
<Button
title={t('This summary is not helpful. Click to update.')}
borderless
size="xs"
onClick={() => setIsHelpful(false)}
>
<IconThumb
color={isHelpful === false ? 'red400' : undefined}
direction="down"
/>
</Button>
}
</Thumbs>
)}
</SummaryHeader>
{selectValue === 'summary' ? (
summaryLoading ? (
<Placeholder height="200px" />
) : (
<Fragment>
<div>{summary}</div>
<KeySentiments>
{keySentiments.map(sentiment => (
<SentimentTag
key={sentiment.value}
icon={getSentimentIcon(sentiment.type)}
type={getSentimentType(sentiment.type)}
onClick={e => {
const targetSentiment = (e.target as HTMLElement).textContent ?? '';
setSelectedSentiment(targetSentiment);
}}
>
{sentiment.value}
</SentimentTag>
))}
</KeySentiments>
</Fragment>
)
) : (
<SentimentOverTimeChart />
)}
</Summary>
<FeedbackListHeader {...checkboxState} />
<FeedbackListItems>
<InfiniteLoader
Expand Down Expand Up @@ -152,6 +281,38 @@ export default function FeedbackList() {
);
}

const SummaryHeader = styled('div')`
font-weight: bold;
display: flex;
justify-content: space-between;
align-items: center;
`;

const Thumbs = styled('div')`
display: flex;
gap: ${space(0.25)};
`;

const Summary = styled('div')`
padding: ${space(2)};
border-bottom: 1px solid ${p => p.theme.innerBorder};
display: flex;
flex-direction: column;
gap: ${space(2)};
`;

const KeySentiments = styled('div')`
display: flex;
flex-direction: column;
gap: ${space(1)};
align-items: flex-start;
`;

const SentimentTag = styled(Tag)`
cursor: pointer;
max-width: 100%;
`;

const FeedbackListItems = styled('div')`
display: grid;
flex-grow: 1;
Expand Down Expand Up @@ -187,3 +348,11 @@ const EmptyMessage = styled('div')`
font-size: ${p => p.theme.fontSizeExtraLarge};
}
`;

const StyledCompactSelect = styled(CompactSelect)`
> button {
border: none;
border-color: transparent;
box-shadow: none;
}
`;
44 changes: 44 additions & 0 deletions static/app/components/feedback/list/sentimentOverTimeChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {useTheme} from '@emotion/react';

import useSentimentOverTime from 'sentry/components/feedback/list/useSentimentOverTime';
import Placeholder from 'sentry/components/placeholder';
import {Line} from 'sentry/views/dashboards/widgets/timeSeriesWidget/plottables/line';
import {TimeSeriesWidgetVisualization} from 'sentry/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization';
import {Widget} from 'sentry/views/dashboards/widgets/widget/widget';
import {convertSeriesToTimeseries} from 'sentry/views/insights/common/utils/convertSeriesToTimeseries';
import {WidgetVisualizationStates} from 'sentry/views/insights/pages/platform/laravel/widgetVisualizationStates';

export default function SentimentOverTimeChart() {
const {series, loading, error} = useSentimentOverTime();
const theme = useTheme();

const colorPalette = theme.chart.getColorPalette(series.length - 2);
const plottables = series.map(
(ts, index) =>
new Line(convertSeriesToTimeseries(ts), {
color: colorPalette[index],
alias: ts.seriesName,
})
);

const hasData = series?.length;
const isLoading = loading;

if (isLoading) {
return <Placeholder height="200px" />;
}

const visualization = (
<WidgetVisualizationStates
isEmpty={!hasData}
isLoading={isLoading}
error={error}
VisualizationType={TimeSeriesWidgetVisualization}
visualizationProps={{
plottables,
}}
/>
);

return <Widget height={200} Visualization={visualization} />;
}
23 changes: 23 additions & 0 deletions static/app/components/feedback/list/summaryUtils.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {IconHappy, IconMeh, IconSad} from 'sentry/icons';

export const getSentimentIcon = (type: string) => {
switch (type) {
case 'positive':
return <IconHappy color="green400" />;
case 'negative':
return <IconSad color="red400" />;
default:
return <IconMeh color="yellow400" />;
}
};

export const getSentimentType = (type: string) => {
switch (type) {
case 'positive':
return 'success';
case 'negative':
return 'error';
default:
return 'warning';
}
};
45 changes: 45 additions & 0 deletions static/app/components/feedback/list/useFeedbackMessages.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type {FeedbackIssue} from 'sentry/utils/feedback/types';
import {useApiQuery} from 'sentry/utils/queryClient';
import {decodeList} from 'sentry/utils/queryString';
import useLocationQuery from 'sentry/utils/url/useLocationQuery';
import useOrganization from 'sentry/utils/useOrganization';

const FEEDBACK_STALE_TIME = 10 * 60 * 1000;

export default function useFeedbackMessages() {
const organization = useOrganization();
const queryView = useLocationQuery({
fields: {
limit: 10,
queryReferrer: 'feedback_list_page',
project: decodeList,
statsPeriod: '7d',
},
});

const {data, isPending, isError} = useApiQuery<FeedbackIssue[]>(
[
`/organizations/${organization.slug}/issues/`,
{
query: {
...queryView,
query: `issue.category:feedback status:unresolved`,
},
},
],
{staleTime: FEEDBACK_STALE_TIME}
);

if (isPending || isError) {
return {messages: [], messagesWithTime: []};
}

return {
messages: data.map(feedback => {
return feedback.metadata.message;
}),
messagesWithTime: data.map(feedback => {
return {message: feedback.metadata.message, time: feedback.firstSeen};
}),
};
}
Loading
Loading