Browse Source

ref(devtoolbar): move code from getsentry (#74478)

- this PR copies everything from the devtoolbar POC
(https://github.com/getsentry/getsentry/pull/14420) but moves all files
from `getsentry` to `sentry`. no files were changed, except for a few
imports moved from `getsentry` to `sentry`, and a few rewordings in the
readme.
- this change means that sentry employees on the sentry org, running
`dev-ui` from `sentry`, will be able to see the devtoolbar. previously,
the devtoolbar was only visible when running `dev-ui` from `getsentry`.
note that sentry employees on any other org will see the employee
feedback button, which was the previous behavior.
- will follow up with a PR that deletes the duplicated code from
`getsentry`. can also follow up with PRs to clean up this code/make any
adjustments as needed.
- relates to https://github.com/getsentry/sentry/issues/74451

running `dev-ui` from `getsentry` and `sentry` on sentry org gives the
devtoolbar:
<img width="1199" alt="SCR-20240717-obbs"
src="https://github.com/user-attachments/assets/4c41c675-75de-4822-b847-15ec702c8dab">

on any other org gives the employee feedback button:
<img width="1196" alt="SCR-20240717-obsf"
src="https://github.com/user-attachments/assets/98dd587a-40f5-4367-956a-8fceff37855c">
Michelle Zhang 7 months ago
parent
commit
36fea73add

+ 11 - 0
static/app/components/devtoolbar/README.md

@@ -0,0 +1,11 @@
+# Dev Toolbar
+
+This folder contains a PoC for what a Dev Toolbar product would look like and what features it could have.
+
+This is not production ready. Only Sentry employees should be able to see this code, while they're on sentry.io or running a development environment. In order for this to be production-ready for customers to install on their own website, it would need to be bundled similar to the SDK or Spotlight. There are many steps remaining to get there.
+
+Therefore, this is built with maximum portability in mind. It is a goal for this to have as few dependencies on sentry/getsentry as possible to make the majority of the code easy to port into a different repo when/if the PoC is successful. However, some code will not be portable, which is why we're leveraging sentry to host the PoC. For example:
+
+- The API calls themselves are not portable. We're using sentry/getsentry to explicitly avoid solving the API-Auth problem right now.
+- Logo assets will need to be copied over, or inserted into platformicons
+- Any complex CSS that cannot be inlined. This folder prefers to use inline `style={}` attributes which gives us maximum flexibility to choose a style library when this is extracted. `styled()` calls are used sparingly.

+ 50 - 0
static/app/components/devtoolbar/components/app.tsx

@@ -0,0 +1,50 @@
+import {Fragment, Suspense} from 'react';
+import {Global} from '@emotion/react';
+
+import LoadingTriangle from 'sentry/components/loadingTriangle';
+import {useSessionStorage} from 'sentry/utils/useSessionStorage';
+
+import usePlacementCss from '../hooks/usePlacementCss';
+import {fixedContainerBaseCss} from '../styles/fixedContainer';
+import {avatarCss, globalCss, loadingIndicatorCss} from '../styles/global';
+import {resetFlexColumnCss} from '../styles/reset';
+
+import Navigation from './navigation';
+import PanelLayout from './panelLayout';
+import PanelRouter from './panelRouter';
+
+export default function App() {
+  const placement = usePlacementCss();
+  const [isHidden, setIsHidden] = useSessionStorage('hide_employee_devtoolbar', false);
+  if (isHidden) {
+    return null;
+  }
+
+  return (
+    <Fragment>
+      <Global styles={globalCss} />
+      <Global styles={loadingIndicatorCss} />
+      <Global styles={avatarCss} />
+      <div css={[fixedContainerBaseCss, placement.fixedContainer.css]}>
+        {isHidden ? null : (
+          <Fragment>
+            <Navigation setIsHidden={setIsHidden} />
+            <Suspense fallback={<LoadingPanel />}>
+              <PanelRouter />
+            </Suspense>
+          </Fragment>
+        )}
+      </div>
+    </Fragment>
+  );
+}
+
+function LoadingPanel() {
+  return (
+    <PanelLayout title="">
+      <div css={resetFlexColumnCss} style={{overflow: 'hidden', contain: 'strict'}}>
+        <LoadingTriangle />
+      </div>
+    </PanelLayout>
+  );
+}

+ 197 - 0
static/app/components/devtoolbar/components/feedback/feedbackPanel.tsx

@@ -0,0 +1,197 @@
+import {useMemo} from 'react';
+import {css} from '@emotion/react';
+
+import ActorAvatar from 'sentry/components/avatar/actorAvatar';
+import ProjectBadge from 'sentry/components/idBadge/projectBadge';
+import Placeholder from 'sentry/components/placeholder';
+import TextOverflow from 'sentry/components/textOverflow';
+import TimeSince from 'sentry/components/timeSince';
+import {IconAdd, IconChat, IconFatal, IconImage, IconPlay} from 'sentry/icons';
+import useReplayCount from 'sentry/utils/replayCount/useReplayCount';
+
+import useConfiguration from '../../hooks/useConfiguration';
+import useCurrentTransactionName from '../../hooks/useCurrentTransactionName';
+import {useSDKFeedbackButton} from '../../hooks/useSDKFeedbackButton';
+import {
+  badgeWithLabelCss,
+  gridFlexEndCss,
+  listItemGridCss,
+  listItemPlaceholderWrapperCss,
+} from '../../styles/listItem';
+import {
+  panelHeadingRightCss,
+  panelInsetContentCss,
+  panelSectionCss,
+} from '../../styles/panel';
+import {resetButtonCss, resetFlexColumnCss} from '../../styles/reset';
+import {smallCss, textOverflowTwoLinesCss, xSmallCss} from '../../styles/typography';
+import type {FeedbackIssueListItem} from '../../types';
+import InfiniteListItems from '../infiniteListItems';
+import InfiniteListState from '../infiniteListState';
+import PanelLayout from '../panelLayout';
+import SentryAppLink from '../sentryAppLink';
+
+import useInfiniteFeedbackList from './useInfiniteFeedbackList';
+
+export default function FeedbackPanel() {
+  const buttonRef = useSDKFeedbackButton();
+  const transactionName = useCurrentTransactionName();
+  const queryResult = useInfiniteFeedbackList({
+    query: `url:*${transactionName}`,
+  });
+
+  const estimateSize = 108;
+  const placeholderHeight = `${estimateSize - 8}px`; // The real height of the items, minus the padding-block value
+
+  return (
+    <PanelLayout
+      title="User Feedback"
+      titleRight={
+        buttonRef ? (
+          <button
+            aria-label="Submit Feedback"
+            css={[resetButtonCss, panelHeadingRightCss]}
+            ref={buttonRef}
+            title="Submit Feedback"
+          >
+            <IconAdd size="xs" />
+          </button>
+        ) : null
+      }
+    >
+      <div css={[smallCss, panelSectionCss, panelInsetContentCss]}>
+        <span>
+          Unresolved feedback related to <code>{transactionName}</code>
+        </span>
+      </div>
+
+      <div css={resetFlexColumnCss}>
+        <InfiniteListState
+          queryResult={queryResult}
+          backgroundUpdatingMessage={() => null}
+          loadingMessage={() => (
+            <div
+              css={[
+                resetFlexColumnCss,
+                panelSectionCss,
+                panelInsetContentCss,
+                listItemPlaceholderWrapperCss,
+              ]}
+            >
+              <Placeholder height={placeholderHeight} />
+              <Placeholder height={placeholderHeight} />
+              <Placeholder height={placeholderHeight} />
+              <Placeholder height={placeholderHeight} />
+            </div>
+          )}
+        >
+          <InfiniteListItems
+            estimateSize={() => estimateSize}
+            queryResult={queryResult}
+            itemRenderer={props => <FeedbackListItem {...props} />}
+            emptyMessage={() => <p css={panelInsetContentCss}>No items to show</p>}
+          />
+        </InfiniteListState>
+      </div>
+    </PanelLayout>
+  );
+}
+
+function FeedbackListItem({item}: {item: FeedbackIssueListItem}) {
+  const {projectSlug, projectId, trackAnalytics} = useConfiguration();
+  const {feedbackHasReplay} = useReplayCountForFeedbacks();
+
+  const hasReplayId = feedbackHasReplay(item.id);
+  const isFatal = ['crash_report_embed_form', 'user_report_envelope'].includes(
+    item.metadata.source ?? ''
+  );
+  const hasAttachments = item.latestEventHasAttachments;
+  const hasComments = item.numComments > 0;
+
+  return (
+    <div css={listItemGridCss}>
+      <TextOverflow css={smallCss} style={{gridArea: 'name'}}>
+        <SentryAppLink
+          to={{
+            url: '/feedback/',
+            query: {project: projectId, feedbackSlug: `${projectSlug}:${item.id}`},
+          }}
+          onClick={() => {
+            trackAnalytics?.({
+              eventKey: `devtoolbar.feedback-list.item.click`,
+              eventName: `devtoolbar: Click feedback-list item`,
+            });
+          }}
+        >
+          <strong>
+            {item.metadata.name ?? item.metadata.contact_email ?? 'Anonymous User'}
+          </strong>
+        </SentryAppLink>
+      </TextOverflow>
+
+      <div
+        css={[gridFlexEndCss, xSmallCss]}
+        style={{gridArea: 'time', color: 'var(--gray300)'}}
+      >
+        <TimeSince date={item.firstSeen} unitStyle="extraShort" />
+      </div>
+
+      <div style={{gridArea: 'message'}}>
+        <TextOverflow css={[smallCss, textOverflowTwoLinesCss]}>
+          {item.metadata.message}
+        </TextOverflow>
+      </div>
+
+      <div css={[badgeWithLabelCss, xSmallCss]} style={{gridArea: 'project'}}>
+        <ProjectBadge
+          css={css({'&& img': {boxShadow: 'none'}})}
+          project={item.project}
+          avatarSize={16}
+          hideName
+          avatarProps={{hasTooltip: false}}
+        />
+        <TextOverflow>{item.shortId}</TextOverflow>
+      </div>
+
+      <div css={gridFlexEndCss} style={{gridArea: 'icons'}}>
+        {/* IssueTrackingSignals could have some refactoring so it doesn't
+            depend on useOrganization, and so the filenames match up better with
+            the exported functions */}
+        {/* <IssueTrackingSignals group={item as unknown as Group} /> */}
+
+        {hasComments ? <IconChat size="sm" /> : null}
+        {isFatal ? <IconFatal size="xs" color="red400" /> : null}
+        {hasReplayId ? <IconPlay size="xs" /> : null}
+        {hasAttachments ? <IconImage size="xs" /> : null}
+        {item.assignedTo ? (
+          <ActorAvatar
+            actor={item.assignedTo}
+            size={16}
+            tooltipOptions={{containerDisplayMode: 'flex'}}
+          />
+        ) : null}
+      </div>
+    </div>
+  );
+}
+
+// Copied from sentry, but we're passing in a fake `organization` object here.
+// TODO: refactor useReplayCountForFeedbacks to accept an org param
+function useReplayCountForFeedbacks() {
+  const {organizationSlug} = useConfiguration();
+  const {hasOne, hasMany} = useReplayCount({
+    bufferLimit: 25,
+    dataSource: 'search_issues',
+    fieldName: 'issue.id',
+    organization: {slug: organizationSlug} as any,
+    statsPeriod: '90d',
+  });
+
+  return useMemo(
+    () => ({
+      feedbackHasReplay: hasOne,
+      feedbacksHaveReplay: hasMany,
+    }),
+    [hasMany, hasOne]
+  );
+}

+ 46 - 0
static/app/components/devtoolbar/components/feedback/useInfiniteFeedbackList.tsx

@@ -0,0 +1,46 @@
+import {useMemo} from 'react';
+
+import useConfiguration from '../../hooks/useConfiguration';
+import useInfiniteApiData from '../../hooks/useInfiniteApiData';
+import type {FeedbackIssueListItem} from '../../types';
+
+interface Props {
+  query: string;
+}
+
+export default function useInfiniteFeedbackList({query}: Props) {
+  const {environment, organizationSlug, projectId} = useConfiguration();
+  const mailbox = 'unresolved';
+
+  return useInfiniteApiData<FeedbackIssueListItem[]>({
+    queryKey: useMemo(
+      () => [
+        `/organizations/${organizationSlug}/issues/`,
+        {
+          query: {
+            limit: 25,
+            queryReferrer: 'devtoolbar',
+            environment: Array.isArray(environment) ? environment : [environment],
+            project: projectId,
+            statsPeriod: '14d',
+            mailbox,
+
+            collapse: ['inbox'],
+            expand: [
+              'owners', // Gives us assignment
+              'stats', // Gives us `firstSeen`
+              // 'pluginActions', // Gives us plugin actions available
+              // 'pluginIssues', // Gives us plugin issues available
+              // 'integrationIssues', // Gives us integration issues available
+              // 'sentryAppIssues', // Gives us Sentry app issues available
+              'latestEventHasAttachments', // Gives us whether the feedback has screenshots
+            ],
+            shortIdLookup: 0,
+            query: `issue.category:feedback status:${mailbox} ${query}`,
+          },
+        },
+      ],
+      [environment, mailbox, organizationSlug, projectId, query]
+    ),
+  });
+}

+ 106 - 0
static/app/components/devtoolbar/components/infiniteListItems.tsx

@@ -0,0 +1,106 @@
+import {useEffect, useRef} from 'react';
+import type {UseInfiniteQueryResult} from '@tanstack/react-query';
+import {useVirtualizer} from '@tanstack/react-virtual';
+
+import LoadingIndicator from 'sentry/components/loadingIndicator';
+
+import {
+  infiniteListFloatingMessageBottomCss,
+  infiniteListParentContainerCss,
+  infiniteListScollablePanelCss,
+  infiniteListScrollableWindowCss,
+} from '../styles/infiniteList';
+import type {ApiResult} from '../types';
+
+interface Props<Data> {
+  itemRenderer: ({item}: {item: Data}) => React.ReactNode;
+  queryResult: UseInfiniteQueryResult<ApiResult<Data[]>, Error>;
+  emptyMessage?: () => React.ReactNode;
+  estimateSize?: () => number;
+  loadingCompleteMessage?: () => React.ReactNode;
+  loadingMoreMessage?: () => React.ReactNode;
+  overscan?: number;
+}
+
+export default function InfiniteListItems<Data>({
+  estimateSize,
+  itemRenderer,
+  emptyMessage = EmptyMessage,
+  loadingCompleteMessage = LoadingCompleteMessage,
+  loadingMoreMessage = LoadingMoreMessage,
+  overscan,
+  queryResult,
+}: Props<Data>) {
+  const {data, hasNextPage, isFetchingNextPage, fetchNextPage} = queryResult;
+  const loadedRows = data ? data.pages.flatMap(d => d.json) : [];
+  const parentRef = useRef<HTMLDivElement>(null);
+
+  const rowVirtualizer = useVirtualizer({
+    count: hasNextPage ? loadedRows.length + 1 : loadedRows.length,
+    getScrollElement: () => parentRef.current,
+    estimateSize: estimateSize ?? (() => 100),
+    overscan: overscan ?? 5,
+  });
+  const items = rowVirtualizer.getVirtualItems();
+
+  useEffect(() => {
+    const lastItem = items.at(-1);
+    if (!lastItem) {
+      return;
+    }
+
+    if (lastItem.index >= loadedRows.length - 1 && hasNextPage && !isFetchingNextPage) {
+      fetchNextPage();
+    }
+  }, [hasNextPage, fetchNextPage, loadedRows.length, isFetchingNextPage, items]);
+
+  return (
+    <div ref={parentRef} css={infiniteListParentContainerCss}>
+      <div
+        css={infiniteListScollablePanelCss}
+        style={{height: rowVirtualizer.getTotalSize()}}
+      >
+        <ul
+          css={infiniteListScrollableWindowCss}
+          style={{transform: `translateY(${items[0]?.start ?? 0}px)`}}
+        >
+          {items.length ? null : emptyMessage()}
+          {items.map(virtualRow => {
+            const isLoaderRow = virtualRow.index > loadedRows.length - 1;
+            const item = loadedRows[virtualRow.index];
+
+            return (
+              <li
+                data-index={virtualRow.index}
+                key={virtualRow.index}
+                ref={rowVirtualizer.measureElement}
+              >
+                {isLoaderRow
+                  ? hasNextPage
+                    ? loadingMoreMessage()
+                    : loadingCompleteMessage()
+                  : itemRenderer({item})}
+              </li>
+            );
+          })}
+        </ul>
+      </div>
+    </div>
+  );
+}
+
+function EmptyMessage() {
+  return <p>No items to show</p>;
+}
+
+function LoadingMoreMessage() {
+  return (
+    <footer css={infiniteListFloatingMessageBottomCss} title="Loading more items...">
+      <LoadingIndicator mini />
+    </footer>
+  );
+}
+
+function LoadingCompleteMessage() {
+  return <p>Nothing more to load</p>;
+}

+ 56 - 0
static/app/components/devtoolbar/components/infiniteListState.tsx

@@ -0,0 +1,56 @@
+import {Fragment} from 'react';
+import type {UseInfiniteQueryResult, UseQueryResult} from '@tanstack/react-query';
+
+import type {ApiResult} from '../types';
+
+export interface Props<Data> {
+  children: React.ReactNode;
+  queryResult:
+    | UseQueryResult<ApiResult<Data>, Error>
+    | UseInfiniteQueryResult<ApiResult<Data[]>, Error>;
+  backgroundUpdatingMessage?: () => React.ReactNode;
+  errorMessage?: (props: {error: Error}) => React.ReactNode;
+  loadingMessage?: () => React.ReactNode;
+}
+
+export default function InfiniteListState<Data>({
+  backgroundUpdatingMessage = BackgroundUpdatingMessage,
+  children,
+  errorMessage = ErrorMessage,
+  loadingMessage = LoadingMessage,
+  queryResult,
+}: Props<Data>) {
+  const {status, error, isFetching} = queryResult;
+  if (status === 'loading') {
+    return loadingMessage();
+  }
+  if (status === 'error') {
+    return errorMessage({error: error as Error});
+  }
+
+  // It's fetching in the background if:
+  // - it's a regular QueryResult, and isFetching is true
+  // - it's an InfiniteQueryResult, and itFetching is true, but we're not fetching the next page
+  const isBackgroundUpdating =
+    isFetching &&
+    ('isFetchingNextPage' in queryResult ? !queryResult.isFetchingNextPage : true);
+
+  return (
+    <Fragment>
+      {children}
+      {isBackgroundUpdating ? backgroundUpdatingMessage() : null}
+    </Fragment>
+  );
+}
+
+function LoadingMessage() {
+  return <p>Loading...</p>;
+}
+
+function ErrorMessage({error}: {error: Error}) {
+  return <p>Error: {(error as Error).message}</p>;
+}
+
+function BackgroundUpdatingMessage() {
+  return <footer>Background Updating...</footer>;
+}

+ 145 - 0
static/app/components/devtoolbar/components/issues/issuesPanel.tsx

@@ -0,0 +1,145 @@
+import {css} from '@emotion/react';
+
+import ActorAvatar from 'sentry/components/avatar/actorAvatar';
+import TimesTag from 'sentry/components/group/inboxBadges/timesTag';
+import ProjectBadge from 'sentry/components/idBadge/projectBadge';
+import Placeholder from 'sentry/components/placeholder';
+import TextOverflow from 'sentry/components/textOverflow';
+import TimeSince from 'sentry/components/timeSince';
+import type {Group} from 'sentry/types/group';
+
+import useConfiguration from '../../hooks/useConfiguration';
+import useCurrentTransactionName from '../../hooks/useCurrentTransactionName';
+import {
+  badgeWithLabelCss,
+  gridFlexEndCss,
+  listItemGridCss,
+  listItemPlaceholderWrapperCss,
+} from '../../styles/listItem';
+import {panelInsetContentCss, panelSectionCss} from '../../styles/panel';
+import {resetFlexColumnCss} from '../../styles/reset';
+import {smallCss, xSmallCss} from '../../styles/typography';
+import InfiniteListItems from '../infiniteListItems';
+import InfiniteListState from '../infiniteListState';
+import PanelLayout from '../panelLayout';
+import SentryAppLink from '../sentryAppLink';
+
+import useInfiniteIssuesList from './useInfiniteIssuesList';
+
+export default function FeedbackPanel() {
+  const transactionName = useCurrentTransactionName();
+  const queryResult = useInfiniteIssuesList({
+    query: `url:*${transactionName}`,
+  });
+
+  const estimateSize = 108;
+  const placeholderHeight = `${estimateSize - 8}px`; // The real height of the items, minus the padding-block value
+
+  return (
+    <PanelLayout title="Issues">
+      <div css={[smallCss, panelSectionCss, panelInsetContentCss]}>
+        <span>
+          Unresolved issues related to <code>{transactionName}</code>
+        </span>
+      </div>
+
+      <div css={resetFlexColumnCss}>
+        <InfiniteListState
+          queryResult={queryResult}
+          backgroundUpdatingMessage={() => null}
+          loadingMessage={() => (
+            <div
+              css={[
+                resetFlexColumnCss,
+                panelSectionCss,
+                panelInsetContentCss,
+                listItemPlaceholderWrapperCss,
+              ]}
+            >
+              <Placeholder height={placeholderHeight} />
+              <Placeholder height={placeholderHeight} />
+              <Placeholder height={placeholderHeight} />
+              <Placeholder height={placeholderHeight} />
+            </div>
+          )}
+        >
+          <InfiniteListItems
+            estimateSize={() => estimateSize}
+            queryResult={queryResult}
+            itemRenderer={props => <IssueListItem {...props} />}
+            emptyMessage={() => <p css={panelInsetContentCss}>No items to show</p>}
+          />
+        </InfiniteListState>
+      </div>
+    </PanelLayout>
+  );
+}
+
+function IssueListItem({item}: {item: Group}) {
+  const {projectSlug, projectId, trackAnalytics} = useConfiguration();
+
+  return (
+    <div css={listItemGridCss}>
+      <TextOverflow
+        css={[badgeWithLabelCss, smallCss]}
+        style={{gridArea: 'name', fontWeight: item.hasSeen ? 'bold' : 400}}
+      >
+        <SentryAppLink
+          to={{
+            url: `/issues/${item.id}/`,
+            query: {project: projectId, feedbackSlug: `${projectSlug}:${item.id}`},
+          }}
+          onClick={() => {
+            trackAnalytics?.({
+              eventKey: `devtoolbar.issue-list.item.click`,
+              eventName: `devtoolbar: Click issue-list item`,
+            });
+          }}
+        >
+          <strong>{item.metadata.type ?? '<unknown>'}</strong>
+        </SentryAppLink>
+      </TextOverflow>
+
+      <div
+        css={[gridFlexEndCss, xSmallCss]}
+        style={{gridArea: 'time', color: 'var(--gray300)'}}
+      >
+        <TimeSince date={item.firstSeen} unitStyle="extraShort" />
+      </div>
+
+      <div style={{gridArea: 'message'}}>
+        <TextOverflow css={[smallCss]}>{item.metadata.value}</TextOverflow>
+      </div>
+
+      <div css={[badgeWithLabelCss, xSmallCss]} style={{gridArea: 'project'}}>
+        <ProjectBadge
+          css={css({'&& img': {boxShadow: 'none'}})}
+          project={item.project}
+          avatarSize={16}
+          hideName
+          avatarProps={{hasTooltip: false}}
+        />
+        <TextOverflow>{item.shortId}</TextOverflow>
+      </div>
+
+      <div css={gridFlexEndCss} style={{gridArea: 'icons'}}>
+        {item.lifetime || item.firstSeen || item.lastSeen ? (
+          <div className="flex-row">
+            <TimesTag
+              lastSeen={item.lifetime?.lastSeen || item.lastSeen}
+              firstSeen={item.lifetime?.firstSeen || item.firstSeen}
+            />
+          </div>
+        ) : null}
+
+        {item.assignedTo ? (
+          <ActorAvatar
+            actor={item.assignedTo}
+            size={16}
+            tooltipOptions={{containerDisplayMode: 'flex'}}
+          />
+        ) : null}
+      </div>
+    </div>
+  );
+}

+ 47 - 0
static/app/components/devtoolbar/components/issues/useInfiniteIssuesList.tsx

@@ -0,0 +1,47 @@
+import {useMemo} from 'react';
+
+import type {Group} from 'sentry/types/group';
+import {IssueCategory} from 'sentry/types/group';
+
+import useConfiguration from '../../hooks/useConfiguration';
+import useInfiniteApiData from '../../hooks/useInfiniteApiData';
+
+interface Props {
+  query: string;
+}
+
+export default function useInfiniteIssuesList({query}: Props) {
+  const {environment, organizationSlug, projectId} = useConfiguration();
+  const mailbox = 'unresolved';
+
+  return useInfiniteApiData<Group[]>({
+    queryKey: useMemo(
+      () => [
+        `/organizations/${organizationSlug}/issues/`,
+        {
+          query: {
+            limit: 25,
+            queryReferrer: 'devtoolbar',
+            environment: Array.isArray(environment) ? environment : [environment],
+            project: projectId,
+            statsPeriod: '14d',
+
+            collapse: ['inbox'],
+            expand: [
+              'owners', // Gives us assignment
+              'stats', // Gives us `firstSeen`
+              // 'pluginActions', // Gives us plugin actions available
+              // 'pluginIssues', // Gives us plugin issues available
+              // 'integrationIssues', // Gives us integration issues available
+              // 'sentryAppIssues', // Gives us Sentry app issues available
+              // 'latestEventHasAttachments', // Gives us whether the feedback has screenshots
+            ],
+            shortIdLookup: 0,
+            query: `issue.category:[${IssueCategory.ERROR},${IssueCategory.PERFORMANCE}] status:${mailbox} ${query}`,
+          },
+        },
+      ],
+      [environment, mailbox, organizationSlug, projectId, query]
+    ),
+  });
+}

+ 102 - 0
static/app/components/devtoolbar/components/navigation.tsx

@@ -0,0 +1,102 @@
+import {css} from '@emotion/react';
+
+import useConfiguration from 'sentry/components/devtoolbar/hooks/useConfiguration';
+import InteractionStateLayer from 'sentry/components/interactionStateLayer';
+import {IconClose, IconIssues, IconMegaphone} from 'sentry/icons';
+
+import usePlacementCss from '../hooks/usePlacementCss';
+import useToolbarRoute from '../hooks/useToolbarRoute';
+import {navigationButtonCss, navigationCss} from '../styles/navigation';
+import {resetButtonCss, resetDialogCss} from '../styles/reset';
+
+export default function Navigation({setIsHidden}: {setIsHidden: (val: boolean) => void}) {
+  const {trackAnalytics} = useConfiguration();
+  const placement = usePlacementCss();
+
+  return (
+    <dialog
+      css={[
+        resetDialogCss,
+        navigationCss,
+        hideButtonContainerCss,
+        placement.navigation.css,
+      ]}
+    >
+      <NavButton panelName="issues" label={'Issues'} icon={<IconIssues />} />
+      <NavButton panelName="feedback" label={'User Feedback'} icon={<IconMegaphone />} />
+      <HideButton
+        onClick={() => {
+          setIsHidden(true);
+          trackAnalytics?.({
+            eventKey: `devtoolbar.nav.hide.click`,
+            eventName: `devtoolbar: Hide devtoolbar`,
+          });
+        }}
+      />
+    </dialog>
+  );
+}
+
+function NavButton({
+  icon,
+  label,
+  panelName,
+}: {
+  icon: React.ReactNode;
+  label: string;
+  panelName: ReturnType<typeof useToolbarRoute>['state']['activePanel'];
+}) {
+  const {trackAnalytics} = useConfiguration();
+  const {state, setActivePanel} = useToolbarRoute();
+
+  const isActive = state.activePanel === panelName;
+
+  return (
+    <button
+      aria-label={label}
+      css={[resetButtonCss, navigationButtonCss]}
+      data-active-route={isActive}
+      onClick={() => {
+        setActivePanel(isActive ? null : panelName);
+        trackAnalytics?.({
+          eventKey: `devtoolbar.nav.button.${label.replace(' ', '-')}.click`,
+          eventName: `devtoolbar: Toggle Nav Panel ${label} Click`,
+        });
+      }}
+      title={label}
+    >
+      <InteractionStateLayer />
+      {icon}
+    </button>
+  );
+}
+
+const hideButtonContainerCss = css`
+  :hover button {
+    visibility: visible;
+  }
+`;
+const hideButtonCss = css`
+  border-radius: 50%;
+  color: var(--gray300);
+  height: 1.6rem;
+  left: -10px;
+  position: absolute;
+  top: -10px;
+  visibility: hidden;
+  width: 1.6rem;
+  z-index: 1;
+`;
+
+function HideButton({onClick}: {onClick: () => void}) {
+  return (
+    <button
+      aria-label="Hide for this session"
+      css={[resetButtonCss, hideButtonCss]}
+      onClick={onClick}
+      title="Hide for this session"
+    >
+      <IconClose isCircled />
+    </button>
+  );
+}

+ 28 - 0
static/app/components/devtoolbar/components/panelLayout.tsx

@@ -0,0 +1,28 @@
+import {buttonCss} from 'sentry/components/devtoolbar/styles/typography';
+
+import {panelCss, panelHeadingCss, panelSectionCss} from '../styles/panel';
+import {resetDialogCss, resetFlexColumnCss} from '../styles/reset';
+
+interface Props {
+  title: string;
+  children?: React.ReactNode;
+  titleLeft?: React.ReactNode;
+  titleRight?: React.ReactNode;
+}
+
+export default function PanelLayout({children, title, titleLeft, titleRight}: Props) {
+  return (
+    <dialog open css={[resetDialogCss, resetFlexColumnCss, panelCss]}>
+      {title ? (
+        <header css={panelSectionCss}>
+          {titleLeft}
+          {titleRight}
+          <h1 css={[buttonCss, panelHeadingCss]}>{title}</h1>
+        </header>
+      ) : null}
+      <section css={resetFlexColumnCss} style={{contain: 'strict'}}>
+        {children}
+      </section>
+    </dialog>
+  );
+}

Some files were not shown because too many files changed in this diff