Skip to content

Commit cd17887

Browse files
feat(playground-ui): add CodeBlock component (mastra-ai#16202)
## Summary - New `CodeBlock` ds component with optional `select` or `tabs` switcher in the header, file-name caption, optional shiki syntax highlighting (via existing `highlight()` helper — no new deps), and a Notion-style copy button that hides on desktop until hover and stays visible on touch devices via `pointer-fine:` / `(hover: hover)` media queries. - Refactored the Mastra version dialog (`mastra-version-footer.tsx`) to use `CodeBlock` for the update command and replaced the full-width "Copy current versions" button with an icon-only `CopyButton` on the status row. <img width="947" height="756" alt="CleanShot 2026-05-05 at 11 52 48" src="https://github.com/user-attachments/assets/276d6fa4-1d27-4011-aba2-d9ff5c2807b3" /> ## Test plan - [ ] Open Storybook and review `Composite/CodeBlock` stories — `Default`, `WithSelect`, `WithTabs`, `TabsWithCode`, `WithFileName`, `Highlighted`, `LongCommand` - [ ] Verify select changes the displayed code and tabs variant switches between provider snippets - [ ] On desktop, copy button is hidden until hover; on touch (or mobile dev tools emulation), it stays visible - [ ] In the playground dialog, switching package manager updates the command and the icon copy button next to the status copies all installed versions <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## ELI5 - What's this PR about? Adds a reusable code display component that can show different snippets (via dropdown or tabs), highlight code, and let you quickly copy the code; the Mastra packages dialog now uses this component so the UI is simpler and consistent. --- ## Changes ### New `CodeBlock` component - Renders code with an optional variant selector (dropdown or tabs) to switch snippets (e.g., package managers or provider examples). - Optional filename caption. - Optional syntax highlighting via the existing highlight() helper (no new deps). Tokens are reset to null before each re-highlight and highlight() rejections are caught so the plain-text fallback remains visible and no unhandled promise rejections occur. - Notion-style copy button: hidden on desktop until hover and remains visible on touch devices using pointer-fine / (hover: hover) media-query behavior. - Supports configurable copy message/tooltip and controlled/uncontrolled value handling for the selector. ### Storybook - Added stories demonstrating variants and behaviors: Default, WithSelect, LongCommand, WithFileName, WithTabs, TabsWithCode, Highlighted. ### Refactor: Mastra version dialog - Replaced the dialog’s previous select + <pre> command block + copy button with the new `CodeBlock` for the update command (package-manager switching now drives CodeBlock via value/onValueChange). - Replaced the full-width “Copy current versions” control with an icon-only `CopyButton` on the status row. - Introduced PACKAGE_MANAGERS const, PackageManager union type, and isPackageManager guard for safer package-manager handling. - Consolidated UI and clipboard handling into `CodeBlock`, simplifying the footer component. ### Exports & package surface - Re-exported `CodeBlock` from packages/playground-ui/src/index.ts and added component index.ts. - New exported types: `CodeBlockSelector`, `CodeBlockOption`, `CodeBlockProps`. ### Changesets / Versioning - Minor bump for `@mastra/playground-ui` documenting the new component. - Patch update for `mastra` recording the Mastra version dialog refactor. ### Notes for reviewers - Verify select/tabs switching updates displayed code in Storybook. - Confirm copy button visibility behavior (hidden until hover on desktop; visible on touch). - In the playground dialog, confirm switching package manager updates the command and that the copy icon copies the expected text. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent ca29d76 commit cd17887

7 files changed

Lines changed: 352 additions & 55 deletions

File tree

.changeset/every-buses-shave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/playground-ui': minor
3+
---
4+
5+
Added CodeBlock component with select/tabs switcher, optional shiki syntax highlighting, and Notion-style hover-only copy button (always visible on touch devices via media query).

.changeset/plenty-pens-call.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'mastra': patch
3+
---
4+
5+
Refactored the Mastra version dialog to use the new CodeBlock component and simplified the package versions copy button to an icon-only button with tooltip.
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
import { useState } from 'react';
3+
4+
import { TooltipProvider } from '../Tooltip';
5+
import { CodeBlock } from './code-block';
6+
7+
const meta: Meta<typeof CodeBlock> = {
8+
title: 'Composite/CodeBlock',
9+
component: CodeBlock,
10+
decorators: [
11+
Story => (
12+
<TooltipProvider>
13+
<div className="w-full p-4">
14+
<Story />
15+
</div>
16+
</TooltipProvider>
17+
),
18+
],
19+
parameters: {
20+
layout: 'fullscreen',
21+
},
22+
};
23+
24+
export default meta;
25+
type Story = StoryObj<typeof CodeBlock>;
26+
27+
const packageManagers = [
28+
{ label: 'pnpm', value: 'pnpm' },
29+
{ label: 'npm', value: 'npm' },
30+
{ label: 'yarn', value: 'yarn' },
31+
{ label: 'bun', value: 'bun' },
32+
];
33+
34+
const commands: Record<string, string> = {
35+
pnpm: 'pnpm add @mastra/core@latest @mastra/memory@latest mastra@latest',
36+
npm: 'npm install @mastra/core@latest @mastra/memory@latest mastra@latest',
37+
yarn: 'yarn add @mastra/core@latest @mastra/memory@latest mastra@latest',
38+
bun: 'bun add @mastra/core@latest @mastra/memory@latest mastra@latest',
39+
};
40+
41+
export const Default: Story = {
42+
render: () => <CodeBlock code="pnpm dlx mastra@latest init" />,
43+
};
44+
45+
export const WithSelect: Story = {
46+
render: () => {
47+
const [pm, setPm] = useState('pnpm');
48+
return (
49+
<CodeBlock
50+
code={commands[pm]}
51+
options={packageManagers}
52+
value={pm}
53+
onValueChange={setPm}
54+
copyMessage="Copied update command!"
55+
/>
56+
);
57+
},
58+
};
59+
60+
export const LongCommand: Story = {
61+
render: () => {
62+
const [pm, setPm] = useState('pnpm');
63+
const long = `${pm} add @mastra/auth-workos@latest @mastra/client-js@latest @mastra/core@latest @mastra/duckdb@latest @mastra/editor@latest @mastra/libsql@latest @mastra/mcp@latest @mastra/memory@latest @mastra/observability@latest @mastra/slack@latest mastra@latest`;
64+
return <CodeBlock code={long} options={packageManagers} value={pm} onValueChange={setPm} />;
65+
},
66+
};
67+
68+
export const WithFileName: Story = {
69+
render: () => (
70+
<CodeBlock
71+
fileName="src/mastra/agents/index.ts"
72+
lang="typescript"
73+
code={`import { Agent } from '@mastra/core/agent';\nimport { openai } from '@ai-sdk/openai';\n\nexport const agent = new Agent({\n name: 'assistant',\n model: openai('gpt-4o-mini'),\n});`}
74+
/>
75+
),
76+
};
77+
78+
export const WithTabs: Story = {
79+
render: () => {
80+
const [pm, setPm] = useState('pnpm');
81+
return (
82+
<CodeBlock
83+
code={commands[pm]}
84+
options={packageManagers}
85+
value={pm}
86+
onValueChange={setPm}
87+
selector="tabs"
88+
copyMessage="Copied update command!"
89+
/>
90+
);
91+
},
92+
};
93+
94+
export const TabsWithCode: Story = {
95+
render: () => {
96+
const [provider, setProvider] = useState('anthropic');
97+
const snippets: Record<string, string> = {
98+
anthropic: `import { anthropic } from '@ai-sdk/anthropic';\n\nconst model = anthropic('claude-sonnet-4-5');`,
99+
openai: `import { openai } from '@ai-sdk/openai';\n\nconst model = openai('gpt-4o-mini');`,
100+
langchain: `import { ChatOpenAI } from '@langchain/openai';\n\nconst model = new ChatOpenAI({ model: 'gpt-4o-mini' });`,
101+
mastra: `import { Agent } from '@mastra/core/agent';\n\nexport const agent = new Agent({ name: 'assistant', model });`,
102+
};
103+
return (
104+
<CodeBlock
105+
code={snippets[provider]}
106+
lang="typescript"
107+
selector="tabs"
108+
options={[
109+
{ label: 'Anthropic', value: 'anthropic' },
110+
{ label: 'OpenAI', value: 'openai' },
111+
{ label: 'LangChain', value: 'langchain' },
112+
{ label: 'Mastra', value: 'mastra' },
113+
]}
114+
value={provider}
115+
onValueChange={setProvider}
116+
/>
117+
);
118+
},
119+
};
120+
121+
export const Highlighted: Story = {
122+
render: () => {
123+
const [provider, setProvider] = useState('openai');
124+
const snippets: Record<string, string> = {
125+
openai: `import { openai } from '@ai-sdk/openai';\n\nconst model = openai('gpt-4o-mini');`,
126+
anthropic: `import { anthropic } from '@ai-sdk/anthropic';\n\nconst model = anthropic('claude-sonnet-4-5');`,
127+
mastra: `import { Agent } from '@mastra/core/agent';\n\nexport const agent = new Agent({ name: 'assistant', model });`,
128+
};
129+
return (
130+
<CodeBlock
131+
code={snippets[provider]}
132+
lang="typescript"
133+
options={[
134+
{ label: 'OpenAI', value: 'openai' },
135+
{ label: 'Anthropic', value: 'anthropic' },
136+
{ label: 'Mastra', value: 'mastra' },
137+
]}
138+
value={provider}
139+
onValueChange={setProvider}
140+
/>
141+
);
142+
},
143+
};
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import * as React from 'react';
2+
import type { ThemedToken } from 'shiki';
3+
4+
import { highlight } from '../CodeEditor';
5+
import { CopyButton } from '../CopyButton';
6+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../Select';
7+
import { Tab, TabList, Tabs } from '../Tabs';
8+
import { transitions } from '@/ds/primitives/transitions';
9+
import { cn } from '@/lib/utils';
10+
11+
export type CodeBlockSelector = 'select' | 'tabs';
12+
13+
export interface CodeBlockOption {
14+
label: string;
15+
value: string;
16+
}
17+
18+
export interface CodeBlockProps {
19+
code: string;
20+
options?: CodeBlockOption[];
21+
value?: string;
22+
onValueChange?: (value: string) => void;
23+
selector?: CodeBlockSelector;
24+
fileName?: string;
25+
lang?: string;
26+
copyMessage?: string;
27+
copyTooltip?: string;
28+
className?: string;
29+
}
30+
31+
export function CodeBlock({
32+
code,
33+
options,
34+
value,
35+
onValueChange,
36+
selector = 'select',
37+
fileName,
38+
lang,
39+
copyMessage,
40+
copyTooltip,
41+
className,
42+
}: CodeBlockProps) {
43+
const hasOptions = options && options.length > 0;
44+
const useTabs = hasOptions && selector === 'tabs';
45+
const useSelect = hasOptions && selector === 'select';
46+
const activeValue = value ?? options?.[0]?.value;
47+
48+
return (
49+
<figure
50+
className={cn(
51+
'group relative flex w-full flex-col overflow-hidden rounded-2xl border border-border2/40 bg-surface2',
52+
className,
53+
)}
54+
>
55+
{useTabs && options && (
56+
<Tabs defaultTab={options[0].value} value={activeValue} onValueChange={onValueChange ?? (() => {})}>
57+
<TabList>
58+
{options.map(opt => (
59+
<Tab key={opt.value} value={opt.value}>
60+
{opt.label}
61+
</Tab>
62+
))}
63+
</TabList>
64+
</Tabs>
65+
)}
66+
67+
{useSelect && options && (
68+
<div className="flex items-center border-b border-border2/40 px-2 py-1.5">
69+
<Select value={activeValue} onValueChange={onValueChange}>
70+
<SelectTrigger size="sm" variant="ghost">
71+
<SelectValue />
72+
</SelectTrigger>
73+
<SelectContent>
74+
{options.map(opt => (
75+
<SelectItem key={opt.value} value={opt.value}>
76+
{opt.label}
77+
</SelectItem>
78+
))}
79+
</SelectContent>
80+
</Select>
81+
</div>
82+
)}
83+
84+
{!hasOptions && fileName && (
85+
<div className="flex items-center border-b border-border2/40 px-4 py-2">
86+
<figcaption className="font-mono text-ui-sm text-neutral4">{fileName}</figcaption>
87+
</div>
88+
)}
89+
90+
<div className="relative">
91+
<HighlightedCode code={code} lang={lang} />
92+
<CopyButton
93+
content={code}
94+
copyMessage={copyMessage}
95+
tooltip={copyTooltip}
96+
size="sm"
97+
className={cn(
98+
'absolute top-2 right-2 opacity-100 pointer-fine:opacity-0 group-hover:opacity-100 group-focus-within:opacity-100',
99+
transitions.opacity,
100+
)}
101+
/>
102+
</div>
103+
</figure>
104+
);
105+
}
106+
107+
interface HighlightedCodeProps {
108+
code: string;
109+
lang?: string;
110+
}
111+
112+
function HighlightedCode({ code, lang }: HighlightedCodeProps) {
113+
const [tokens, setTokens] = React.useState<ThemedToken[][] | null>(null);
114+
115+
React.useEffect(() => {
116+
if (!lang) {
117+
setTokens(null);
118+
return;
119+
}
120+
setTokens(null);
121+
let cancelled = false;
122+
void highlight(code, lang)
123+
.then(result => {
124+
if (!cancelled && result) setTokens(result);
125+
})
126+
.catch(() => {
127+
// Highlighting failed — plain-text fallback remains visible.
128+
});
129+
return () => {
130+
cancelled = true;
131+
};
132+
}, [code, lang]);
133+
134+
const preClass = 'px-4 py-3 font-mono text-ui-sm text-neutral5 whitespace-pre-wrap break-all';
135+
136+
if (!lang || !tokens) {
137+
return <pre className={preClass}>{code}</pre>;
138+
}
139+
140+
return (
141+
<pre className={preClass}>
142+
<code>
143+
{tokens.map((line, lineIndex) => (
144+
<React.Fragment key={lineIndex}>
145+
<span>
146+
{line.map((token, tokenIndex) => (
147+
<span
148+
key={tokenIndex}
149+
className="text-shiki-light bg-shiki-light-bg dark:text-shiki-dark dark:bg-shiki-dark-bg"
150+
>
151+
{token.content}
152+
</span>
153+
))}
154+
</span>
155+
{lineIndex !== tokens.length - 1 && '\n'}
156+
</React.Fragment>
157+
))}
158+
</code>
159+
</pre>
160+
);
161+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './code-block';

packages/playground-ui/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export * from './ds/components/Checkbox';
2525
export * from './ds/components/Collapsible';
2626
export * from './ds/components/Combobox';
2727
export * from './ds/components/Command';
28+
export * from './ds/components/CodeBlock';
2829
export * from './ds/components/CopyButton';
2930
export * from './ds/components/DashboardCard';
3031
export * from './ds/components/Dialog';

0 commit comments

Comments
 (0)