From a30141e0ca72ee0e6a107801b66ff96191d870e0 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Tue, 19 May 2026 14:17:14 -0700 Subject: [PATCH 1/9] feat(feedback): reuse streamlined activity section --- static/app/components/activity/item/index.tsx | 2 +- .../app/components/activity/note/header.tsx | 67 -- static/app/components/activity/note/index.tsx | 127 ---- .../feedbackActivitySection.spec.tsx | 144 ++++ .../feedbackItem/feedbackActivitySection.tsx | 28 +- static/app/views/alerts/types.tsx | 2 +- .../views/issueDetails/activitySection.tsx | 126 ---- .../views/issueDetails/groupActivityItem.tsx | 665 ------------------ 8 files changed, 164 insertions(+), 997 deletions(-) delete mode 100644 static/app/components/activity/note/header.tsx delete mode 100644 static/app/components/activity/note/index.tsx create mode 100644 static/app/components/feedback/feedbackItem/feedbackActivitySection.spec.tsx delete mode 100644 static/app/views/issueDetails/activitySection.tsx delete mode 100644 static/app/views/issueDetails/groupActivityItem.tsx diff --git a/static/app/components/activity/item/index.tsx b/static/app/components/activity/item/index.tsx index 060b70e83975..fc2705df6d12 100644 --- a/static/app/components/activity/item/index.tsx +++ b/static/app/components/activity/item/index.tsx @@ -12,7 +12,7 @@ import {ActivityAvatar} from './avatar'; import type {ActivityBubbleProps} from './bubble'; import {ActivityBubble} from './bubble'; -export type ActivityAuthorType = 'user' | 'system'; +type ActivityAuthorType = 'user' | 'system'; interface ActivityItemProps { /** diff --git a/static/app/components/activity/note/header.tsx b/static/app/components/activity/note/header.tsx deleted file mode 100644 index 96c6915d61d7..000000000000 --- a/static/app/components/activity/note/header.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import {Flex} from '@sentry/scraps/layout'; - -import {ActivityAuthor} from 'sentry/components/activity/author'; -import {openConfirmModal} from 'sentry/components/confirm'; -import {DropdownMenu} from 'sentry/components/dropdownMenu'; -import {IconEllipsis} from 'sentry/icons'; -import {t} from 'sentry/locale'; -import type {User} from 'sentry/types/user'; -import {useUser} from 'sentry/utils/useUser'; - -type Props = { - authorName: string; - onDelete: () => void; - onEdit: () => void; - // Naming is not great here, but this seems to be the author, aka user who wrote the note. - user?: User; -}; - -function NoteHeader({authorName, user, onEdit, onDelete}: Props) { - const activeUser = useUser(); - const canEdit = activeUser && (activeUser.isSuperuser || user?.id === activeUser.id); - - return ( - - {authorName} - {canEdit && ( - , - 'aria-label': t('Comment Actions'), - }} - items={[ - { - key: 'edit', - label: t('Edit'), - onAction: onEdit, - tooltip: activeUser.isSuperuser - ? t('You can edit this comment due to your superuser status') - : undefined, - }, - { - key: 'delete', - label: t('Remove'), - priority: 'danger', - onAction: () => - openConfirmModal({ - header: t('Remove'), - message: t('Are you sure you wish to delete this comment?'), - onConfirm: onDelete, - }), - tooltip: activeUser.isSuperuser - ? t('You can delete this comment due to your superuser status') - : undefined, - }, - ]} - /> - )} - - ); -} - -export {NoteHeader}; diff --git a/static/app/components/activity/note/index.tsx b/static/app/components/activity/note/index.tsx deleted file mode 100644 index a247f10e3601..000000000000 --- a/static/app/components/activity/note/index.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import {useState} from 'react'; - -import {Container} from '@sentry/scraps/layout'; - -import type {ActivityAuthorType} from 'sentry/components/activity/item'; -import {ActivityItem} from 'sentry/components/activity/item'; -import type {NoteType} from 'sentry/types/alerts'; -import type {User} from 'sentry/types/user'; -import type {ActivityType} from 'sentry/views/alerts/types'; - -import {NoteBody} from './body'; -import {NoteHeader} from './header'; -import {NoteInput} from './input'; - -type Props = { - /** - * String for author name to be displayed in header. - * - * This is not completely derived from `props.user` because we can set a - * default from parent component - */ - authorName: string; - dateCreated: Date | string; - /** - * Minimum height for the comment editor, in pixels. - * - * Passed through to NoteInput. - */ - minHeight: number; - /** - * This is the id of the note object from the server. This is to indicate you - * are editing an existing item - */ - noteId: string; - onDelete: (props: Props) => void; - onUpdate: (data: NoteType, props: Props) => void; - /** - * If used, will fetch list of teams/members that can be mentioned for projects - */ - projectSlugs: string[]; - /** - * Pass through to ActivityItem. Shows absolute time instead of a relative - * string - */ - showTime: boolean; - /** - * The note text itself - */ - text: string; - user: User; - /** - * This is unusual usage that Alert Details uses to get back the activity - * that an input was bound to as the onUpdate and onDelete actions forward - * this component's props. - */ - activity?: ActivityType; - /** - * pass through to ActivityItem. Hides the date/timestamp in header - */ - hideDate?: boolean; - onCreate?: (data: NoteType) => void; -}; - -function Note(props: Props) { - const [editing, setEditing] = useState(false); - - const { - noteId, - user, - dateCreated, - text, - authorName, - hideDate, - minHeight, - showTime, - projectSlugs, - onDelete, - onCreate, - onUpdate, - } = props; - - const activityItemProps = { - hideDate, - showTime, - id: `activity-item-${noteId}`, - author: { - type: 'user' as ActivityAuthorType, - user, - }, - date: dateCreated, - }; - - if (editing) { - return ( - - - setEditing(false)} - onUpdate={note => { - onUpdate(note, props); - setEditing(false); - }} - onCreate={note => onCreate?.(note)} - /> - - - ); - } - - const header = ( - setEditing(true)} - onDelete={() => onDelete(props)} - /> - ); - - return ( - - - - ); -} - -export {Note}; diff --git a/static/app/components/feedback/feedbackItem/feedbackActivitySection.spec.tsx b/static/app/components/feedback/feedbackItem/feedbackActivitySection.spec.tsx new file mode 100644 index 000000000000..5368e151a6f2 --- /dev/null +++ b/static/app/components/feedback/feedbackItem/feedbackActivitySection.spec.tsx @@ -0,0 +1,144 @@ +import {GroupFixture} from 'sentry-fixture/group'; +import {OrganizationFixture} from 'sentry-fixture/organization'; +import {ProjectFixture} from 'sentry-fixture/project'; +import {UserFixture} from 'sentry-fixture/user'; + +import { + render, + renderGlobalModal, + screen, + userEvent, +} from 'sentry-test/reactTestingLibrary'; + +import {FeedbackActivitySection} from 'sentry/components/feedback/feedbackItem/feedbackActivitySection'; +import {FeedbackApiOptions} from 'sentry/components/feedback/useFeedbackApiOptions'; +import {GroupActivityType} from 'sentry/types/group'; + +describe('FeedbackActivitySection', () => { + const organization = OrganizationFixture(); + const project = ProjectFixture(); + const user = UserFixture(); + + beforeEach(() => { + MockApiClient.clearMockResponses(); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/members/', + body: [{user}], + }); + }); + + it('renders feedback activity with the streamlined activity feed', async () => { + const feedbackItem = GroupFixture({ + id: '1337', + activity: [ + { + type: GroupActivityType.NOTE, + id: 'note-1', + data: {text: 'Existing feedback note'}, + dateCreated: '2020-01-01T00:00:00', + user, + }, + ], + project, + }); + + render( + + + , + {organization} + ); + + const commentInput = screen.getByPlaceholderText( + /Add details or updates to this feedback/ + ); + + expect(commentInput).toBeInTheDocument(); + expect(screen.getByText('Existing feedback note')).toBeInTheDocument(); + expect(screen.getByTestId('activity-timeline')).not.toContainElement( + screen.getByTestId('activity-input-frame') + ); + + await userEvent.click(commentInput); + + expect(screen.getByRole('radio', {name: 'Write'})).toBeInTheDocument(); + expect(screen.getByRole('radio', {name: 'Preview'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Comment'})).toBeInTheDocument(); + }); + + it('posts comments through the feedback activity mutation', async () => { + const comment = 'feedback follow up'; + const feedbackItem = GroupFixture({ + id: '1337', + activity: [], + project, + }); + const postMock = MockApiClient.addMockResponse({ + url: '/organizations/org-slug/issues/1337/comments/', + method: 'POST', + body: { + id: 'note-2', + user, + type: 'note', + data: {text: comment}, + dateCreated: '2024-10-31T00:00:00.000000Z', + }, + }); + + render( + + + , + {organization} + ); + + await userEvent.type(screen.getByRole('textbox'), comment); + await userEvent.click(screen.getByRole('button', {name: 'Comment'})); + + expect(postMock).toHaveBeenCalledWith( + '/organizations/org-slug/issues/1337/comments/', + expect.objectContaining({ + method: 'POST', + data: { + text: comment, + mentions: [], + }, + }) + ); + }); + + it('keeps the framed input when editing a comment', async () => { + const feedbackItem = GroupFixture({ + id: '1337', + activity: [ + { + type: GroupActivityType.NOTE, + id: 'note-1', + data: {text: 'Existing feedback note'}, + dateCreated: '2020-01-01T00:00:00', + user, + }, + ], + project, + }); + + render( + + + , + {organization} + ); + renderGlobalModal(); + + await userEvent.click(screen.getByRole('button', {name: 'Comment Actions'})); + await userEvent.click(screen.getByRole('menuitemradio', {name: 'Edit'})); + + const editInput = screen.getByDisplayValue('Existing feedback note'); + const editFrame = screen + .getAllByTestId('activity-input-frame') + .find(frame => frame.contains(editInput)); + + expect(editFrame).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Cancel'})).toBeEnabled(); + }); +}); diff --git a/static/app/components/feedback/feedbackItem/feedbackActivitySection.tsx b/static/app/components/feedback/feedbackItem/feedbackActivitySection.tsx index 75f8093ef5db..f1d11ec9d921 100644 --- a/static/app/components/feedback/feedbackItem/feedbackActivitySection.tsx +++ b/static/app/components/feedback/feedbackItem/feedbackActivitySection.tsx @@ -1,4 +1,5 @@ import {useCallback, useMemo} from 'react'; +import styled from '@emotion/styled'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {useFeedbackCache} from 'sentry/components/feedback/useFeedbackCache'; @@ -14,7 +15,7 @@ import { import type {User} from 'sentry/types/user'; import {uniqueId} from 'sentry/utils/guid'; import {useOrganization} from 'sentry/utils/useOrganization'; -import {ActivitySection} from 'sentry/views/issueDetails/activitySection'; +import {StreamlinedActivitySection} from 'sentry/views/issueDetails/streamline/sidebar/activitySection'; type Props = { feedbackItem: Group; @@ -107,14 +108,21 @@ export function FeedbackActivitySection(props: Props) { ); return ( - + + + ); } + +const ActivitySectionContainer = styled('div')` + padding-bottom: ${p => p.theme.space.xl}; +`; diff --git a/static/app/views/alerts/types.tsx b/static/app/views/alerts/types.tsx index 300ee64227f9..6cc2909316b0 100644 --- a/static/app/views/alerts/types.tsx +++ b/static/app/views/alerts/types.tsx @@ -54,7 +54,7 @@ type ActivityTypeDraft = { user: User | null; }; -export type ActivityType = ActivityTypeDraft & { +type ActivityType = ActivityTypeDraft & { previousValue: string | null; value: string | null; // determines IncidentStatus of the activity (CRITICAL/WARNING/etc.) eventStats?: {data: Data}; diff --git a/static/app/views/issueDetails/activitySection.tsx b/static/app/views/issueDetails/activitySection.tsx deleted file mode 100644 index 368ad43af8e8..000000000000 --- a/static/app/views/issueDetails/activitySection.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import {Fragment, useState} from 'react'; - -import {Container} from '@sentry/scraps/layout'; - -import {ActivityAuthor} from 'sentry/components/activity/author'; -import {ActivityItem} from 'sentry/components/activity/item'; -import {Note} from 'sentry/components/activity/note'; -import {NoteInputWithStorage} from 'sentry/components/activity/note/inputWithStorage'; -import {ErrorBoundary} from 'sentry/components/errorBoundary'; -import type {NoteType} from 'sentry/types/alerts'; -import type {Group, GroupActivity} from 'sentry/types/group'; -import {GroupActivityType, SEER_ACTIVITY_TYPES} from 'sentry/types/group'; -import type {User} from 'sentry/types/user'; -import {trackAnalytics} from 'sentry/utils/analytics'; -import {uniqueId} from 'sentry/utils/guid'; -import {useOrganization} from 'sentry/utils/useOrganization'; -import {useUser} from 'sentry/utils/useUser'; -import {GroupActivityItem} from 'sentry/views/issueDetails/groupActivityItem'; - -type Props = { - group: Group; - onCreate: (n: NoteType, me: User) => void; - onDelete: (item: GroupActivity) => void; - onUpdate: (item: GroupActivity, n: NoteType) => void; - placeholderText: string; -}; - -export function ActivitySection(props: Props) { - const {group, placeholderText, onCreate, onDelete, onUpdate} = props; - const organization = useOrganization(); - - const [inputId, setInputId] = useState(uniqueId()); - - const visibleActivities = organization.features.includes('seer-activity-timeline') - ? group.activity - : group.activity.filter(item => !SEER_ACTIVITY_TYPES.has(item.type)); - - const me = useUser(); - const projectSlugs = group?.project ? [group.project.slug] : []; - const noteProps = { - minHeight: 112, - group, - projectSlugs, - placeholder: placeholderText, - }; - - return ( - - - { - onCreate(n, me); - trackAnalytics('issue_details.comment_created', { - organization, - org_streamline_only: organization.streamlineOnly ?? undefined, - streamline: false, - }); - setInputId(uniqueId()); - }} - {...noteProps} - /> - - - {visibleActivities.map(item => { - const authorName = item.user ? item.user.name : 'Sentry'; - - if (item.type === GroupActivityType.NOTE) { - return ( - - { - onDelete(item); - trackAnalytics('issue_details.comment_deleted', { - organization, - streamline: false, - org_streamline_only: organization.streamlineOnly ?? undefined, - }); - }} - onUpdate={n => { - item.data.text = n.text; - onUpdate(item, n); - trackAnalytics('issue_details.comment_updated', { - organization, - streamline: false, - org_streamline_only: organization.streamlineOnly ?? undefined, - }); - }} - {...noteProps} - /> - - ); - } - - return ( - - {authorName}} - activity={item} - organization={organization} - projectId={group.project.id} - group={group} - /> - } - /> - - ); - })} - - ); -} diff --git a/static/app/views/issueDetails/groupActivityItem.tsx b/static/app/views/issueDetails/groupActivityItem.tsx deleted file mode 100644 index f3ff8b0825e7..000000000000 --- a/static/app/views/issueDetails/groupActivityItem.tsx +++ /dev/null @@ -1,665 +0,0 @@ -import {Fragment} from 'react'; -import styled from '@emotion/styled'; -import moment from 'moment-timezone'; - -import {ExternalLink, Link} from '@sentry/scraps/link'; - -import {CommitLink} from 'sentry/components/commitLink'; -import {DateTime} from 'sentry/components/dateTime'; -import {Duration} from 'sentry/components/duration'; -import {PullRequestLink} from 'sentry/components/pullRequestLink'; -import {Version} from 'sentry/components/version'; -import {t, tct, tn} from 'sentry/locale'; -import type { - Group, - GroupActivity, - GroupActivityAssigned, - GroupActivitySetEscalating, - GroupActivitySetIgnored, -} from 'sentry/types/group'; -import {GroupActivityType} from 'sentry/types/group'; -import type {Organization} from 'sentry/types/organization'; -import type {Project} from 'sentry/types/project'; -import type {User} from 'sentry/types/user'; -import {useTeamsById} from 'sentry/utils/useTeamsById'; -import {isSemverRelease} from 'sentry/utils/versions/isSemverRelease'; - -interface AssignedMessageProps { - activity: GroupActivityAssigned; - author: React.ReactNode; - issueType: string; -} - -function AssignedMessage({activity, author, issueType}: AssignedMessageProps) { - const {data} = activity; - let assignee: string | User | undefined; - const {teams} = useTeamsById( - data.assigneeType === 'team' ? {ids: [data.assignee]} : undefined - ); - - if (data.assigneeType === 'team') { - const team = teams.find(({id}) => id === data.assignee); - // TODO: could show a loading indicator if the team is loading - assignee = team ? `#${team.slug}` : ''; - } else if (data.assignee === activity.user?.id) { - assignee = t('themselves'); - } else if (data.assigneeType === 'user' && data.assigneeEmail) { - assignee = data.assigneeEmail; - } else { - assignee = t('an unknown user'); - } - - const isAutoAssigned = ['projectOwnership', 'codeowners', 'suspectCommitter'].includes( - data.integration as string - ); - - const integrationName: Record< - NonNullable, - string - > = { - msteams: t('Microsoft Teams'), - slack: t('Slack'), - projectOwnership: t('Ownership Rule'), - codeowners: t('Codeowners Rule'), - suspectCommitter: t('Suspect Commit'), - }; - - return ( - -
- {tct('[author] [action] this [issueType] to [assignee]', { - action: isAutoAssigned ? t('auto-assigned') : t('assigned'), - author, - assignee, - issueType, - })} -
- {data.integration && integrationName[data.integration] && ( - - {t('Assigned via %s', integrationName[data.integration])} - {data.rule && ( - - : {data.rule} - - )} - - )} -
- ); -} - -interface GroupActivityItemProps { - activity: GroupActivity; - author: React.ReactNode; - group: Group; - organization: Organization; - projectId: Project['id']; -} - -export function GroupActivityItem({ - activity, - organization, - projectId, - author, - group, -}: GroupActivityItemProps) { - const issuesLink = `/organizations/${organization.slug}/issues/`; - const isFeedback = (group.issueCategory as string) === 'feedback'; - const issueType = isFeedback ? t('feedback') : t('issue'); - - function getIgnoredMessage(data: GroupActivitySetIgnored['data']) { - const archived = t('archived'); - if (data.ignoreDuration) { - return tct('[author] [action] this issue for [duration]', { - author, - action: archived, - duration: , - }); - } - - if (data.ignoreCount && data.ignoreWindow) { - return tct( - '[author] [action] this issue until it happens [count] time(s) in [duration]', - { - author, - action: archived, - count: data.ignoreCount, - duration: , - } - ); - } - - if (data.ignoreCount) { - return tct('[author] [action] this issue until it happens [count] time(s)', { - author, - action: archived, - count: data.ignoreCount, - }); - } - - if (data.ignoreUserCount && data.ignoreUserWindow) { - return tct( - '[author] [action] this issue until it affects [count] user(s) in [duration]', - { - author, - action: archived, - count: data.ignoreUserCount, - duration: , - } - ); - } - - if (data.ignoreUserCount) { - return tct('[author] [action] this issue until it affects [count] user(s)', { - author, - action: archived, - count: data.ignoreUserCount, - }); - } - - if (data.ignoreUntil) { - return tct('[author] [action] this issue until [date]', { - author, - action: archived, - date: , - }); - } - if (data.ignoreUntilEscalating) { - return tct('[author] archived this issue until it escalates', { - author, - }); - } - - return isFeedback - ? tct('[author] marked this feedback as spam', { - author, - }) - : tct('[author] [action] this issue forever', { - author, - action: archived, - }); - } - - function getEscalatingMessage(data: GroupActivitySetEscalating['data']) { - if (data.forecast) { - return tct( - '[author] flagged this issue as escalating because over [forecast] [event] happened in an hour', - { - author, - forecast: data.forecast, - event: data.forecast === 1 ? 'event' : 'events', - } - ); - } - - if (data.expired_snooze) { - if (data.expired_snooze.count && data.expired_snooze.window) { - return tct( - '[author] flagged this issue as escalating because [count] [event] happened in [duration]', - { - author, - count: data.expired_snooze.count, - event: data.expired_snooze.count === 1 ? 'event' : 'events', - duration: , - } - ); - } - - if (data.expired_snooze.count) { - return tct( - '[author] flagged this issue as escalating because [count] [event] happened', - { - author, - count: data.expired_snooze.count, - event: data.expired_snooze.count === 1 ? 'event' : 'events', - } - ); - } - - if (data.expired_snooze.user_count && data.expired_snooze.user_window) { - return tct( - '[author] flagged this issue as escalating because [count] [user] affected in [duration]', - { - author, - count: data.expired_snooze.user_count, - user: data.expired_snooze.user_count === 1 ? 'user was' : 'users were', - duration: , - } - ); - } - - if (data.expired_snooze.user_count) { - return tct( - '[author] flagged this issue as escalating because [count] [user] affected', - { - author, - count: data.expired_snooze.user_count, - user: data.expired_snooze.user_count === 1 ? 'user was' : 'users were', - } - ); - } - - if (data.expired_snooze.until) { - return tct('[author] flagged this issue as escalating because [date] passed', { - author, - date: , - }); - } - } - - return tct('[author] flagged this issue as escalating', {author}); // should not reach this - } - - function renderContent() { - switch (activity.type) { - case GroupActivityType.NOTE: - return tct('[author] left a comment', {author}); - case GroupActivityType.SET_RESOLVED: - if ('integration_id' in activity.data && activity.data.integration_id) { - return tct('[author] marked this [issueType] as resolved via [integration]', { - integration: ( - - {activity.data.provider} - - ), - author, - issueType, - }); - } - return tct('[author] marked this [issueType] as resolved', {author, issueType}); - case GroupActivityType.SET_RESOLVED_BY_AGE: - return tct('[author] marked this issue as resolved due to inactivity', { - author, - }); - case GroupActivityType.SET_RESOLVED_IN_RELEASE: { - // Resolved in the next release - if ('current_release_version' in activity.data) { - const currentVersion = activity.data.current_release_version; - return tct( - '[author] marked this issue as resolved in releases greater than [version] [semver]', - { - author, - version: ( - - ), - semver: isSemverRelease(currentVersion) ? t('(semver)') : t('(non-semver)'), - } - ); - } - - const version = activity.data.version; - return version - ? tct('[author] marked this issue as resolved in [version] [semver]', { - author, - version: ( - - ), - semver: isSemverRelease(version) ? t('(semver)') : t('(non-semver)'), - }) - : tct('[author] marked this issue as resolved in the upcoming release', { - author, - }); - } - case GroupActivityType.SET_RESOLVED_IN_COMMIT: { - const deployedReleases = (activity.data.commit?.releases || []) - .filter(r => r.dateReleased !== null) - .sort( - (a, b) => moment(a.dateReleased).valueOf() - moment(b.dateReleased).valueOf() - ); - if (deployedReleases.length === 1 && activity.data.commit) { - return tct( - '[author] marked this issue as resolved in [version] [break]This commit was released in [release]', - { - author, - version: ( - - ), - break:
, - release: ( - - ), - } - ); - } - if (deployedReleases.length > 1 && activity.data.commit) { - return tct( - '[author] marked this issue as resolved in [version] [break]This commit was released in [release] and [otherCount] others', - { - author, - otherCount: deployedReleases.length - 1, - version: ( - - ), - break:
, - release: ( - - ), - } - ); - } - if (activity.data.commit) { - return tct('[author] marked this issue as resolved in [commit]', { - author, - commit: ( - - ), - }); - } - return tct('[author] marked this issue as resolved in a commit', {author}); - } - case GroupActivityType.REFERENCED_IN_COMMIT: { - if (activity.data.commit) { - return tct('[author] referenced this issue in [commit]', { - author, - commit: ( - - ), - }); - } - return tct('[author] referenced this issue in a commit', {author}); - } - case GroupActivityType.SET_RESOLVED_IN_PULL_REQUEST: { - const {data} = activity; - const {pullRequest} = data; - return tct('[author] has created a PR for this issue: [pullRequest]', { - author, - pullRequest: pullRequest ? ( - - ) : ( - t('PR not available') - ), - }); - } - case GroupActivityType.SET_UNRESOLVED: { - // TODO(nisanthan): Remove after migrating records to SET_ESCALATING - const {data} = activity; - if ('forecast' in data && data.forecast) { - return tct( - '[author] flagged this issue as escalating because over [forecast] [event] happened in an hour', - { - author, - forecast: data.forecast, - event: data.forecast === 1 ? 'event' : 'events', - } - ); - } - if ('integration_id' in data && data.integration_id) { - return tct('[author] marked this [issueType] as unresolved via [integration]', { - integration: ( - - {data.provider} - - ), - author, - issueType, - }); - } - return tct('[author] marked this [issueType] as unresolved', {author, issueType}); - } - case GroupActivityType.SET_IGNORED: { - const {data} = activity; - return getIgnoredMessage(data); - } - case GroupActivityType.SET_PUBLIC: - return tct('[author] made this issue public', {author}); - case GroupActivityType.SET_PRIVATE: - return tct('[author] made this issue private', {author}); - case GroupActivityType.SET_REGRESSION: { - const {data} = activity; - let subtext: React.ReactNode = null; - if (data.version && data.resolved_in_version && 'follows_semver' in data) { - subtext = ( - - {tct( - '[regressionVersion] is greater than or equal to [resolvedVersion] compared via [comparison]', - { - regressionVersion: ( - - ), - resolvedVersion: ( - - ), - comparison: data.follows_semver ? t('semver') : t('release date'), - } - )} - - ); - } - - return data.version ? ( - - {tct('[author] marked this issue as a regression in [version]', { - author, - version: ( - - ), - })} - {subtext} - - ) : ( - - {tct('[author] marked this issue as a regression', { - author, - })} - {subtext} - - ); - } - case GroupActivityType.CREATE_ISSUE: { - const {data} = activity; - if (data.new === true) { - return tct('[author] linked this issue to [issue] on [provider]', { - author, - issue: {data.title}, - provider: data.provider, - }); - } - return tct('[author] created an issue on [provider] titled [title]', { - author, - provider: data.provider, - title: {data.title}, - }); - } - case GroupActivityType.UNMERGE_SOURCE: { - const {data} = activity; - const {destination, fingerprints} = data; - return tn( - '%2$s migrated %1$s fingerprint to %3$s', - '%2$s migrated %1$s fingerprints to %3$s', - fingerprints.length, - author, - destination ? ( - - {destination.shortId} - - ) : ( - t('a group') - ) - ); - } - case GroupActivityType.UNMERGE_DESTINATION: { - const {data} = activity; - const {source, fingerprints} = data; - return tn( - '%2$s migrated %1$s fingerprint from %3$s', - '%2$s migrated %1$s fingerprints from %3$s', - fingerprints.length, - author, - source ? ( - - {source.shortId} - - ) : ( - t('a group') - ) - ); - } - case GroupActivityType.FIRST_SEEN: - if (activity.data.priority) { - return tct( - '[author] first saw this issue and marked it as [priority] priority', - {author, priority: activity.data.priority} - ); - } - - return tct('[author] first saw this issue', {author}); - case GroupActivityType.ASSIGNED: { - return ( - - ); - } - case GroupActivityType.UNASSIGNED: - return tct('[author] unassigned this [issueType]', {author, issueType}); - case GroupActivityType.MERGE: - return tn( - '%2$s merged %1$s issue into this issue', - '%2$s merged %1$s issues into this issue', - activity.data.issues.length, - author - ); - case GroupActivityType.REPROCESS: { - const {data} = activity; - const {oldGroupId, eventCount} = data; - - return tct('[author] reprocessed the events in this issue. [new-events]', { - author, - ['new-events']: ( - - {tn('See %s new event', 'See %s new events', eventCount)} - - ), - }); - } - case GroupActivityType.MARK_REVIEWED: { - return tct('[author] marked this issue as reviewed', { - author, - }); - } - case GroupActivityType.AUTO_SET_ONGOING: { - return activity.data?.afterDays - ? tct( - '[author] automatically marked this issue as ongoing after [afterDays] days', - {author, afterDays: activity.data.afterDays} - ) - : tct('[author] automatically marked this issue as ongoing', { - author, - }); - } - case GroupActivityType.SET_ESCALATING: { - return getEscalatingMessage(activity.data); - } - case GroupActivityType.SET_PRIORITY: { - const {data} = activity; - switch (data.reason) { - case 'escalating': - return tct( - '[author] updated the priority value of this issue to be [priority] after it escalated', - {author, priority: data.priority} - ); - case 'ongoing': - return tct( - '[author] updated the priority value of this issue to be [priority] after it was marked as ongoing', - {author, priority: data.priority} - ); - default: - return tct( - '[author] updated the priority value of this issue to be [priority]', - {author, priority: data.priority} - ); - } - } - case GroupActivityType.DELETED_ATTACHMENT: - return tct('[author] deleted an attachment', {author}); - case GroupActivityType.SEER_RCA_STARTED: - return t('Seer started analyzing the root cause'); - case GroupActivityType.SEER_RCA_COMPLETED: - return t('Seer completed root cause analysis'); - case GroupActivityType.SEER_SOLUTION_STARTED: - return t('Seer started developing a solution'); - case GroupActivityType.SEER_SOLUTION_COMPLETED: - return t('Seer completed developing a solution'); - case GroupActivityType.SEER_CODING_STARTED: - return t('Seer started implementing a fix'); - case GroupActivityType.SEER_CODING_COMPLETED: - return t('Seer completed implementing a fix'); - case GroupActivityType.SEER_PR_CREATED: { - const {data: prData} = activity; - const pr = prData.pull_requests?.[0]; - if (pr) { - return tct('Seer created a [link:pull request] in [repo]', { - link: , - repo: pr.repo_name, - }); - } - return t('Seer created a pull request'); - } - default: - return ''; // should never hit (?) - } - } - - return {renderContent()}; -} - -const Subtext = styled('div')` - font-size: ${p => p.theme.font.size.sm}; -`; - -const CodeWrapper = styled('div')` - overflow-wrap: anywhere; - font-size: ${p => p.theme.font.size.sm}; -`; - -const StyledRuleSpan = styled('span')` - font-family: ${p => p.theme.font.family.mono}; -`; From 020cba6be8228ec4e7f94d94d33f68a5309ebbfc Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Tue, 19 May 2026 15:33:21 -0700 Subject: [PATCH 2/9] fix(feedback): Pad feedback details scroll area feedback needed the extra bottom space on the scroll container, not tucked inside the activity section. Also narrows the activity section variant prop to sidebar vs standalone. the drawer can ask for md text size separately, which keeps feedback from needing a fake inline variant. Co-Authored-By: Codex GPT-5 --- .../feedbackItem/feedbackActivitySection.tsx | 27 ++++++---------- .../feedback/feedbackItem/feedbackItem.tsx | 3 +- .../streamline/sidebar/activityDrawer.tsx | 3 +- .../sidebar/activitySection.spec.tsx | 13 ++++++-- .../streamline/sidebar/activitySection.tsx | 32 +++++++++---------- 5 files changed, 38 insertions(+), 40 deletions(-) diff --git a/static/app/components/feedback/feedbackItem/feedbackActivitySection.tsx b/static/app/components/feedback/feedbackItem/feedbackActivitySection.tsx index f1d11ec9d921..25691728e196 100644 --- a/static/app/components/feedback/feedbackItem/feedbackActivitySection.tsx +++ b/static/app/components/feedback/feedbackItem/feedbackActivitySection.tsx @@ -1,5 +1,4 @@ import {useCallback, useMemo} from 'react'; -import styled from '@emotion/styled'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; import {useFeedbackCache} from 'sentry/components/feedback/useFeedbackCache'; @@ -108,21 +107,15 @@ export function FeedbackActivitySection(props: Props) { ); return ( - - - + ); } - -const ActivitySectionContainer = styled('div')` - padding-bottom: ${p => p.theme.space.xl}; -`; diff --git a/static/app/components/feedback/feedbackItem/feedbackItem.tsx b/static/app/components/feedback/feedbackItem/feedbackItem.tsx index f0fa0fb33f13..f3fd460af3a8 100644 --- a/static/app/components/feedback/feedbackItem/feedbackItem.tsx +++ b/static/app/components/feedback/feedbackItem/feedbackItem.tsx @@ -195,12 +195,11 @@ function FeedbackItemContexts({ ); } -// 0 padding-bottom because has space(2) built-in. const OverflowPanelItem = styled(PanelItem)` overflow: auto; flex-direction: column; flex-grow: 1; gap: ${p => p.theme.space.xl}; - padding: ${p => p.theme.space.xl} ${p => p.theme.space.xl} 0 ${p => p.theme.space.xl}; + padding: ${p => p.theme.space.xl}; `; diff --git a/static/app/views/issueDetails/streamline/sidebar/activityDrawer.tsx b/static/app/views/issueDetails/streamline/sidebar/activityDrawer.tsx index a503d2d7265e..d7b731059025 100644 --- a/static/app/views/issueDetails/streamline/sidebar/activityDrawer.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/activityDrawer.tsx @@ -80,7 +80,8 @@ export function ActivityDrawer({group, project}: ActivityDrawerProps) { { }, }); - render(); + render(); await userEvent.type(screen.getByPlaceholderText('Add a comment…'), '@jane'); await userEvent.click(await screen.findByRole('option', {name: 'Jane Doe'})); @@ -321,7 +321,13 @@ describe('StreamlinedActivitySection', () => { project, }); - render(); + render( + + ); for (const activity of activities) { expect( @@ -359,7 +365,8 @@ describe('StreamlinedActivitySection', () => { render( ); diff --git a/static/app/views/issueDetails/streamline/sidebar/activitySection.tsx b/static/app/views/issueDetails/streamline/sidebar/activitySection.tsx index 4e1d2d088701..9ed010733a3d 100644 --- a/static/app/views/issueDetails/streamline/sidebar/activitySection.tsx +++ b/static/app/views/issueDetails/streamline/sidebar/activitySection.tsx @@ -52,7 +52,7 @@ function TimelineItem({ handleUpdate, group, teams, - isDrawer, + size, inputVariant, }: { group: Group; @@ -60,8 +60,8 @@ function TimelineItem({ handleUpdate: (item: GroupActivity, n: NoteType) => void; inputVariant: 'compact' | 'full'; item: GroupActivity; + size: 'sm' | 'md'; teams: Team[]; - isDrawer?: boolean; }) { const organization = useOrganization(); const [editing, setEditing] = useState(false); @@ -125,11 +125,11 @@ function TimelineItem({ onCancel={() => setEditing(false)} /> ) : typeof message === 'string' ? ( - + ) : ( - {message} + {message} )} ); @@ -153,16 +153,14 @@ interface StreamlinedActivitySectionProps { onCreate?: (n: NoteType, me: User) => void; onDelete?: (item: GroupActivity) => void; onUpdate?: (item: GroupActivity, n: NoteType) => void; - placeholder?: string; /** * Controls layout and input style. * - `sidebar` (default): fold section, compact input, collapses at 5 items - * - `drawer`: full input, no collapse, larger text - * - `inline`: full input, no collapse - * TODO: Revisit whether `drawer` and `inline` should be one variant with - * an explicit density/text-size option after the feedback activity split. + * - `standalone`: full input, no collapse */ - variant?: 'sidebar' | 'drawer' | 'inline'; + placeholder?: string; + size?: 'sm' | 'md'; + variant?: 'sidebar' | 'standalone'; } export function StreamlinedActivitySection({ @@ -172,6 +170,7 @@ export function StreamlinedActivitySection({ onDelete: onDeleteProp, onUpdate: onUpdateProp, variant = 'sidebar', + size = 'sm', minHeight = 96, placeholder = t('Add a comment\u2026'), }: StreamlinedActivitySectionProps) { @@ -298,7 +297,6 @@ export function StreamlinedActivitySection({ const filteredActivities = visibleActivities.filter( item => !filterComments || item.type === GroupActivityType.NOTE ); - const isDrawer = variant === 'drawer'; const inputVariant = variant === 'sidebar' ? 'compact' : 'full'; const renderActivityItem = (item: GroupActivity) => ( @@ -309,7 +307,7 @@ export function StreamlinedActivitySection({ group={group} teams={teams} key={item.id} - isDrawer={isDrawer} + size={size} inputVariant={inputVariant} /> ); @@ -334,7 +332,7 @@ export function StreamlinedActivitySection({ ); - if (variant !== 'sidebar') { + if (variant === 'standalone') { return ( {noteInput} @@ -412,13 +410,13 @@ const RotatedEllipsisIcon = styled(IconEllipsis)` transform: rotate(90deg) translateY(1px); `; -const NoteWrapper = styled('div')<{isDrawer?: boolean}>` +const NoteWrapper = styled('div')<{size: 'sm' | 'md'}>` ${textStyles} - font-size: ${p => (p.isDrawer ? p.theme.font.size.md : p.theme.font.size.sm)}; + font-size: ${p => (p.size === 'md' ? p.theme.font.size.md : p.theme.font.size.sm)}; `; -const MessageWrapper = styled('div')<{isDrawer?: boolean}>` - font-size: ${p => (p.isDrawer ? p.theme.font.size.md : p.theme.font.size.sm)}; +const MessageWrapper = styled('div')<{size: 'sm' | 'md'}>` + font-size: ${p => (p.size === 'md' ? p.theme.font.size.md : p.theme.font.size.sm)}; `; const ActivityInputFrame = styled('div')` From d50ef02e15bf1a3d80e87d71e2b93185e7005570 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Tue, 19 May 2026 16:01:50 -0700 Subject: [PATCH 3/9] ref(issues): Rename shared activity components The feedback activity work made these components shared, but a bunch of names still said Streamlined and lived under the streamline sidebar folder. Move the shared activity section and compact comment input into neutral homes, and rename the comment actions menu so it says what it is. Co-Authored-By: Codex GPT-5 --- .../activity/note/compact.spec.tsx} | 10 ++--- .../activity/note/compact.tsx} | 18 ++++----- .../activity/note/inputWithStorage.tsx | 4 +- .../feedbackItem/feedbackActivitySection.tsx | 4 +- .../commentActionsDropdown.tsx} | 4 +- .../groupActivityIcons.tsx | 0 .../groupActivityItem.tsx | 0 .../index.spec.tsx} | 40 +++++++++---------- .../index.tsx} | 18 ++++----- .../streamline/sidebar/activityDrawer.tsx | 4 +- .../streamline/sidebar/sidebar.tsx | 4 +- 11 files changed, 47 insertions(+), 59 deletions(-) rename static/app/{views/issueDetails/streamline/sidebar/note.spec.tsx => components/activity/note/compact.spec.tsx} (88%) rename static/app/{views/issueDetails/streamline/sidebar/note.tsx => components/activity/note/compact.tsx} (95%) rename static/app/views/issueDetails/{streamline/sidebar/noteDropdown.tsx => activitySection/commentActionsDropdown.tsx} (97%) rename static/app/views/issueDetails/{streamline/sidebar => activitySection}/groupActivityIcons.tsx (100%) rename static/app/views/issueDetails/{streamline/sidebar => activitySection}/groupActivityItem.tsx (100%) rename static/app/views/issueDetails/{streamline/sidebar/activitySection.spec.tsx => activitySection/index.spec.tsx} (92%) rename static/app/views/issueDetails/{streamline/sidebar/activitySection.tsx => activitySection/index.tsx} (96%) diff --git a/static/app/views/issueDetails/streamline/sidebar/note.spec.tsx b/static/app/components/activity/note/compact.spec.tsx similarity index 88% rename from static/app/views/issueDetails/streamline/sidebar/note.spec.tsx rename to static/app/components/activity/note/compact.spec.tsx index f2a7e0d3f4f0..77953ae71553 100644 --- a/static/app/views/issueDetails/streamline/sidebar/note.spec.tsx +++ b/static/app/components/activity/note/compact.spec.tsx @@ -3,10 +3,10 @@ import {UserFixture} from 'sentry-fixture/user'; import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; +import {CompactNoteInput} from 'sentry/components/activity/note/compact'; import {TeamStore} from 'sentry/stores/teamStore'; -import {StreamlinedNoteInput} from 'sentry/views/issueDetails/streamline/sidebar/note'; -describe('StreamlinedNoteInput', () => { +describe('CompactNoteInput', () => { beforeEach(() => { TeamStore.reset(); MockApiClient.addMockResponse({ @@ -17,7 +17,7 @@ describe('StreamlinedNoteInput', () => { it('can mention a member', async () => { const onCreate = jest.fn(); - render(); + render(); await userEvent.type(screen.getByRole('textbox', {name: 'Add a comment'}), '@foo'); await userEvent.click(screen.getByRole('option', {name: 'Foo Bar'})); expect(screen.getByRole('textbox')).toHaveTextContent('@Foo Bar'); @@ -42,7 +42,7 @@ describe('StreamlinedNoteInput', () => { }); const onCreate = jest.fn(); - render(); + render(); await userEvent.type(screen.getByRole('textbox', {name: 'Add a comment'}), '@nick'); await userEvent.click(await screen.findByRole('option', {name: 'Nick Search'})); @@ -56,7 +56,7 @@ describe('StreamlinedNoteInput', () => { it('can mention a team', async () => { TeamStore.loadInitialData([TeamFixture()]); const onCreate = jest.fn(); - render(); + render(); await userEvent.type(screen.getByRole('textbox', {name: 'Add a comment'}), '#team'); await userEvent.click(screen.getByRole('option', {name: '# team -slug'})); expect(screen.getByRole('textbox')).toHaveTextContent('#team-slug'); diff --git a/static/app/views/issueDetails/streamline/sidebar/note.tsx b/static/app/components/activity/note/compact.tsx similarity index 95% rename from static/app/views/issueDetails/streamline/sidebar/note.tsx rename to static/app/components/activity/note/compact.tsx index f95c5db1bcd4..d8cc3d9f0e15 100644 --- a/static/app/views/issueDetails/streamline/sidebar/note.tsx +++ b/static/app/components/activity/note/compact.tsx @@ -2,7 +2,7 @@ import {useCallback, useId, useState} from 'react'; import type {MentionsInputProps} from 'react-mentions'; import {Mention, MentionsInput} from 'react-mentions'; import type {Theme} from '@emotion/react'; -import {useTheme} from '@emotion/react'; +import {css, useTheme} from '@emotion/react'; import styled from '@emotion/styled'; import {Button} from '@sentry/scraps/button'; @@ -37,7 +37,7 @@ type Props = { text?: string; }; -function StreamlinedNoteInput({ +export function CompactNoteInput({ text, onCreate, onChange, @@ -192,18 +192,16 @@ function StreamlinedNoteInput({ ); } -export {StreamlinedNoteInput}; - const getNoteInputErrorStyles = (p: {theme: Theme; error?: string}) => { if (!p.error) { return ''; } - return ` - color: ${p.theme.tokens.content.danger}; - margin: -1px; - border: 1px solid ${p.theme.tokens.content.danger}; - border-radius: ${p.theme.radius.md}; + return css` + color: ${p.theme.tokens.content.danger}; + margin: -1px; + border: 1px solid ${p.theme.tokens.border.danger}; + border-radius: ${p.theme.radius.md}; &:before { display: block; @@ -241,5 +239,5 @@ const NoteInputForm = styled('form')<{error?: string}>` width: 100%; transition: padding 0.2s ease-in-out; - ${p => getNoteInputErrorStyles(p)}; + ${getNoteInputErrorStyles}; `; diff --git a/static/app/components/activity/note/inputWithStorage.tsx b/static/app/components/activity/note/inputWithStorage.tsx index 623354f6b501..dbc7869b006a 100644 --- a/static/app/components/activity/note/inputWithStorage.tsx +++ b/static/app/components/activity/note/inputWithStorage.tsx @@ -2,11 +2,11 @@ import {useCallback, useMemo} from 'react'; import * as Sentry from '@sentry/react'; import debounce from 'lodash/debounce'; +import {CompactNoteInput} from 'sentry/components/activity/note/compact'; import {NoteInput} from 'sentry/components/activity/note/input'; import type {MentionChangeEvent} from 'sentry/components/activity/note/types'; import type {NoteType} from 'sentry/types/alerts'; import {localStorageWrapper} from 'sentry/utils/localStorage'; -import {StreamlinedNoteInput} from 'sentry/views/issueDetails/streamline/sidebar/note'; type InputProps = React.ComponentProps; @@ -135,7 +135,7 @@ function NoteInputWithStorage({ if (variant === 'compact') { return ( - ); } - -export {NoteDropdown}; diff --git a/static/app/views/issueDetails/streamline/sidebar/groupActivityIcons.tsx b/static/app/views/issueDetails/activitySection/groupActivityIcons.tsx similarity index 100% rename from static/app/views/issueDetails/streamline/sidebar/groupActivityIcons.tsx rename to static/app/views/issueDetails/activitySection/groupActivityIcons.tsx diff --git a/static/app/views/issueDetails/streamline/sidebar/groupActivityItem.tsx b/static/app/views/issueDetails/activitySection/groupActivityItem.tsx similarity index 100% rename from static/app/views/issueDetails/streamline/sidebar/groupActivityItem.tsx rename to static/app/views/issueDetails/activitySection/groupActivityItem.tsx diff --git a/static/app/views/issueDetails/streamline/sidebar/activitySection.spec.tsx b/static/app/views/issueDetails/activitySection/index.spec.tsx similarity index 92% rename from static/app/views/issueDetails/streamline/sidebar/activitySection.spec.tsx rename to static/app/views/issueDetails/activitySection/index.spec.tsx index ca9d544fb26e..9d3acf064aa8 100644 --- a/static/app/views/issueDetails/streamline/sidebar/activitySection.spec.tsx +++ b/static/app/views/issueDetails/activitySection/index.spec.tsx @@ -18,9 +18,9 @@ import {GroupStore} from 'sentry/stores/groupStore'; import {ProjectsStore} from 'sentry/stores/projectsStore'; import type {GroupActivity} from 'sentry/types/group'; import {GroupActivityType} from 'sentry/types/group'; -import {StreamlinedActivitySection} from 'sentry/views/issueDetails/streamline/sidebar/activitySection'; +import {ActivitySection} from 'sentry/views/issueDetails/activitySection'; -describe('StreamlinedActivitySection', () => { +describe('ActivitySection', () => { const project = ProjectFixture(); const user = UserFixture(); user.options.prefersIssueDetailsStreamlinedUI = true; @@ -69,7 +69,7 @@ describe('StreamlinedActivitySection', () => { }, }); - render(); + render(); const commentInput = screen.getByPlaceholderText('Add a comment…'); expect(commentInput).toBeInTheDocument(); @@ -105,7 +105,7 @@ describe('StreamlinedActivitySection', () => { }, }); - render(); + render(); const commentInput = screen.getByPlaceholderText('Add a comment…'); await userEvent.type(commentInput, comment); @@ -131,7 +131,7 @@ describe('StreamlinedActivitySection', () => { }, }); - render(); + render(); await userEvent.type(screen.getByPlaceholderText('Add a comment…'), '@jane'); await userEvent.click(await screen.findByRole('option', {name: 'Jane Doe'})); @@ -155,7 +155,7 @@ describe('StreamlinedActivitySection', () => { method: 'DELETE', }); - render(); + render(); renderGlobalModal(); expect(await screen.findByText('Test Note')).toBeInTheDocument(); @@ -199,7 +199,7 @@ describe('StreamlinedActivitySection', () => { }, }); - render(); + render(); expect(await screen.findByText('Group Test')).toBeInTheDocument(); await userEvent.click(screen.getByRole('button', {name: 'Comment Actions'})); @@ -249,7 +249,7 @@ describe('StreamlinedActivitySection', () => { ], }); - render(); + render(); expect( await screen.findByText('This note came from my sentry app') ).toBeInTheDocument(); @@ -274,7 +274,7 @@ describe('StreamlinedActivitySection', () => { project, }); - render(); + render(); expect(await screen.findByText('Test Note')).toBeInTheDocument(); expect( @@ -298,7 +298,7 @@ describe('StreamlinedActivitySection', () => { project, }); - render(); + render(); expect(await screen.findByText('Test Note 1')).toBeInTheDocument(); expect(await screen.findByText('Test Note 3')).toBeInTheDocument(); expect(screen.queryByText('Test Note 7')).not.toBeInTheDocument(); @@ -322,11 +322,7 @@ describe('StreamlinedActivitySection', () => { }); render( - + ); for (const activity of activities) { @@ -363,7 +359,7 @@ describe('StreamlinedActivitySection', () => { }); render( - { project, }); - render(); + render(); expect(await screen.findByText('Resolved')).toBeInTheDocument(); expect(screen.getByRole('link', {name: '1.0.0'})).toBeInTheDocument(); expect(screen.getByRole('link', {name: 'Jira Server'})).toBeInTheDocument(); @@ -425,7 +421,7 @@ describe('StreamlinedActivitySection', () => { project, }); - render(); + render(); expect(await screen.findByText('Resolved')).toBeInTheDocument(); expect(screen.getByRole('link', {name: '1.0.0'})).toBeInTheDocument(); }); @@ -449,7 +445,7 @@ describe('StreamlinedActivitySection', () => { project, }); - render(); + render(); expect(await screen.findByText('Referenced in Commit')).toBeInTheDocument(); expect(screen.getByText('f7f395d')).toBeInTheDocument(); }); @@ -471,7 +467,7 @@ describe('StreamlinedActivitySection', () => { const org = OrganizationFixture({features: ['seer-activity-timeline']}); - render(, {organization: org}); + render(, {organization: org}); expect(await screen.findByText('Root Cause Analysis')).toBeInTheDocument(); expect(screen.getByText('Seer completed root cause analysis')).toBeInTheDocument(); }); @@ -491,7 +487,7 @@ describe('StreamlinedActivitySection', () => { project, }); - render(); + render(); expect(screen.queryByText('Root Cause Analysis')).not.toBeInTheDocument(); expect( screen.queryByText('Seer completed root cause analysis') @@ -527,7 +523,7 @@ describe('StreamlinedActivitySection', () => { const org = OrganizationFixture({features: ['seer-activity-timeline']}); - render(, {organization: org}); + render(, {organization: org}); expect(await screen.findByText('Pull Request Created')).toBeInTheDocument(); expect(screen.getByRole('link', {name: 'pull request'})).toHaveAttribute( 'href', diff --git a/static/app/views/issueDetails/streamline/sidebar/activitySection.tsx b/static/app/views/issueDetails/activitySection/index.tsx similarity index 96% rename from static/app/views/issueDetails/streamline/sidebar/activitySection.tsx rename to static/app/views/issueDetails/activitySection/index.tsx index 9ed010733a3d..eef917abbf53 100644 --- a/static/app/views/issueDetails/streamline/sidebar/activitySection.tsx +++ b/static/app/views/issueDetails/activitySection/index.tsx @@ -27,11 +27,11 @@ import {useLocation} from 'sentry/utils/useLocation'; import {useOrganization} from 'sentry/utils/useOrganization'; import {useTeamsById} from 'sentry/utils/useTeamsById'; import {useUser} from 'sentry/utils/useUser'; +import {CommentActionsDropdown} from 'sentry/views/issueDetails/activitySection/commentActionsDropdown'; +import {groupActivityTypeIconMapping} from 'sentry/views/issueDetails/activitySection/groupActivityIcons'; +import {getGroupActivityItem} from 'sentry/views/issueDetails/activitySection/groupActivityItem'; import {SectionKey} from 'sentry/views/issueDetails/streamline/context'; import {SidebarFoldSection} from 'sentry/views/issueDetails/streamline/foldSection'; -import {groupActivityTypeIconMapping} from 'sentry/views/issueDetails/streamline/sidebar/groupActivityIcons'; -import {getGroupActivityItem} from 'sentry/views/issueDetails/streamline/sidebar/groupActivityItem'; -import {NoteDropdown} from 'sentry/views/issueDetails/streamline/sidebar/noteDropdown'; import {SidebarSectionTitle} from 'sentry/views/issueDetails/streamline/sidebar/sidebar'; import {Tab, TabPaths} from 'sentry/views/issueDetails/types'; import {useGroupDetailsRoute} from 'sentry/views/issueDetails/useGroupDetailsRoute'; @@ -91,7 +91,7 @@ function TimelineItem({ {title} {item.type === GroupActivityType.NOTE && !editing && ( - handleDelete(item)} onEdit={() => setEditing(true)} user={item.user} @@ -143,7 +143,7 @@ function ActivityNoteInput(props: React.ComponentProps - )} - + {showPeopleSection && ( Date: Tue, 19 May 2026 16:07:06 -0700 Subject: [PATCH 4/9] ref(issues): Use Text for activity messages Use the Text primitive for non-note activity messages instead of wiring font-size by hand. keeps the activity section closer to the design system now that it is shared outside the old sidebar. Co-Authored-By: Codex GPT-5 --- static/app/views/issueDetails/activitySection/index.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/static/app/views/issueDetails/activitySection/index.tsx b/static/app/views/issueDetails/activitySection/index.tsx index eef917abbf53..7bb8f2bf9d4f 100644 --- a/static/app/views/issueDetails/activitySection/index.tsx +++ b/static/app/views/issueDetails/activitySection/index.tsx @@ -4,6 +4,7 @@ import styled from '@emotion/styled'; import {LinkButton} from '@sentry/scraps/button'; import {Flex, Grid} from '@sentry/scraps/layout'; +import {Text} from '@sentry/scraps/text'; import {Tooltip} from '@sentry/scraps/tooltip'; import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator'; @@ -129,7 +130,9 @@ function TimelineItem({ ) : ( - {message} + + {message} + )} ); @@ -411,10 +414,6 @@ const NoteWrapper = styled('div')<{size: 'sm' | 'md'}>` font-size: ${p => (p.size === 'md' ? p.theme.font.size.md : p.theme.font.size.sm)}; `; -const MessageWrapper = styled('div')<{size: 'sm' | 'md'}>` - font-size: ${p => (p.size === 'md' ? p.theme.font.size.md : p.theme.font.size.sm)}; -`; - const ActivityInputFrame = styled('div')` color: ${p => p.theme.tokens.content.primary}; `; From 15060e5e2cf47c175a9dad18d4ddc89debb77c6e Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 20 May 2026 13:42:08 -0700 Subject: [PATCH 5/9] fix(feedback): Label spam activity correctly Feedback spam activity was falling through the shared issue activity copy and showing up as archived forever. Pass the issue category into the activity formatter so feedback keeps the spam label, with a test covering the regression. Co-Authored-By: Codex GPT-5 --- .../feedbackActivitySection.spec.tsx | 30 ++++++++++++++++++- .../activitySection/groupActivityItem.tsx | 24 ++++++++++----- .../issueDetails/activitySection/index.tsx | 1 + 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/static/app/components/feedback/feedbackItem/feedbackActivitySection.spec.tsx b/static/app/components/feedback/feedbackItem/feedbackActivitySection.spec.tsx index 5368e151a6f2..0d00c36b77d6 100644 --- a/static/app/components/feedback/feedbackItem/feedbackActivitySection.spec.tsx +++ b/static/app/components/feedback/feedbackItem/feedbackActivitySection.spec.tsx @@ -12,7 +12,7 @@ import { import {FeedbackActivitySection} from 'sentry/components/feedback/feedbackItem/feedbackActivitySection'; import {FeedbackApiOptions} from 'sentry/components/feedback/useFeedbackApiOptions'; -import {GroupActivityType} from 'sentry/types/group'; +import {GroupActivityType, IssueCategory} from 'sentry/types/group'; describe('FeedbackActivitySection', () => { const organization = OrganizationFixture(); @@ -141,4 +141,32 @@ describe('FeedbackActivitySection', () => { expect(editFrame).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Cancel'})).toBeEnabled(); }); + + it('renders ignored feedback activity as spam', async () => { + const feedbackItem = GroupFixture({ + id: '1337', + activity: [ + { + type: GroupActivityType.SET_IGNORED, + id: 'spam-1', + data: {}, + dateCreated: '2020-01-01T00:00:00', + user, + }, + ], + issueCategory: IssueCategory.FEEDBACK, + project, + }); + + render( + + + , + {organization} + ); + + expect(await screen.findByText('Marked as Spam')).toBeInTheDocument(); + expect(screen.queryByText('Archived')).not.toBeInTheDocument(); + expect(screen.queryByText(/forever/)).not.toBeInTheDocument(); + }); }); diff --git a/static/app/views/issueDetails/activitySection/groupActivityItem.tsx b/static/app/views/issueDetails/activitySection/groupActivityItem.tsx index 92d3f281224a..6ca536ee9f56 100644 --- a/static/app/views/issueDetails/activitySection/groupActivityItem.tsx +++ b/static/app/views/issueDetails/activitySection/groupActivityItem.tsx @@ -16,8 +16,9 @@ import type { GroupActivityAssigned, GroupActivitySetEscalating, GroupActivitySetIgnored, + IssueCategory, } from 'sentry/types/group'; -import {GroupActivityType} from 'sentry/types/group'; +import {GroupActivityType, IssueCategory as IssueCategoryEnum} from 'sentry/types/group'; import type {Organization, Team} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import type {User} from 'sentry/types/user'; @@ -28,10 +29,12 @@ export function getGroupActivityItem( activity: GroupActivity, organization: Organization, project: Project, + issueCategory: IssueCategory, author: React.ReactNode, teams: Team[] ) { const issuesLink = `/organizations/${organization.slug}/issues/`; + const isFeedback = issueCategory === IssueCategoryEnum.FEEDBACK; function getIgnoredMessage(data: GroupActivitySetIgnored['data']): { message: React.JSX.Element | string | null; @@ -107,12 +110,19 @@ export function getGroupActivityItem( }; } - return { - title: t('Archived'), - message: tct('by [author] forever', { - author, - }), - }; + return isFeedback + ? { + title: t('Marked as Spam'), + message: tct('by [author]', { + author, + }), + } + : { + title: t('Archived'), + message: tct('by [author] forever', { + author, + }), + }; } function getAssignedMessage(assignedActivity: GroupActivityAssigned) { diff --git a/static/app/views/issueDetails/activitySection/index.tsx b/static/app/views/issueDetails/activitySection/index.tsx index 7bb8f2bf9d4f..b79f8da5d6d1 100644 --- a/static/app/views/issueDetails/activitySection/index.tsx +++ b/static/app/views/issueDetails/activitySection/index.tsx @@ -71,6 +71,7 @@ function TimelineItem({ item, organization, group.project, + group.issueCategory, {authorName}, teams ); From 930a12e66a12957d1e02a7883d948da852a16d5d Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 20 May 2026 14:53:18 -0700 Subject: [PATCH 6/9] feat(issues): Gate two-column activity icons Adds a FlagPole flag for the updated issue activity row treatment. When enabled, activity rows split actor markers from action icons, color the action icon, and carry that same color onto user avatar rings or system dots. When disabled, the existing one-column activity layout stays in place. Co-Authored-By: Codex GPT-5 --- src/sentry/features/temporary.py | 2 + static/app/components/timeline/index.tsx | 27 +++- .../activitySection/index.spec.tsx | 52 ++++++++ .../issueDetails/activitySection/index.tsx | 118 ++++++++++++++++-- 4 files changed, 184 insertions(+), 15 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 2229b461781b..d7a352a2765a 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -329,6 +329,8 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:session-replay-recording-scrubbing", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False) # Enable core Session Replay link in the sidebar manager.add("organizations:session-replay-ui", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, default=True, api_expose=True) + # Enable two-column activity icons on issue details + manager.add("organizations:issue-activity-two-column-icons", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable new stack trace component for issue details manager.add("organizations:issue-details-new-stack-trace", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable double-reading from EAP for issue feed search queries diff --git a/static/app/components/timeline/index.tsx b/static/app/components/timeline/index.tsx index d425cc24e98d..79c0a4c6ead7 100644 --- a/static/app/components/timeline/index.tsx +++ b/static/app/components/timeline/index.tsx @@ -19,6 +19,7 @@ export interface TimelineItemProps { 'data-index'?: number; icon?: React.ReactNode; isActive?: boolean; + marker?: React.ReactNode; onClick?: React.MouseEventHandler; onMouseEnter?: React.MouseEventHandler; onMouseLeave?: React.MouseEventHandler; @@ -33,6 +34,7 @@ function Item({ title, children, icon, + marker, colorConfig, timestamp, isActive = false, @@ -42,9 +44,11 @@ function Item({ }: TimelineItemProps) { const theme = useTheme(); const config = colorConfig ?? makeDefaultColorConfig(theme); + const hasMarker = marker !== undefined; return ( - + + {hasMarker && {marker}} {icon ? ( {timestamp ??
} - {children} + {children} ); } @@ -77,12 +81,14 @@ function makeDefaultColorConfig(theme: Theme) { }; } -const Row = styled('div')<{showLastLine?: boolean}>` +const Row = styled('div')<{hasMarker: boolean; showLastLine?: boolean}>` position: relative; color: ${p => p.theme.tokens.content.secondary}; display: grid; align-items: start; - grid-template: auto auto / 22px 1fr auto; + grid-template-rows: auto auto; + grid-template-columns: ${p => + p.hasMarker ? '22px 22px minmax(50px, 1fr) auto' : '22px minmax(50px, 1fr) auto'}; grid-column-gap: ${p => p.theme.space.md}; margin: ${p => p.theme.space.md} 0; &:first-child { @@ -96,6 +102,15 @@ const Row = styled('div')<{showLastLine?: boolean}>` } `; +const MarkerWrapper = styled('div')` + grid-column: span 1; + z-index: 10; + display: grid; + place-items: center; + min-width: 22px; + min-height: 22px; +`; + const IconWrapper = styled('div')` grid-column: span 1; border-radius: 100%; @@ -115,9 +130,9 @@ const Title = styled('div')` font-size: ${p => p.theme.font.size.md}; `; -const Content = styled('div')` +const Content = styled('div')<{hasMarker: boolean}>` text-align: left; - grid-column: span 2; + grid-column: ${p => (p.hasMarker ? '3 / -1' : 'span 2')}; color: ${p => p.theme.tokens.content.secondary}; margin: ${p => p.theme.space['2xs']} 0 0; font-size: ${p => p.theme.font.size.sm}; diff --git a/static/app/views/issueDetails/activitySection/index.spec.tsx b/static/app/views/issueDetails/activitySection/index.spec.tsx index 9d3acf064aa8..ab65f73d5606 100644 --- a/static/app/views/issueDetails/activitySection/index.spec.tsx +++ b/static/app/views/issueDetails/activitySection/index.spec.tsx @@ -174,6 +174,58 @@ describe('ActivitySection', () => { expect(screen.queryByText('Test Note')).not.toBeInTheDocument(); }); + it('renders activity actor markers', async () => { + const activityGroup = GroupFixture({ + id: '1338', + activity: [ + { + type: GroupActivityType.NOTE, + id: 'note-1', + data: {text: 'User note'}, + dateCreated: '2020-01-01T00:00:00', + user, + }, + { + type: GroupActivityType.SET_RESOLVED, + id: 'resolved-1', + data: {}, + dateCreated: '2020-01-02T00:00:00', + user: null, + }, + ], + project, + }); + + render(, { + organization: OrganizationFixture({features: ['issue-activity-two-column-icons']}), + }); + + expect(await screen.findByText('User note')).toBeInTheDocument(); + expect(screen.getByTestId('user-activity-marker')).toBeInTheDocument(); + expect(screen.getByTestId('sentry-activity-marker')).toBeInTheDocument(); + }); + + it('does not render activity actor markers when the feature is disabled', async () => { + const activityGroup = GroupFixture({ + id: '1338', + activity: [ + { + type: GroupActivityType.NOTE, + id: 'note-1', + data: {text: 'User note'}, + dateCreated: '2020-01-01T00:00:00', + user, + }, + ], + project, + }); + + render(); + + expect(await screen.findByText('User note')).toBeInTheDocument(); + expect(screen.queryByTestId('user-activity-marker')).not.toBeInTheDocument(); + }); + it('renders note and allows for edit', async () => { jest.spyOn(indicators, 'addSuccessMessage'); diff --git a/static/app/views/issueDetails/activitySection/index.tsx b/static/app/views/issueDetails/activitySection/index.tsx index b79f8da5d6d1..d91b9a386285 100644 --- a/static/app/views/issueDetails/activitySection/index.tsx +++ b/static/app/views/issueDetails/activitySection/index.tsx @@ -1,7 +1,8 @@ import {Fragment, useCallback, useState} from 'react'; -import {useTheme} from '@emotion/react'; +import {useTheme, type Theme} from '@emotion/react'; import styled from '@emotion/styled'; +import {SentryAppAvatar, UserAvatar} from '@sentry/scraps/avatar'; import {LinkButton} from '@sentry/scraps/button'; import {Flex, Grid} from '@sentry/scraps/layout'; import {Text} from '@sentry/scraps/text'; @@ -47,6 +48,75 @@ function getAuthorName(item: GroupActivity) { return 'Sentry'; } +function getActivityMarker(item: GroupActivity, color: string) { + if (item.sentry_app) { + return ( + + + + ); + } + if (item.user) { + return ( + + + + ); + } + return ; +} + +function getActivityColorConfig(theme: Theme, type: GroupActivityType) { + const defaultConfig = { + title: theme.tokens.content.primary, + icon: theme.tokens.content.secondary, + iconBorder: theme.tokens.content.secondary, + }; + + switch (type) { + case GroupActivityType.SET_RESOLVED: + case GroupActivityType.SET_RESOLVED_BY_AGE: + case GroupActivityType.SET_RESOLVED_IN_RELEASE: + case GroupActivityType.SET_RESOLVED_IN_COMMIT: + case GroupActivityType.MARK_REVIEWED: + case GroupActivityType.SEER_RCA_COMPLETED: + case GroupActivityType.SEER_SOLUTION_COMPLETED: + case GroupActivityType.SEER_CODING_COMPLETED: + case GroupActivityType.SEER_PR_CREATED: + return { + ...defaultConfig, + icon: theme.tokens.graphics.success.vibrant, + iconBorder: theme.tokens.border.success.vibrant, + }; + case GroupActivityType.SET_UNRESOLVED: + case GroupActivityType.SET_REGRESSION: + return { + ...defaultConfig, + icon: theme.tokens.graphics.danger.vibrant, + iconBorder: theme.tokens.border.danger.vibrant, + }; + case GroupActivityType.SET_ESCALATING: + case GroupActivityType.SET_PRIORITY: + return { + ...defaultConfig, + icon: theme.tokens.graphics.warning.vibrant, + iconBorder: theme.tokens.border.warning.vibrant, + }; + case GroupActivityType.SET_IGNORED: + return { + ...defaultConfig, + icon: theme.tokens.graphics.promotion.vibrant, + iconBorder: theme.tokens.border.promotion.vibrant, + }; + default: + return defaultConfig; + } +} + function TimelineItem({ item, handleDelete, @@ -65,8 +135,13 @@ function TimelineItem({ teams: Team[]; }) { const organization = useOrganization(); + const theme = useTheme(); const [editing, setEditing] = useState(false); + const useTwoColumnLayout = organization.features.includes( + 'issue-activity-two-column-icons' + ); const authorName = getAuthorName(item); + const colorConfig = getActivityColorConfig(theme, item.type); const {title, message} = getGroupActivityItem( item, organization, @@ -77,13 +152,14 @@ function TimelineItem({ ); const iconMapping = groupActivityTypeIconMapping[item.type]; - const Icon = iconMapping?.componentFunction - ? iconMapping.componentFunction({ - data: item.data, - user: item.user, - sentry_app: item.sentry_app, - }) - : (iconMapping?.Component ?? null); + const Icon = + !useTwoColumnLayout && iconMapping?.componentFunction + ? iconMapping.componentFunction({ + data: item.data, + user: item.user, + sentry_app: item.sentry_app, + }) + : (iconMapping?.Component ?? null); return ( } timestamp={} + marker={useTwoColumnLayout ? getActivityMarker(item, colorConfig.icon) : undefined} + colorConfig={useTwoColumnLayout ? colorConfig : undefined} icon={ Icon && ( ` const ActivityInputFrame = styled('div')` color: ${p => p.theme.tokens.content.primary}; `; + +const AvatarMarker = styled('span')<{color: string}>` + display: block; + border-radius: 100%; + box-shadow: 0 0 0 1px ${p => p.color}; +`; + +const SentryMarker = styled('span')<{color: string}>` + width: 12px; + height: 12px; + border-radius: 100%; + background: ${p => p.theme.tokens.background.primary}; + display: grid; + place-items: center; + + &::after { + content: ''; + width: 6px; + height: 6px; + border-radius: 100%; + background: ${p => p.color}; + } +`; From 567cf7f8917dbc109cd245e5bdab80dbf2df92be Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 20 May 2026 15:14:19 -0700 Subject: [PATCH 7/9] feat(issues): Use activity feed v2 flag Point the activity row UI at activity-feed-v2 and drop the backend flag registration from this stacked branch. The flag itself now lives in the standalone master PR. Co-Authored-By: Codex GPT-5 --- src/sentry/features/temporary.py | 2 -- static/app/views/issueDetails/activitySection/index.spec.tsx | 2 +- static/app/views/issueDetails/activitySection/index.tsx | 4 +--- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index d7a352a2765a..2229b461781b 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -329,8 +329,6 @@ def register_temporary_features(manager: FeatureManager) -> None: manager.add("organizations:session-replay-recording-scrubbing", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, api_expose=False) # Enable core Session Replay link in the sidebar manager.add("organizations:session-replay-ui", OrganizationFeature, FeatureHandlerStrategy.INTERNAL, default=True, api_expose=True) - # Enable two-column activity icons on issue details - manager.add("organizations:issue-activity-two-column-icons", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable new stack trace component for issue details manager.add("organizations:issue-details-new-stack-trace", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE, api_expose=True) # Enable double-reading from EAP for issue feed search queries diff --git a/static/app/views/issueDetails/activitySection/index.spec.tsx b/static/app/views/issueDetails/activitySection/index.spec.tsx index ab65f73d5606..0662b930229f 100644 --- a/static/app/views/issueDetails/activitySection/index.spec.tsx +++ b/static/app/views/issueDetails/activitySection/index.spec.tsx @@ -197,7 +197,7 @@ describe('ActivitySection', () => { }); render(, { - organization: OrganizationFixture({features: ['issue-activity-two-column-icons']}), + organization: OrganizationFixture({features: ['activity-feed-v2']}), }); expect(await screen.findByText('User note')).toBeInTheDocument(); diff --git a/static/app/views/issueDetails/activitySection/index.tsx b/static/app/views/issueDetails/activitySection/index.tsx index d91b9a386285..4be6fca5698c 100644 --- a/static/app/views/issueDetails/activitySection/index.tsx +++ b/static/app/views/issueDetails/activitySection/index.tsx @@ -137,9 +137,7 @@ function TimelineItem({ const organization = useOrganization(); const theme = useTheme(); const [editing, setEditing] = useState(false); - const useTwoColumnLayout = organization.features.includes( - 'issue-activity-two-column-icons' - ); + const useTwoColumnLayout = organization.features.includes('activity-feed-v2'); const authorName = getAuthorName(item); const colorConfig = getActivityColorConfig(theme, item.type); const {title, message} = getGroupActivityItem( From 997a2fbf9af5b71cbacd67ea2eedf91ae24c7a42 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 20 May 2026 15:30:40 -0700 Subject: [PATCH 8/9] fix(issues): Tune activity marker colors Make archived activity use the default grey state and move avatar rings to a 2px inset border. Keeps the activity-feed-v2 flag check in place after local testing. Co-Authored-By: Codex GPT-5 --- .../views/issueDetails/activitySection/index.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/static/app/views/issueDetails/activitySection/index.tsx b/static/app/views/issueDetails/activitySection/index.tsx index 4be6fca5698c..d9b858dbf3bc 100644 --- a/static/app/views/issueDetails/activitySection/index.tsx +++ b/static/app/views/issueDetails/activitySection/index.tsx @@ -109,8 +109,6 @@ function getActivityColorConfig(theme: Theme, type: GroupActivityType) { case GroupActivityType.SET_IGNORED: return { ...defaultConfig, - icon: theme.tokens.graphics.promotion.vibrant, - iconBorder: theme.tokens.border.promotion.vibrant, }; default: return defaultConfig; @@ -496,8 +494,18 @@ const ActivityInputFrame = styled('div')` const AvatarMarker = styled('span')<{color: string}>` display: block; + position: relative; border-radius: 100%; - box-shadow: 0 0 0 1px ${p => p.color}; + line-height: 0; + + &::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 100%; + box-shadow: inset 0 0 0 2px ${p => p.color}; + pointer-events: none; + } `; const SentryMarker = styled('span')<{color: string}>` From fde9e8eb4df2fd36daf8d5e18f91096d6c81a409 Mon Sep 17 00:00:00 2001 From: Scott Cooper Date: Wed, 20 May 2026 15:51:41 -0700 Subject: [PATCH 9/9] feat(issues): Use issue activity feed v2 flag --- static/app/views/issueDetails/activitySection/index.spec.tsx | 2 +- static/app/views/issueDetails/activitySection/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/static/app/views/issueDetails/activitySection/index.spec.tsx b/static/app/views/issueDetails/activitySection/index.spec.tsx index 0662b930229f..a801e182bd08 100644 --- a/static/app/views/issueDetails/activitySection/index.spec.tsx +++ b/static/app/views/issueDetails/activitySection/index.spec.tsx @@ -197,7 +197,7 @@ describe('ActivitySection', () => { }); render(, { - organization: OrganizationFixture({features: ['activity-feed-v2']}), + organization: OrganizationFixture({features: ['issue-activity-feed-v2']}), }); expect(await screen.findByText('User note')).toBeInTheDocument(); diff --git a/static/app/views/issueDetails/activitySection/index.tsx b/static/app/views/issueDetails/activitySection/index.tsx index d9b858dbf3bc..f27843506575 100644 --- a/static/app/views/issueDetails/activitySection/index.tsx +++ b/static/app/views/issueDetails/activitySection/index.tsx @@ -135,7 +135,7 @@ function TimelineItem({ const organization = useOrganization(); const theme = useTheme(); const [editing, setEditing] = useState(false); - const useTwoColumnLayout = organization.features.includes('activity-feed-v2'); + const useTwoColumnLayout = organization.features.includes('issue-activity-feed-v2'); const authorName = getAuthorName(item); const colorConfig = getActivityColorConfig(theme, item.type); const {title, message} = getGroupActivityItem(