Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions static/app/components/core/chat/assistantActions.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
title: AssistantActions
description: Thumbs up/down feedback and a copy button for an agent's response.
category: chat
source: '@sentry/scraps/chat'
resources:
js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/chat/assistantActions.tsx
---

import {AssistantActions} from '@sentry/scraps/chat';

import * as Storybook from 'sentry/stories';

export const documentation = import('!!type-loader!@sentry/scraps/chat/assistantActions');

The `AssistantActions` renders the action bar for an agent's response: thumbs
up/down feedback and a copy button.

It is presentation only. It reports votes through `onFeedback` and copies
through `onCopy`, and holds no submitted state — the caller decides what a vote
means, what to copy, and owns any analytics or persistence. Placement and
hover-reveal are the caller's responsibility via the forwarded `ButtonBar`
props.

## With copy button

Pass `copyText` to show the copy button alongside feedback.

<Storybook.Demo>
<AssistantActions onFeedback={() => {}} copyText="The agent response." />
</Storybook.Demo>

```jsx
<AssistantActions
onFeedback={feedback => recordFeedback(feedback)}
copyText={response}
onCopy={() => trackCopy()}
/>
```

## Without copy button

Omit `copyText` (or leave it empty) to render feedback only.

<Storybook.Demo>
<AssistantActions onFeedback={() => {}} />
</Storybook.Demo>

```jsx
<AssistantActions onFeedback={recordFeedback} />
```

## Disabled

Set `feedbackDisabled` once a vote has been recorded to lock both feedback
buttons and switch their tooltip to a submitted state. Hover a thumb to see the
"Feedback submitted" tooltip.

<Storybook.Demo>
<AssistantActions
onFeedback={() => {}}
feedbackDisabled
copyText="The agent response."
/>
</Storybook.Demo>

```jsx
<AssistantActions onFeedback={recordFeedback} feedbackDisabled copyText={response} />
```
104 changes: 104 additions & 0 deletions static/app/components/core/chat/assistantActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import {Button, ButtonBar, type ButtonBarProps} from '@sentry/scraps/button';

import {CopyToClipboardButton} from 'sentry/components/copyToClipboardButton';
import {IconThumb} from 'sentry/icons';
import {t} from 'sentry/locale';

type AssistantFeedback = 'positive' | 'negative';

interface AssistantActionsProps extends Omit<ButtonBarProps, 'children' | 'onCopy'> {
/**
* Text copied by the copy button. When omitted or empty, the copy button is
* hidden.
*/
copyText?: string;
/**
* Locks the feedback buttons — e.g. once a vote has been recorded — and
* switches their tooltip to a submitted state.
*/
feedbackDisabled?: boolean;
/**
* Fired after the response is copied. Analytics are the caller's
* responsibility.
*/
onCopy?: (copiedText: string) => void;
/**
* Fired when the user votes on the response. The meaning of a vote and any
* analytics/persistence are the caller's responsibility.
*/
onFeedback?: (feedback: AssistantFeedback) => void;
}

/**
* The action bar for an assistant's response: thumbs up/down feedback and a
* copy button.
*
* Presentation only — it renders the controls and reports votes through
* `onFeedback` and copies through `onCopy`. It holds no submitted state and
* fires no analytics; the caller owns what a vote means and what to copy.
* Placement (and any hover-reveal) is the caller's responsibility via the
* forwarded `ButtonBar` props.
*/
export function AssistantActions({
onFeedback,
feedbackDisabled,
copyText,
onCopy,
size = 'xs',
...props
}: AssistantActionsProps) {
return (
<ButtonBar size={size} {...props}>
<FeedbackButton
feedback="positive"
disabled={feedbackDisabled}
onClick={onFeedback}
/>
<FeedbackButton
feedback="negative"
disabled={feedbackDisabled}
onClick={onFeedback}
/>
{copyText ? (
<CopyToClipboardButton
aria-label={t('Copy to clipboard')}
text={copyText}
tooltipProps={{title: t('Copy to clipboard')}}
Comment thread
cursor[bot] marked this conversation as resolved.
onCopy={onCopy}
onClick={e => {
e.stopPropagation();
}}
/>
) : null}
</ButtonBar>
);
}

function FeedbackButton({
feedback,
disabled,
onClick,
}: {
feedback: AssistantFeedback;
disabled?: boolean;
onClick?: (feedback: AssistantFeedback) => void;
}) {
const isPositive = feedback === 'positive';
const label = disabled
? t('Feedback submitted')
: isPositive
? t('I like this response')
: t("I don't like this response");
return (
<Button
aria-label={label}
icon={<IconThumb direction={isPositive ? 'up' : 'down'} />}
disabled={disabled}
tooltipProps={{title: label}}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate disabled feedback labels

Low Severity

When feedbackDisabled is set, both thumb buttons share the same aria-label and tooltip text (Feedback submitted). Previously each control kept a distinct accessible name while only the tooltip switched to the submitted state, so assistive tech could still tell the two buttons apart after a vote.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 9f645ad. Configure here.

onClick={e => {
e.stopPropagation();
onClick?.(feedback);
}}
/>
);
}
51 changes: 51 additions & 0 deletions static/app/components/core/chat/assistantMessage.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
---
title: AssistantMessage
description: The content region for an agent's response in a conversation.
category: chat
source: '@sentry/scraps/chat'
resources:
js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/chat/assistantMessage.tsx
---

import {AssistantMessage, MessageRow, UserBubble} from '@sentry/scraps/chat';
import {Stack} from '@sentry/scraps/layout';

import * as Storybook from 'sentry/stories';

export const documentation = import('!!type-loader!@sentry/scraps/chat/assistantMessage');

The `AssistantMessage` renders the content region for an agent's response.
Unlike the sender's own messages — which are bubbled with `UserBubble` — an
agent response is not bubbled: it renders as full-width, left-aligned content so
rich output like markdown, tables, and code reads naturally.

## Usage

Shown here in a conversation for context; on its own it is just the agent's
content region.

<Storybook.Demo align="stretch">
<Stack width="100%">
<MessageRow from="user">
<UserBubble>How many errors happened in the last 24 hours?</UserBubble>
</MessageRow>
<MessageRow from="assistant">
<AssistantMessage>
There were 1,204 errors in the last 24 hours — a 32% jump from the previous day,
mostly TypeErrors from the checkout flow. The spike began around 2pm UTC and has
been trending down since the latest deploy.
</AssistantMessage>
</MessageRow>
</Stack>
</Storybook.Demo>

```jsx
<AssistantMessage>
There were 1,204 errors in the last 24 hours — a 32% jump from the previous day, mostly
TypeErrors from the checkout flow. The spike began around 2pm UTC and has been trending
down since the latest deploy.
</AssistantMessage>
```

Wide block content (tables, code) is kept from forcing the row wider, so it
scrolls within the region instead of overflowing the conversation.
21 changes: 21 additions & 0 deletions static/app/components/core/chat/assistantMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {Container} from '@sentry/scraps/layout';

interface AssistantMessageProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}

/**
* The content region for an agent's response in a conversation.
*
* Unlike the sender's messages (see `UserBubble`), an agent response is not
* bubbled — it renders as full-width left-aligned content so rich output
* (markdown, tables, code) reads naturally. It keeps wide content from forcing
* the row wider; the turn's gutter is owned by the wrapping `MessageRow`.
*/
export function AssistantMessage({children, ...props}: AssistantMessageProps) {
return (
<Container width="100%" minWidth={0} overflow="hidden" {...props}>
{children}
</Container>
Comment thread
cursor[bot] marked this conversation as resolved.
);
Comment thread
cursor[bot] marked this conversation as resolved.
}
3 changes: 3 additions & 0 deletions static/app/components/core/chat/index.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export {AssistantMessage} from './assistantMessage';
export {AssistantActions} from './assistantActions';
export {UserBubble} from './userBubble';
export {ToolCallIndicator, type ToolCallStatus} from './toolCallIndicator';
export {Spinner} from './spinner';
export {MessageRow} from './messageRow';
43 changes: 43 additions & 0 deletions static/app/components/core/chat/messageRow.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
title: MessageRow
description: The full-width row that positions a single message turn in a conversation.
category: chat
source: '@sentry/scraps/chat'
resources:
js: https://github.com/getsentry/sentry/blob/master/static/app/components/core/chat/messageRow.tsx
---

import {MessageRow, UserBubble} from '@sentry/scraps/chat';
import {Container} from '@sentry/scraps/layout';

import * as Storybook from 'sentry/stories';

export const documentation = import('!!type-loader!@sentry/scraps/chat/messageRow');

The `MessageRow` lays out a single message turn within a conversation. It is
presentation only — it owns alignment and the consistent gutter around a turn,
while the bubble or content is the caller's responsibility.

## Alignment

Set `from="user"` for the sender's own messages and `from="assistant"` for the
agent's responses, so the two sides sit opposite each other. The row owns the
role-to-side mapping — callers express whose turn it is, not the layout.

<Storybook.Demo align="stretch">
<Container width="100%" background="secondary" radius="md">
<MessageRow from="user">
<UserBubble>Which of my issues are getting worse?</UserBubble>
</MessageRow>
<MessageRow from="assistant">
Here are the three escalating issues I found.
</MessageRow>
</Container>
</Storybook.Demo>

```jsx
<MessageRow from="user">
<UserBubble>Which of my issues are getting worse?</UserBubble>
</MessageRow>
<MessageRow from="assistant">Here are the three escalating issues I found.</MessageRow>
```
31 changes: 31 additions & 0 deletions static/app/components/core/chat/messageRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import {Flex} from '@sentry/scraps/layout';

interface MessageRowProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
/**
* Whose turn this is: `user` sits on the right, `assistant` on the left. The
* row owns the role-to-side mapping so callers express intent, not layout.
*/
from: 'user' | 'assistant';
}

/**
* The full-width row that positions a single message turn within a conversation.
*
* Presentation only — it owns the row's alignment and the consistent gutter
* around a turn. The bubble or content is the caller's responsibility; lay out
* multi-part content in your own wrapper.
*/
export function MessageRow({children, from, ...props}: MessageRowProps) {
return (
<Flex
align="start"
justify={from === 'user' ? 'end' : 'start'}
width="100%"
padding="xl"
{...props}
>
{children}
</Flex>
);
}
Loading
Loading