diff --git a/static/app/components/core/chat/assistantActions.mdx b/static/app/components/core/chat/assistantActions.mdx
new file mode 100644
index 000000000000..ff37c001aa96
--- /dev/null
+++ b/static/app/components/core/chat/assistantActions.mdx
@@ -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.
+
+
+ {}} copyText="The agent response." />
+
+
+```jsx
+ recordFeedback(feedback)}
+ copyText={response}
+ onCopy={() => trackCopy()}
+/>
+```
+
+## Without copy button
+
+Omit `copyText` (or leave it empty) to render feedback only.
+
+
+ {}} />
+
+
+```jsx
+
+```
+
+## 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.
+
+
+ {}}
+ feedbackDisabled
+ copyText="The agent response."
+ />
+
+
+```jsx
+
+```
diff --git a/static/app/components/core/chat/assistantActions.tsx b/static/app/components/core/chat/assistantActions.tsx
new file mode 100644
index 000000000000..a0dcdce4bb7b
--- /dev/null
+++ b/static/app/components/core/chat/assistantActions.tsx
@@ -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 {
+ /**
+ * 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 (
+
+
+
+ {copyText ? (
+ {
+ e.stopPropagation();
+ }}
+ />
+ ) : null}
+
+ );
+}
+
+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 (
+ }
+ disabled={disabled}
+ tooltipProps={{title: label}}
+ onClick={e => {
+ e.stopPropagation();
+ onClick?.(feedback);
+ }}
+ />
+ );
+}
diff --git a/static/app/components/core/chat/assistantMessage.mdx b/static/app/components/core/chat/assistantMessage.mdx
new file mode 100644
index 000000000000..33189658ce1f
--- /dev/null
+++ b/static/app/components/core/chat/assistantMessage.mdx
@@ -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.
+
+
+
+
+ How many errors happened in the last 24 hours?
+
+
+
+ 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.
+
+
+
+
+
+```jsx
+
+ 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.
+
+```
+
+Wide block content (tables, code) is kept from forcing the row wider, so it
+scrolls within the region instead of overflowing the conversation.
diff --git a/static/app/components/core/chat/assistantMessage.tsx b/static/app/components/core/chat/assistantMessage.tsx
new file mode 100644
index 000000000000..c8cba5b26a8c
--- /dev/null
+++ b/static/app/components/core/chat/assistantMessage.tsx
@@ -0,0 +1,21 @@
+import {Container} from '@sentry/scraps/layout';
+
+interface AssistantMessageProps extends React.HTMLAttributes {
+ 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 (
+
+ {children}
+
+ );
+}
diff --git a/static/app/components/core/chat/index.tsx b/static/app/components/core/chat/index.tsx
index f9a891bc7201..9da8c577d9c3 100644
--- a/static/app/components/core/chat/index.tsx
+++ b/static/app/components/core/chat/index.tsx
@@ -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';
diff --git a/static/app/components/core/chat/messageRow.mdx b/static/app/components/core/chat/messageRow.mdx
new file mode 100644
index 000000000000..3689c59c93f8
--- /dev/null
+++ b/static/app/components/core/chat/messageRow.mdx
@@ -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.
+
+
+
+
+ Which of my issues are getting worse?
+
+
+ Here are the three escalating issues I found.
+
+
+
+
+```jsx
+
+ Which of my issues are getting worse?
+
+Here are the three escalating issues I found.
+```
diff --git a/static/app/components/core/chat/messageRow.tsx b/static/app/components/core/chat/messageRow.tsx
new file mode 100644
index 000000000000..6e251752002b
--- /dev/null
+++ b/static/app/components/core/chat/messageRow.tsx
@@ -0,0 +1,31 @@
+import {Flex} from '@sentry/scraps/layout';
+
+interface MessageRowProps extends React.HTMLAttributes {
+ 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 (
+
+ {children}
+
+ );
+}
diff --git a/static/app/views/seerExplorer/components/chat/assistant.spec.tsx b/static/app/views/seerExplorer/components/chat/assistant.spec.tsx
index 2b9a18c47874..496e283db6bb 100644
--- a/static/app/views/seerExplorer/components/chat/assistant.spec.tsx
+++ b/static/app/views/seerExplorer/components/chat/assistant.spec.tsx
@@ -71,14 +71,12 @@ describe('AssistantBlock', () => {
await userEvent.hover(container.firstChild as HTMLElement);
expect(
- screen.getByRole('button', {name: 'Feedback Thumbs Up'})
+ screen.getByRole('button', {name: 'I like this response'})
).toBeInTheDocument();
expect(
- screen.getByRole('button', {name: 'Feedback Thumbs Down'})
- ).toBeInTheDocument();
- expect(
- screen.getByRole('button', {name: 'Copy block content'})
+ screen.getByRole('button', {name: "I don't like this response"})
).toBeInTheDocument();
+ expect(screen.getByRole('button', {name: 'Copy to clipboard'})).toBeInTheDocument();
});
it('disables both thumbs after thumbs up is clicked', async () => {
@@ -88,8 +86,8 @@ describe('AssistantBlock', () => {
await userEvent.hover(container.firstChild as HTMLElement);
- const upButton = screen.getByRole('button', {name: 'Feedback Thumbs Up'});
- const downButton = screen.getByRole('button', {name: 'Feedback Thumbs Down'});
+ const upButton = screen.getByRole('button', {name: 'I like this response'});
+ const downButton = screen.getByRole('button', {name: "I don't like this response"});
await userEvent.click(upButton);
@@ -104,8 +102,8 @@ describe('AssistantBlock', () => {
await userEvent.hover(container.firstChild as HTMLElement);
- const upButton = screen.getByRole('button', {name: 'Feedback Thumbs Up'});
- const downButton = screen.getByRole('button', {name: 'Feedback Thumbs Down'});
+ const upButton = screen.getByRole('button', {name: 'I like this response'});
+ const downButton = screen.getByRole('button', {name: "I don't like this response"});
await userEvent.click(downButton);
@@ -120,8 +118,8 @@ describe('AssistantBlock', () => {
await userEvent.hover(container.firstChild as HTMLElement);
- const upButton = screen.getByRole('button', {name: 'Feedback Thumbs Up'});
- const downButton = screen.getByRole('button', {name: 'Feedback Thumbs Down'});
+ const upButton = screen.getByRole('button', {name: 'I like this response'});
+ const downButton = screen.getByRole('button', {name: "I don't like this response"});
await userEvent.click(upButton);
@@ -142,7 +140,7 @@ describe('AssistantBlock', () => {
await userEvent.hover(container.firstChild as HTMLElement);
expect(
- screen.queryByRole('button', {name: 'Feedback Thumbs Up'})
+ screen.queryByRole('button', {name: 'I like this response'})
).not.toBeInTheDocument();
});
@@ -154,10 +152,10 @@ describe('AssistantBlock', () => {
await userEvent.hover(container.firstChild as HTMLElement);
expect(
- screen.queryByRole('button', {name: 'Feedback Thumbs Up'})
+ screen.queryByRole('button', {name: 'I like this response'})
).not.toBeInTheDocument();
expect(
- screen.queryByRole('button', {name: 'Copy block content'})
+ screen.queryByRole('button', {name: 'Copy to clipboard'})
).not.toBeInTheDocument();
});
});
diff --git a/static/app/views/seerExplorer/components/chat/assistant.tsx b/static/app/views/seerExplorer/components/chat/assistant.tsx
index 41df57558288..9f3a0dde3a83 100644
--- a/static/app/views/seerExplorer/components/chat/assistant.tsx
+++ b/static/app/views/seerExplorer/components/chat/assistant.tsx
@@ -1,13 +1,9 @@
import {Fragment} from 'react';
import {css} from '@emotion/react';
-import {Button, ButtonBar} from '@sentry/scraps/button';
-import {Container} from '@sentry/scraps/layout';
+import {AssistantActions, AssistantMessage, MessageRow} from '@sentry/scraps/chat';
-import {CopyToClipboardButton} from 'sentry/components/copyToClipboardButton';
import {SeerMarkdown} from 'sentry/components/seer/markdown';
-import {IconThumb} from 'sentry/icons';
-import {t} from 'sentry/locale';
import {trackAnalytics} from 'sentry/utils/analytics';
import {useOrganization} from 'sentry/utils/useOrganization';
import {useSessionStorage} from 'sentry/utils/useSessionStorage';
@@ -32,9 +28,11 @@ export function AssistantBlock({
if (block.loading) {
if (isStreamingEnabled && hasValidContent(content)) {
return (
-
-
-
+
+
+
+
+
);
}
return ;
@@ -43,9 +41,11 @@ export function AssistantBlock({
return (
{hasValidContent(content) && (
-
-
-
+
+
+
+
+
)}
{
+ trackAnalytics('seer.explorer.block_copied', {organization});
+ }}
css={css`
${BLOCK_WRAPPER_SELECTOR}:hover &,
${BLOCK_WRAPPER_SELECTOR}:focus-within & {
visibility: visible;
}
`}
- >
-
-
- {showCopy && (
- {
- trackAnalytics('seer.explorer.block_copied', {organization});
- }}
- onClick={e => {
- e.stopPropagation();
- }}
- />
- )}
-
- );
-}
-
-function FeedbackButton({
- type,
- disabled,
- onClick,
-}: {
- disabled: boolean;
- onClick: (type: 'positive' | 'negative') => void;
- type: 'positive' | 'negative';
-}) {
- return (
- }
- disabled={disabled}
- tooltipProps={{
- title: disabled
- ? t('Feedback submitted')
- : type === 'positive'
- ? t('I like this response')
- : t("I don't like this response"),
- }}
- onClick={e => {
- e.stopPropagation();
- onClick(type);
- }}
- >
- {undefined}
-
+ />
);
}
diff --git a/static/app/views/seerExplorer/components/chat/shared.tsx b/static/app/views/seerExplorer/components/chat/shared.tsx
index a3efe3dcd423..19e3787719d9 100644
--- a/static/app/views/seerExplorer/components/chat/shared.tsx
+++ b/static/app/views/seerExplorer/components/chat/shared.tsx
@@ -1,4 +1,4 @@
-import {Spinner, type ToolCallStatus} from '@sentry/scraps/chat';
+import {MessageRow, Spinner, type ToolCallStatus} from '@sentry/scraps/chat';
import {Flex} from '@sentry/scraps/layout';
import {SeerMarkdown} from 'sentry/components/seer/markdown';
@@ -66,19 +66,21 @@ export function hasValidContent(content: string | null | undefined): content is
export function MessagePlaceholder({content}: {content?: string}) {
return (
-
-
-
+
+
+
+
+
+ {hasValidContent(content) && }
- {hasValidContent(content) && }
-
+
);
}
diff --git a/static/app/views/seerExplorer/components/chat/toolUse.spec.tsx b/static/app/views/seerExplorer/components/chat/toolUse.spec.tsx
index cfa0e80547bd..0c9ac2656121 100644
--- a/static/app/views/seerExplorer/components/chat/toolUse.spec.tsx
+++ b/static/app/views/seerExplorer/components/chat/toolUse.spec.tsx
@@ -109,7 +109,7 @@ describe('ToolUseBlock', () => {
it('does not render action bar', () => {
render();
expect(
- screen.queryByRole('button', {name: 'Feedback Thumbs Up'})
+ screen.queryByRole('button', {name: 'I like this response'})
).not.toBeInTheDocument();
});
});
diff --git a/static/app/views/seerExplorer/components/chat/user.spec.tsx b/static/app/views/seerExplorer/components/chat/user.spec.tsx
index 7aede39f5cef..20a77167fea1 100644
--- a/static/app/views/seerExplorer/components/chat/user.spec.tsx
+++ b/static/app/views/seerExplorer/components/chat/user.spec.tsx
@@ -38,10 +38,10 @@ describe('UserBlock', () => {
await userEvent.hover(container.firstChild as HTMLElement);
expect(
- screen.queryByRole('button', {name: 'Feedback Thumbs Up'})
+ screen.queryByRole('button', {name: 'I like this response'})
).not.toBeInTheDocument();
expect(
- screen.queryByRole('button', {name: 'Copy block content'})
+ screen.queryByRole('button', {name: 'Copy to clipboard'})
).not.toBeInTheDocument();
});
});
diff --git a/static/app/views/seerExplorer/components/chat/user.tsx b/static/app/views/seerExplorer/components/chat/user.tsx
index 2940d8278929..7c80cf462094 100644
--- a/static/app/views/seerExplorer/components/chat/user.tsx
+++ b/static/app/views/seerExplorer/components/chat/user.tsx
@@ -1,12 +1,11 @@
-import {UserBubble} from '@sentry/scraps/chat';
-import {Flex} from '@sentry/scraps/layout';
+import {MessageRow, UserBubble} from '@sentry/scraps/chat';
import type {UserBlockProps} from './shared';
export function UserBlock({block}: UserBlockProps) {
return (
-
+ {block.message.content ?? ''}
-
+
);
}