From 1f8acef49234a33b29dc7ad79d53c4d22ff662de Mon Sep 17 00:00:00 2001 From: Evan Purkhiser Date: Mon, 9 Nov 2026 13:15:51 -0500 Subject: [PATCH] feat(agent-handoff): Add reusable setup prompt UI --- .../agentHandoff/agentHandoff.spec.tsx | 155 +++++++++++ .../agentHandoff/agentHandoff.stories.mdx | 27 ++ .../components/agentHandoff/agentHandoff.tsx | 256 ++++++++++++++++++ .../agentHandoff/useAgentHandoff.spec.tsx | 120 ++++++++ .../agentHandoff/useAgentHandoff.tsx | 103 +++++++ 5 files changed, 661 insertions(+) create mode 100644 static/app/components/agentHandoff/agentHandoff.spec.tsx create mode 100644 static/app/components/agentHandoff/agentHandoff.stories.mdx create mode 100644 static/app/components/agentHandoff/agentHandoff.tsx create mode 100644 static/app/components/agentHandoff/useAgentHandoff.spec.tsx create mode 100644 static/app/components/agentHandoff/useAgentHandoff.tsx diff --git a/static/app/components/agentHandoff/agentHandoff.spec.tsx b/static/app/components/agentHandoff/agentHandoff.spec.tsx new file mode 100644 index 000000000000..32337acb25b5 --- /dev/null +++ b/static/app/components/agentHandoff/agentHandoff.spec.tsx @@ -0,0 +1,155 @@ +import {OrganizationFixture} from 'sentry-fixture/organization'; + +import { + fireEvent, + render, + screen, + userEvent, + waitFor, +} from 'sentry-test/reactTestingLibrary'; + +import {AgentHandoff} from './agentHandoff'; +import {AgentHandoffFlow} from './useAgentHandoff'; + +function TestAgentHandoff() { + return ( + + + + ); +} + +describe('AgentHandoff', () => { + const organization = OrganizationFixture(); + const promptUrl = `/organizations/${organization.slug}/agent-handoff/prompts/`; + const prompt = 'Fetch the guide. Your secret token is `sntryh_example`.'; + const warning = /Don’t paste it while screen sharing or recording/; + const writeText = jest.fn(); + + function mockPrompt() { + return MockApiClient.addMockResponse({ + url: promptUrl, + method: 'POST', + body: {prompt, expiresAt: new Date(Date.now() + 300_000).toISOString()}, + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + MockApiClient.clearMockResponses(); + writeText.mockResolvedValue(undefined); + Object.assign(navigator, {clipboard: {writeText}}); + }); + + it('copies an IP-pinned handoff inline', async () => { + const mint = mockPrompt(); + render(, {organization}); + + await userEvent.click(screen.getByRole('button', {name: 'Copy agent prompt'})); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(prompt)); + expect(mint).toHaveBeenCalledWith( + promptUrl, + expect.objectContaining({ + data: {flow: 'onboarding', disableIpPinning: false}, + }) + ); + expect(screen.queryByRole('button', {name: 'Cancel'})).not.toBeInTheDocument(); + }); + + it('copies the complete prompt when using the native copy command', async () => { + mockPrompt(); + render(, {organization}); + + fireEvent.copy(screen.getByText('`sntryh_*`')); + + await waitFor(() => expect(writeText).toHaveBeenCalledWith(prompt)); + }); + + it('shows the handoff URL with a masked token', () => { + render(, {organization}); + + expect(screen.getByText(/\/agent-handoff\//)).toBeInTheDocument(); + expect(screen.getByText('`sntryh_*`')).toBeInTheDocument(); + }); + + it('explains what the masked token is used for', async () => { + render(, {organization}); + + await userEvent.hover(screen.getByText('`sntryh_*`')); + + expect( + await screen.findByText('The secret token authenticates the CLI via this prompt') + ).toBeInTheDocument(); + }); + + it('selects the complete prompt on pointer down', async () => { + render(, {organization}); + const promptText = screen.getByText('`sntryh_*`'); + + await userEvent.pointer({keys: '[MouseLeft>]', target: promptText}); + + expect(window.getSelection()?.toString()).toContain('/agent-handoff/'); + expect(window.getSelection()?.toString()).toContain('sntryh_*'); + await userEvent.pointer({keys: '[/MouseLeft]', target: promptText}); + window.getSelection()?.removeAllRanges(); + }); + + it('warns when disabling IP pinning and copies that choice', async () => { + const mint = mockPrompt(); + render(, {organization}); + expect(screen.queryByText(warning)).not.toBeInTheDocument(); + + await userEvent.click(screen.getByRole('button', {name: 'Advanced options'})); + const checkbox = screen.getByRole('checkbox', {name: 'Disable IP pinning'}); + expect(checkbox).not.toBeChecked(); + expect(checkbox).toHaveAccessibleDescription( + /Only enable this option if your agent runs on a different machine/ + ); + await userEvent.click(checkbox); + expect(screen.getByText(warning)).toBeInTheDocument(); + await userEvent.click(screen.getByRole('button', {name: 'Advanced options'})); + expect(screen.getByText(warning)).toBeVisible(); + await userEvent.click(screen.getByRole('button', {name: 'Advanced options'})); + await userEvent.click(checkbox); + expect(screen.queryByText(warning)).not.toBeInTheDocument(); + await userEvent.click(checkbox); + await userEvent.click(screen.getByRole('button', {name: 'Copy agent prompt'})); + await waitFor(() => expect(writeText).toHaveBeenCalledWith(prompt)); + expect(mint).toHaveBeenCalledWith( + promptUrl, + expect.objectContaining({ + data: {flow: 'onboarding', disableIpPinning: true}, + }) + ); + }); + + it('mints a new handoff when pinning changes after a clipboard failure', async () => { + const mint = mockPrompt(); + writeText.mockRejectedValueOnce(new Error('Clipboard unavailable')); + render(, {organization}); + await userEvent.click(screen.getByRole('button', {name: 'Copy agent prompt'})); + await screen.findByText( + 'Unable to copy your agent prompt. Allow clipboard access and try again.' + ); + await userEvent.click(screen.getByRole('button', {name: 'Advanced options'})); + await userEvent.click(screen.getByRole('checkbox', {name: 'Disable IP pinning'})); + await userEvent.click(screen.getByRole('button', {name: 'Copy agent prompt'})); + await waitFor(() => expect(writeText).toHaveBeenCalledTimes(2)); + expect(mint).toHaveBeenCalledTimes(2); + expect(mint).toHaveBeenLastCalledWith( + promptUrl, + expect.objectContaining({ + data: {flow: 'onboarding', disableIpPinning: true}, + }) + ); + }); + + it('links to a redacted preview of the handoff markdown', () => { + render(, {organization}); + + expect( + screen.getByRole('link', {name: 'Preview the handoff markdown'}) + ).toHaveAttribute('href', 'https://sentry.io/agent-handoff/?preview=1'); + }); +}); diff --git a/static/app/components/agentHandoff/agentHandoff.stories.mdx b/static/app/components/agentHandoff/agentHandoff.stories.mdx new file mode 100644 index 000000000000..110147742eee --- /dev/null +++ b/static/app/components/agentHandoff/agentHandoff.stories.mdx @@ -0,0 +1,27 @@ +--- +title: Agent Handoff +description: Preview and copy a private prompt for setting up Sentry with a coding agent. +category: patterns +source: sentry/components/agentHandoff/agentHandoff +--- + +import {AgentHandoff} from './agentHandoff'; +import {AgentHandoffFlow} from './useAgentHandoff'; +import * as Storybook from 'sentry/stories'; + +Use `AgentHandoff` within an organization to configure IP pinning and copy a private +handoff prompt. The preview link opens the handoff markdown in a new tab. Copying mints +a real, single-use handoff for the current organization. + + + + + + + +IP pinning is enabled by default. Disabling it shows a privacy warning that remains +visible when advanced options are collapsed. + +The displayed prompt keeps its secret token masked. Selecting its text and using the +native copy command, or pressing the copy icon, mints the private handoff and writes the +complete prompt to the clipboard. diff --git a/static/app/components/agentHandoff/agentHandoff.tsx b/static/app/components/agentHandoff/agentHandoff.tsx new file mode 100644 index 000000000000..0888ac67e54a --- /dev/null +++ b/static/app/components/agentHandoff/agentHandoff.tsx @@ -0,0 +1,256 @@ +import { + createContext, + useContext, + useRef, + type ClipboardEvent, + type PointerEvent, + type ReactNode, +} from 'react'; +import styled from '@emotion/styled'; +import {IconCopy} from '@sentry/icons/copy'; +import {z} from 'zod'; + +import {Alert} from '@sentry/scraps/alert'; +import {Button} from '@sentry/scraps/button'; +import {Disclosure} from '@sentry/scraps/disclosure'; +import {defaultFormOptions, useScrapsForm, useStore} from '@sentry/scraps/form'; +import {InfoText} from '@sentry/scraps/info'; +import {Container, Grid, Stack} from '@sentry/scraps/layout'; +import {ExternalLink} from '@sentry/scraps/link'; +import {Text} from '@sentry/scraps/text'; +import {toast} from '@sentry/scraps/toast'; + +import {List} from 'sentry/components/list'; +import {ListItem} from 'sentry/components/list/listItem'; +import {t, tct} from 'sentry/locale'; +import {ConfigStore} from 'sentry/stores/configStore'; +import {useLegacyStore} from 'sentry/stores/useLegacyStore'; + +import {useAgentHandoff, type AgentHandoffFlow} from './useAgentHandoff'; + +type AgentHandoffProps = { + children: ReactNode; + flow: AgentHandoffFlow; +}; + +function useAgentHandoffState(flow: AgentHandoffFlow) { + const form = useScrapsForm({ + ...defaultFormOptions, + defaultValues: {disableIpPinning: false}, + validators: {onDynamic: z.object({disableIpPinning: z.boolean()})}, + }); + const disableIpPinning = useStore(form.store, state => state.values.disableIpPinning); + const handoff = useAgentHandoff(flow); + + return {disableIpPinning, flow, form, handoff}; +} + +type AgentHandoffContextValue = ReturnType; + +const AgentHandoffContext = createContext(null); + +function useAgentHandoffContext() { + const context = useContext(AgentHandoffContext); + if (!context) { + throw new Error('AgentHandoff compound components must be used within AgentHandoff'); + } + + return context; +} + +function AgentHandoffRoot({children, flow}: AgentHandoffProps) { + const context = useAgentHandoffState(flow); + + return ( + + {children} + + ); +} + +function Details() { + const {links} = useLegacyStore(ConfigStore); + const {disableIpPinning, form, handoff} = useAgentHandoffContext(); + const guideUrl = `${ConfigStore.get('urlPrefix').replace(/\/$/, '')}/agent-handoff/`; + const previewUrl = new URL('/agent-handoff/?preview=1', links.sentryUrl).href; + + function copyPrompt() { + if (handoff.isPending) { + return; + } + + handoff.mutate( + {disableIpPinning}, + {onSuccess: () => toast.success(t('Agent prompt copied to clipboard'))} + ); + } + + return ( + + + {t( + 'Copy and paste this prompt in your favorite coding agent, opened in the project you want to instrument with Sentry, and let it handle the setup.' + )} + + + + {t('Preview the handoff markdown')} + + + + {t('Advanced options')} + + + {field => ( + { + field.handleChange(checked); + handoff.reset(); + }} + /> + )} + + + + + {disableIpPinning && ( + + {t( + 'Keep this prompt private. Anyone with the prompt can use it to authenticate the Sentry CLI to your account. Don’t paste it while screen sharing or recording.' + )} + + )} + {handoff.error && {handoff.error.message}} + + {t('Your agent will:')} + + {layoutProps => ( + + + {tct('Install and automatically authenticate the [cli:Sentry CLI].', { + cli: , + })} + + + {t('Set up Sentry for your project.')} + + + {t('Inspect your project to determine the best Sentry instrumentation.')} + + + {t('Implement the instrumentation in your project.')} + + + {t('Verify telemetry from your development environment.')} + + + )} + + + + ); +} + +function CopyableAgentPrompt({ + busy, + guideUrl, + onCopy, +}: { + busy: boolean; + guideUrl: string; + onCopy: () => void; +}) { + const promptRef = useRef(null); + + function selectPrompt(event: PointerEvent) { + event.preventDefault(); + + if (!promptRef.current) { + return; + } + + const range = document.createRange(); + range.selectNodeContents(promptRef.current); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + } + + function handleNativeCopy(event: ClipboardEvent) { + event.preventDefault(); + onCopy(); + } + + return ( + + + {tct( + 'Fetch and follow the instructions at[break][guideUrl]. Your secret token is [token].', + { + break:
, + guideUrl, + token: ( + + `sntryh_*` + + ), + } + )} +
+ +