mirror of
https://github.com/supabase/supabase.git
synced 2026-09-22 13:37:53 +08:00
e3c677fc5a
## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? Add telemetry for `PromptPanel` to help us understand how people interact with our AI prompts better. Relates to DOCS-1393 Dashboard(restricted access): [Docs: AI prompt affordances](https://eu.posthog.com/project/34344/dashboard/957235) ## What is the current behavior? The docs homepage cover renders a setup panel with "AI Prompt" and "CLI" tabs, and guides render `AiPrompt` blocks. Both are built on the shared `PromptPanel`, whose copy button called `copyToClipboard` and nothing else. Copying was therefore unmeasured, while the neighbouring affordances (`ask_ai_clicked`, `agent_setup_clicked`, `copy_as_markdown_clicked`) are already instrumented. ## What is the new behavior? `PromptPanel` takes an optional `telemetry` prop. When it is set, the panel sends a new docs-owned event after a **successful** clipboard write, so instrumentation lives in the shared component instead of a forked homepage copy button. New event in `packages/common/telemetry-constants.ts`: | | | | --- | --- | | `action` | `docs_ai_prompt_copied` | | `source` | `homepage` \| `guide` \| `agent_setup` | | `tab` | `prompt` \| `cli` (omitted for panes outside that set) | | `promptId` | prompt id, when the panel comes from an `AiPrompt` block | Wired consumers: `HomePageCover` (`homepage`), `AiPrompt` (`guide` by default, plus `promptId`), and `AgentSetup` (`agent_setup`). No prompt body text and no PII is sent. Studio's existing `ai_prompt_copied` event is deliberately left alone: it has a different owner and surface, and merging the two would blend unrelated funnels. ### Proof it works ``` $ pnpm run test:local:unwatch features/ui/PromptPanel.telemetry.test.ts RUN v5.0.0 /apps/docs Test Files 1 passed (1) Tests 4 passed (4) Duration 775ms ``` ## Additional context Test plan, run against a local docs server with a stub telemetry endpoint so the request bodies could be read directly: | Case | Observed payload | | --- | --- | | Homepage, AI Prompt tab | `{"source":"homepage","tab":"prompt"}` | | Homepage, CLI tab | `{"source":"homepage","tab":"cli"}` | | Next.js quickstart `AiPrompt` | `{"source":"guide","tab":"prompt","promptId":"nextjs"}` | | `automate-with-agents/health` `AgentSetup` | `{"source":"agent_setup","tab":"prompt","promptId":"monitoring-agent-health"}` | | Clipboard write rejected | no request sent, error toast shown, button does not flip to "copied" | The failure case was re-checked with a control click on the same page after restoring a working clipboard, which did send the event, so the negative result is not just a missed handler. Also run: `turbo typecheck --filter=docs --filter=common` (passes), Prettier check on the touched files (passes), and ESLint on the touched docs files (no new findings; the one warning on `HomePageCover` is the pre-existing default export). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Successful prompt copies are now tracked across the homepage, documentation guides, and agent setup experiences. * Copy activity records the prompt’s source, selected format, and associated prompt when available, providing more complete usage insights. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Nik Richers <nik@validmind.ai>
387 lines
12 KiB
TypeScript
387 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useSendTelemetryEvent } from '~/lib/telemetry'
|
|
import { type DocsAiPromptSource } from 'common/telemetry-constants'
|
|
import { ChevronDown } from 'lucide-react'
|
|
import { Children, isValidElement, useId, useState, type ReactElement, type ReactNode } from 'react'
|
|
import ReactMarkdown from 'react-markdown'
|
|
// shadcn tabs from packages/ui/src/components/shadcn/ui/tabs.tsx
|
|
import { cn, Tabs, TabsContent, TabsIndicator, TabsList, TabsTrigger } from 'ui'
|
|
|
|
import { CodeCopyButton } from './CodeBlock/CodeBlock.client'
|
|
|
|
type PromptTitleProps = {
|
|
children: ReactNode
|
|
}
|
|
|
|
type PromptCopyProps = {
|
|
children: string
|
|
}
|
|
|
|
type PromptContentProps = {
|
|
children: ReactNode
|
|
/** Draws attention with a text shimmer. Defaults to true. */
|
|
shimmer?: boolean
|
|
}
|
|
|
|
type PromptProps = {
|
|
children: ReactNode
|
|
/** Stable tab id. Auto-generated when omitted. */
|
|
value?: string
|
|
/** When true, content starts collapsed with a show more / show less control. */
|
|
expandable?: boolean
|
|
}
|
|
|
|
type PromptPanelTelemetry = {
|
|
/** Surface the panel renders on. */
|
|
source: DocsAiPromptSource
|
|
/** Prompt identifier, when the panel wraps a known prompt. */
|
|
promptId?: string
|
|
}
|
|
|
|
type PromptPanelProps = {
|
|
children: ReactNode
|
|
className?: string
|
|
/**
|
|
* Sends `docs_ai_prompt_copied` on a successful copy. Omit to leave the panel
|
|
* untracked.
|
|
*/
|
|
telemetry?: PromptPanelTelemetry
|
|
}
|
|
|
|
type CollectedPrompt = {
|
|
value: string
|
|
title: ReactNode
|
|
copyValue: string
|
|
content: ReactNode
|
|
expandable: boolean
|
|
shimmer: boolean
|
|
}
|
|
|
|
function PromptTitle(_props: PromptTitleProps) {
|
|
return null
|
|
}
|
|
PromptTitle.displayName = 'PromptTitle'
|
|
|
|
function PromptCopy(_props: PromptCopyProps) {
|
|
return null
|
|
}
|
|
PromptCopy.displayName = 'PromptCopy'
|
|
|
|
function PromptContent(_props: PromptContentProps) {
|
|
return null
|
|
}
|
|
PromptContent.displayName = 'PromptContent'
|
|
|
|
function Prompt(_props: PromptProps) {
|
|
return null
|
|
}
|
|
Prompt.displayName = 'Prompt'
|
|
|
|
function isElementOfType(child: ReactNode, type: { displayName?: string }) {
|
|
return (
|
|
isValidElement(child) &&
|
|
typeof child.type !== 'string' &&
|
|
'displayName' in child.type &&
|
|
child.type.displayName === type.displayName
|
|
)
|
|
}
|
|
|
|
function collectPrompt(prompt: ReactElement<PromptProps>, index: number): CollectedPrompt {
|
|
let title: ReactNode = `Prompt ${index + 1}`
|
|
let copyValue = ''
|
|
let content: ReactNode = null
|
|
let shimmer = true
|
|
|
|
Children.forEach(prompt.props.children, (child) => {
|
|
if (isElementOfType(child, PromptTitle)) {
|
|
title = (child as ReactElement<PromptTitleProps>).props.children
|
|
} else if (isElementOfType(child, PromptCopy)) {
|
|
copyValue = (child as ReactElement<PromptCopyProps>).props.children
|
|
} else if (isElementOfType(child, PromptContent)) {
|
|
const props = (child as ReactElement<PromptContentProps>).props
|
|
content = props.children
|
|
shimmer = props.shimmer !== false
|
|
}
|
|
})
|
|
|
|
return {
|
|
value: prompt.props.value ?? `prompt-${index}`,
|
|
title,
|
|
copyValue,
|
|
content,
|
|
expandable: Boolean(prompt.props.expandable),
|
|
shimmer,
|
|
}
|
|
}
|
|
|
|
function collectPrompts(children: ReactNode): CollectedPrompt[] {
|
|
return Children.toArray(children)
|
|
.filter((child): child is ReactElement<PromptProps> => isElementOfType(child, Prompt))
|
|
.map((prompt, index) => collectPrompt(prompt, index))
|
|
}
|
|
|
|
function ExpandableContent({ children }: { children: ReactNode }) {
|
|
const [isExpanded, setIsExpanded] = useState(false)
|
|
const [isHoverRevealPaused, setIsHoverRevealPaused] = useState(false)
|
|
|
|
const handleReelPointerLeave = () => setIsHoverRevealPaused(false)
|
|
const handleToggle = () => {
|
|
if (isExpanded) setIsHoverRevealPaused(true)
|
|
setIsExpanded((expanded) => !expanded)
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="group/expand relative h-full px-4 py-3.5"
|
|
onPointerLeave={handleReelPointerLeave}
|
|
>
|
|
<div
|
|
className={cn(
|
|
'overflow-hidden [interpolate-size:allow-keywords] transition-[max-height] duration-300 ease-out motion-reduce:transition-none',
|
|
isExpanded ? 'max-h-max' : 'max-h-28'
|
|
)}
|
|
>
|
|
{children}
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
'pointer-events-none absolute inset-x-4 bottom-3.5 h-16 bg-gradient-to-t from-background-200 to-transparent',
|
|
'transition-opacity duration-300 ease-out motion-reduce:transition-none',
|
|
isExpanded && 'opacity-0'
|
|
)}
|
|
/>
|
|
<div
|
|
className={cn('flex justify-center', isExpanded ? 'mt-3' : 'absolute inset-x-0 bottom-3.5')}
|
|
>
|
|
<button
|
|
key={String(isExpanded)}
|
|
tabIndex={0}
|
|
type="button"
|
|
onClick={handleToggle}
|
|
className={cn(
|
|
'inline-flex cursor-pointer items-center gap-1.5 rounded-full pl-2.5 pr-3 py-1 text-xs text-foreground',
|
|
'bg-surface-200/90 shadow-sm ring-1 ring-foreground/10 backdrop-blur-sm',
|
|
'transition-[opacity,translate,background-color,scale] duration-200 ease-out',
|
|
'hover:bg-surface-300 active:scale-[0.96] focus-ring',
|
|
!isExpanded &&
|
|
'translate-y-1 opacity-0 focus-visible:translate-y-0 focus-visible:opacity-100 pointer-coarse:translate-y-0 pointer-coarse:opacity-100 motion-reduce:translate-y-0',
|
|
!isExpanded &&
|
|
!isHoverRevealPaused &&
|
|
'group-hover/expand:translate-y-0 group-hover/expand:opacity-100'
|
|
)}
|
|
aria-expanded={isExpanded}
|
|
>
|
|
<ChevronDown aria-hidden="true" size={14} className={cn(isExpanded && 'rotate-180')} />
|
|
{isExpanded ? 'Show less' : 'Show more'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PromptBody({
|
|
prompt,
|
|
shimmerEnabled,
|
|
}: {
|
|
prompt: CollectedPrompt
|
|
shimmerEnabled: boolean
|
|
}) {
|
|
const [shimmerMounted, setShimmerMounted] = useState(prompt.shimmer)
|
|
|
|
const content = (
|
|
<div
|
|
className={cn(shimmerMounted && 'shimmer')}
|
|
data-shimmer-fading={shimmerMounted && !shimmerEnabled ? true : undefined}
|
|
onTransitionEnd={(event) => {
|
|
if (event.target !== event.currentTarget) return
|
|
if (shimmerMounted && !shimmerEnabled) {
|
|
setShimmerMounted(false)
|
|
}
|
|
}}
|
|
>
|
|
{prompt.content}
|
|
</div>
|
|
)
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'h-full bg-200 text-sm leading-6 text-foreground-light font-normal',
|
|
!prompt.expandable && 'px-4 py-3.5'
|
|
)}
|
|
>
|
|
{prompt.expandable ? <ExpandableContent>{content}</ExpandableContent> : content}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Renders a Markdown prompt string as the panel body, with backticks as code chips. */
|
|
function PromptMarkdown({ children }: { children: string }) {
|
|
return (
|
|
<div className="not-prose flex flex-col gap-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5 [&_li+li]:mt-1">
|
|
<ReactMarkdown components={MARKDOWN_COMPONENTS}>{children}</ReactMarkdown>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** Inline code chip for prompt bodies; opts out of the text shimmer. */
|
|
function PromptCode({ children }: { children?: ReactNode }) {
|
|
return (
|
|
<code className="shimmer-none rounded bg-surface-200 px-1 py-0.5 font-mono text-xs text-foreground">
|
|
{children}
|
|
</code>
|
|
)
|
|
}
|
|
|
|
const MARKDOWN_COMPONENTS = { code: PromptCode }
|
|
|
|
const tabTriggerClassName = cn(
|
|
'h-full px-0 py-0 text-xs shadow-none data-[state=active]:shadow-none',
|
|
'group-has-[[data-tab-indicator]]/list:border-b',
|
|
'group-data-[tab-indicator-ready]/list:data-[state=active]:border-b-transparent'
|
|
)
|
|
|
|
/**
|
|
* Copyable prompt card. Compose one or more `<Prompt>` children; multiple
|
|
* prompts render as tabs. A single prompt still shows the header + copy
|
|
* button, with the title styled like an inactive tab (no underline).
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* <PromptPanel>
|
|
* <Prompt value="prompt" expandable>
|
|
* <PromptTitle>Agent Prompt</PromptTitle>
|
|
* <PromptCopy>Plain text copied to the clipboard</PromptCopy>
|
|
* <PromptContent>Rich content shown in the panel</PromptContent>
|
|
* </Prompt>
|
|
* </PromptPanel>
|
|
* ```
|
|
*/
|
|
function PromptPanel({ children, className, telemetry }: PromptPanelProps) {
|
|
const fallbackId = useId()
|
|
const titleId = useId()
|
|
const prompts = collectPrompts(children)
|
|
const [activeTab, setActiveTab] = useState(prompts[0]?.value ?? fallbackId)
|
|
const [shimmerEnabled, setShimmerEnabled] = useState(true)
|
|
const sendTelemetryEvent = useSendTelemetryEvent()
|
|
|
|
if (prompts.length === 0) return null
|
|
|
|
const activePrompt = prompts.find((prompt) => prompt.value === activeTab) ?? prompts[0]
|
|
const hasTabs = prompts.length > 1
|
|
const dismissShimmer = () => setShimmerEnabled(false)
|
|
|
|
const copyTarget = typeof activePrompt.title === 'string' ? activePrompt.title : 'content'
|
|
const handleCopied = telemetry
|
|
? () => {
|
|
sendTelemetryEvent({
|
|
action: 'docs_ai_prompt_copied',
|
|
properties: {
|
|
source: telemetry.source,
|
|
tab: activePrompt.value,
|
|
promptId: telemetry.promptId,
|
|
},
|
|
})
|
|
}
|
|
: undefined
|
|
|
|
const header = (
|
|
<div className="flex h-11 items-center justify-between pl-4 pr-2 shadow-[inset_0_-1px_0_0_var(--border-default)] [--btn-active:color-mix(in_srgb,var(--foreground)_4%,var(--background-200))]">
|
|
{hasTabs ? (
|
|
<TabsList className="h-full gap-5 border-0 [--tab-track:transparent]">
|
|
{prompts.map((prompt) => (
|
|
<TabsTrigger key={prompt.value} value={prompt.value} className={tabTriggerClassName}>
|
|
{prompt.title}
|
|
</TabsTrigger>
|
|
))}
|
|
<TabsIndicator />
|
|
</TabsList>
|
|
) : (
|
|
<span
|
|
id={titleId}
|
|
className={cn(
|
|
'inline-flex items-center justify-center whitespace-nowrap border-b-2 border-transparent text-foreground-lighter',
|
|
tabTriggerClassName
|
|
)}
|
|
>
|
|
{prompts[0].title}
|
|
</span>
|
|
)}
|
|
<CodeCopyButton
|
|
key={activePrompt.value}
|
|
content={activePrompt.copyValue}
|
|
label={`Copy ${copyTarget}`}
|
|
copiedLabel={`${copyTarget} copied`}
|
|
onCopied={handleCopied}
|
|
/>
|
|
</div>
|
|
)
|
|
|
|
if (!hasTabs) {
|
|
return (
|
|
<div
|
|
role="region"
|
|
aria-labelledby={titleId}
|
|
onFocusCapture={dismissShimmer}
|
|
onPointerEnter={dismissShimmer}
|
|
className={cn(
|
|
'w-full overflow-hidden rounded-lg border border-default shadow-codeblock',
|
|
className
|
|
)}
|
|
>
|
|
{header}
|
|
<PromptBody prompt={prompts[0]} shimmerEnabled={shimmerEnabled} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={setActiveTab}
|
|
// Manual activation keeps VoiceOver focus from switching tabs before Copy is reached.
|
|
activationMode="manual"
|
|
onFocusCapture={dismissShimmer}
|
|
onPointerEnter={dismissShimmer}
|
|
className={cn(
|
|
'w-full overflow-hidden rounded-lg border border-default shadow-codeblock',
|
|
className
|
|
)}
|
|
>
|
|
{header}
|
|
{/* Stack panes in one grid cell so the panel keeps the tallest tab's height. */}
|
|
<div className="grid">
|
|
{prompts.map((prompt) => {
|
|
const isActive = prompt.value === activeTab
|
|
|
|
return (
|
|
<TabsContent
|
|
key={prompt.value}
|
|
value={prompt.value}
|
|
forceMount
|
|
// pane's own controls remain reachable
|
|
tabIndex={-1}
|
|
inert={!isActive}
|
|
aria-hidden={!isActive}
|
|
className="col-start-1 row-start-1 m-0 data-[state=inactive]:invisible data-[state=inactive]:pointer-events-none"
|
|
>
|
|
<PromptBody prompt={prompt} shimmerEnabled={shimmerEnabled} />
|
|
</TabsContent>
|
|
)
|
|
})}
|
|
</div>
|
|
</Tabs>
|
|
)
|
|
}
|
|
|
|
export { Prompt, PromptCode, PromptContent, PromptCopy, PromptMarkdown, PromptPanel, PromptTitle }
|
|
export type {
|
|
PromptContentProps,
|
|
PromptCopyProps,
|
|
PromptPanelProps,
|
|
PromptPanelTelemetry,
|
|
PromptProps,
|
|
PromptTitleProps,
|
|
}
|