Files
copilotkit__copilotkit/codemods/migrate-attachments.ts
T
Alem Tuzlak 18c7a6001d feat: multimodal attachments — UI polish, deprecations, docs, codemod
Attachment queue & previews:
- Image lightbox with View Transition API morph animation
- Video lightbox with native controls and play button overlay
- Document lightbox (PDF via blob URL, text inline, info card fallback)
- Drop zone overlay with upload icon
- Filename preservation via InputContent metadata
- Proper video thumbnail sizing and play/pause indicator
- Fix attachment queue positioning (max-w-3xl constraint)
- Padding between X button and content for audio/document cards
- Document filenames wrap instead of truncating

Attachments config:
- onUploadFailed callback for validation/upload errors (file-too-large, invalid-type, upload-failed)
- onUpload accepts sync or async returns
- AttachmentUploadResult discriminated union with explicit interfaces
- Metadata field on Attachment and onUpload return type

AG-UI version bump:
- Bump @ag-ui/client, @ag-ui/core, @ag-ui/encoder, @ag-ui/proto to 0.0.51
- Remove process.env Vite workaround (fixed upstream in 0.0.51)

Deprecation lifecycle:
- @deprecated JSDoc on all legacy image upload APIs
- ImageRenderer, ImageRendererProps, ImageUpload type, imageUploadsEnabled prop,
  inputFileAccept prop, ImageRenderer prop, AIMessage.image, ImageData
- Codemod at codemods/migrate-attachments.ts (15 tests)
- Migration guide updated with codemod instructions and new type shapes

Docs:
- New guide: docs/(root)/multimodal-attachments.mdx
- Updated migration guide with onUpload return type, metadata, codemod section
- Cross-links from prebuilt-components and migration guide
- Label change: "Add photos or files" → "Add attachments"

Tests:
- CopilotChat.attachments.test.tsx — 5 tests for onUploadFailed
- migrate-attachments codemod — 15 tests
2026-04-06 14:55:06 +02:00

177 lines
5.6 KiB
TypeScript

/**
* Codemod: migrate-attachments
*
* Migrates from the deprecated image-upload API to the new attachments API.
*
* Transformations:
* 1. JSX props on CopilotChat / CopilotSidebar / CopilotPopup:
* - imageUploadsEnabled={true} → attachments={{ enabled: true }}
* - inputFileAccept="..." → merged into attachments={{ accept: "..." }}
* - Both props present → attachments={{ enabled: true, accept: "..." }}
*
* 2. Named imports from "@copilotkit/react-ui":
* - ImageUploadQueue → AttachmentQueue
* - ImageUpload (type) → Attachment (type)
*
* Usage:
* npx jscodeshift -t ./codemods/migrate-attachments.ts --extensions=tsx,ts ./src
*/
import type {
API,
FileInfo,
JSXElement,
JSXAttribute,
JSXExpressionContainer,
ImportSpecifier,
} from "jscodeshift";
const COPILOTKIT_PACKAGE = "@copilotkit/react-ui";
const TARGET_COMPONENTS = new Set([
"CopilotChat",
"CopilotSidebar",
"CopilotPopup",
]);
const IMPORT_RENAMES: Record<string, string> = {
ImageUploadQueue: "AttachmentQueue",
ImageUpload: "Attachment",
};
export default function transform(file: FileInfo, api: API) {
const j = api.jscodeshift;
const root = j(file.source);
let changed = false;
// -----------------------------------------------------------------------
// 1. Rename imports from @copilotkit/react-ui
// -----------------------------------------------------------------------
root
.find(j.ImportDeclaration, { source: { value: COPILOTKIT_PACKAGE } })
.forEach((path) => {
const specifiers = path.node.specifiers;
if (!specifiers) return;
for (const spec of specifiers) {
if (spec.type !== "ImportSpecifier") continue;
const imported = (spec as ImportSpecifier).imported;
if (imported.type !== "Identifier") continue;
const newName = IMPORT_RENAMES[imported.name];
if (!newName) continue;
const localName = spec.local?.name ?? imported.name;
const isAliased = localName !== imported.name;
// Rename the imported identifier
imported.name = newName;
// If the local name matched the old imported name (not aliased),
// update all references in the file to use the new name.
if (!isAliased) {
root.find(j.Identifier, { name: localName }).forEach((idPath) => {
// Skip the import specifier itself — already renamed above
if (idPath.parent.node === spec) return;
idPath.node.name = newName;
});
// Also update JSX element names (opening + closing tags)
root.find(j.JSXIdentifier, { name: localName }).forEach((idPath) => {
idPath.node.name = newName;
});
if (spec.local) {
spec.local.name = newName;
}
}
changed = true;
}
});
// -----------------------------------------------------------------------
// 2. Transform JSX props on CopilotChat / CopilotSidebar / CopilotPopup
// -----------------------------------------------------------------------
root.find(j.JSXOpeningElement).forEach((path) => {
const nameNode = path.node.name;
if (nameNode.type !== "JSXIdentifier") return;
if (!TARGET_COMPONENTS.has(nameNode.name)) return;
const attrs = path.node.attributes;
if (!attrs) return;
// Find the deprecated props
let imageUploadsAttr: JSXAttribute | null = null;
let inputFileAcceptAttr: JSXAttribute | null = null;
let existingAttachmentsAttr: JSXAttribute | null = null;
for (const attr of attrs) {
if (
attr.type !== "JSXAttribute" ||
!attr.name ||
attr.name.type !== "JSXIdentifier"
)
continue;
if (attr.name.name === "imageUploadsEnabled") imageUploadsAttr = attr;
if (attr.name.name === "inputFileAccept") inputFileAcceptAttr = attr;
if (attr.name.name === "attachments") existingAttachmentsAttr = attr;
}
// Skip if neither deprecated prop is present
if (!imageUploadsAttr && !inputFileAcceptAttr) return;
// Skip if attachments prop already exists (already migrated or manual)
if (existingAttachmentsAttr) return;
// Build the attachments object properties
const properties = [];
if (imageUploadsAttr) {
properties.push(
j.objectProperty(j.identifier("enabled"), j.booleanLiteral(true)),
);
}
if (inputFileAcceptAttr) {
let acceptValue: string | null = null;
if (inputFileAcceptAttr.value) {
if (inputFileAcceptAttr.value.type === "StringLiteral") {
acceptValue = inputFileAcceptAttr.value.value;
} else if (
inputFileAcceptAttr.value.type === "JSXExpressionContainer" &&
inputFileAcceptAttr.value.expression.type === "StringLiteral"
) {
acceptValue = inputFileAcceptAttr.value.expression.value;
}
}
if (acceptValue) {
properties.push(
j.objectProperty(
j.identifier("accept"),
j.stringLiteral(acceptValue),
),
);
}
}
if (properties.length === 0) return;
// Create: attachments={{ enabled: true, accept: "..." }}
const attachmentsAttr = j.jsxAttribute(
j.jsxIdentifier("attachments"),
j.jsxExpressionContainer(j.objectExpression(properties)),
);
// Remove old props, add new one
path.node.attributes = attrs.filter(
(attr) => attr !== imageUploadsAttr && attr !== inputFileAcceptAttr,
);
path.node.attributes.push(attachmentsAttr);
changed = true;
});
return changed ? root.toSource() : undefined;
}