tool-ui
blockimage-gallerysource: ok · hit
Image Gallery
Grid layout for browsing image collections.
npx shadcn add "https://www.tool-ui.com/r/image-gallery.json"free
Where it goes
gallery
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
formszod
Registry dependencies
button
Files18 · source cached 10/8/2026, 3:38:46 PM
components/tool-ui/image-gallery/_adapter.tsx
/**
* Adapter: UI and utility re-exports for copy-standalone portability.
*
* When copying this component to another project, update these imports
* to match your project's paths:
*
* cn → Your Tailwind merge utility (e.g., "@/lib/utils", "~/lib/cn")
* Button → shadcn/ui Button
*/
export { cn } from "@/lib/utils";
export { Button } from "@/components/ui/button";
export { ChevronLeft, ChevronRight, X, ImageOff } from "lucide-react";
components/tool-ui/image-gallery/context.tsx
"use client";
import {
createContext,
use,
useState,
useCallback,
useMemo,
useRef,
} from "react";
import { flushSync } from "react-dom";
import type { ImageGalleryItem } from "./schema";
const VIEW_TRANSITION_NAME = "active-gallery-image";
interface ImageGalleryContextValue {
images: ImageGalleryItem[];
activeIndex: number | null;
openLightbox: (index: number) => void;
closeLightbox: () => void;
registerImage: (id: string, element: HTMLElement | null) => void;
lightboxContentRef: React.RefObject<HTMLDivElement | null>;
setDialogRef: (element: HTMLDialogElement | null) => void;
}
const ImageGalleryContext = createContext<ImageGalleryContextValue | null>(
null,
);
export function useImageGallery(): ImageGalleryContextValue {
const context = use(ImageGalleryContext);
if (!context) {
throw new Error("useImageGallery must be used within ImageGalleryProvider");
}
return context;
}
function supportsViewTransitions(): boolean {
return (
typeof document !== "undefined" &&
"startViewTransition" in document &&
typeof window !== "undefined" &&
!window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches
);
}
function withViewTransition(
element: HTMLElement,
domUpdate: () => void,
onFinished?: () => void,
): void {
if (!supportsViewTransitions()) {
domUpdate();
onFinished?.();
return;
}
element.style.viewTransitionName = VIEW_TRANSITION_NAME;
const transition = document.startViewTransition(() => domUpdate());
transition.finished.finally(() => {
element.style.removeProperty("view-transition-name");
onFinished?.();
});
}
interface ImageGalleryProviderProps {
images: ImageGalleryItem[];
children: React.ReactNode;
}
export function ImageGalleryProvider({
images,
children,
}: ImageGalleryProviderProps) {
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const imageElementsRef = useRef<Map<string, HTMLElement>>(new Map());
const lightboxContentRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDialogElement | null>(null);
const originalParentRef = useRef<HTMLElement | null>(null);
const registerImage = useCallback(
(id: string, element: HTMLElement | null) => {
if (element) {
imageElementsRef.current.set(id, element);
} else {
imageElementsRef.current.delete(id);
}
},
[],
);
const setDialogRef = useCallback((element: HTMLDialogElement | null) => {
dialogRef.current = element;
}, []);
const openLightbox = useCallback(
(index: number) => {
const image = images[index];
if (!image) return;
const imageElement = imageElementsRef.current.get(image.id);
const container = lightboxContentRef.current;
const dialog = dialogRef.current;
if (!imageElement || !container || !dialog) {
setActiveIndex(index);
dialog?.showModal();
return;
}
originalParentRef.current = imageElement.parentElement;
withViewTransition(imageElement, () => {
container.appendChild(imageElement);
flushSync(() => setActiveIndex(index));
dialog.showModal();
});
},
[images],
);
const closeLightbox = useCallback(() => {
if (activeIndex === null) return;
const image = images[activeIndex];
const dialog = dialogRef.current;
if (!image) {
setActiveIndex(null);
dialog?.close();
return;
}
const imageElement = imageElementsRef.current.get(image.id);
const originalParent = originalParentRef.current;
if (!imageElement || !originalParent) {
setActiveIndex(null);
dialog?.close();
return;
}
withViewTransition(
imageElement,
() => {
originalParent.appendChild(imageElement);
flushSync(() => setActiveIndex(null));
dialog?.close();
},
() => {
originalParentRef.current = null;
},
);
}, [activeIndex, images]);
const value = useMemo<ImageGalleryContextValue>(
() => ({
images,
activeIndex,
openLightbox,
closeLightbox,
registerImage,
lightboxContentRef,
setDialogRef,
}),
[
images,
activeIndex,
openLightbox,
closeLightbox,
registerImage,
setDialogRef,
],
);
return (
<ImageGalleryContext.Provider value={value}>
{children}
</ImageGalleryContext.Provider>
);
}
components/tool-ui/image-gallery/gallery-grid.tsx
"use client";
import { useState, useCallback, useEffect, useRef } from "react";
import { cn, ImageOff } from "./_adapter";
import { useImageGallery } from "./context";
import type { ImageGalleryItem } from "./schema";
type GridImage = Pick<
ImageGalleryItem,
"id" | "src" | "alt" | "width" | "height"
>;
interface GalleryGridProps {
onImageClick?: (imageId: string) => void;
}
export function GalleryGrid({ onImageClick }: GalleryGridProps) {
const { images, openLightbox } = useImageGallery();
const handleOpen = useCallback(
(index: number) => {
const image = images[index];
if (image && onImageClick) {
onImageClick(image.id);
}
openLightbox(index);
},
[images, onImageClick, openLightbox],
);
return (
<div
className="grid grid-cols-2 gap-2 @md:grid-cols-3 @lg:grid-cols-4"
role="list"
>
{images.map((image, index) => (
<GridImageCard
key={image.id}
image={image}
index={index}
onClick={handleOpen}
/>
))}
</div>
);
}
interface GridImageCardProps {
image: GridImage;
index: number;
onClick: (index: number) => void;
}
function GridImageCard({ image, index, onClick }: GridImageCardProps) {
const [hasError, setHasError] = useState(false);
const wrapperRef = useRef<HTMLDivElement>(null);
const { registerImage } = useImageGallery();
const shouldSpanTwoRows = isPortraitImage(image);
useEffect(() => {
const wrapper = wrapperRef.current;
const img = wrapper?.querySelector("img");
if (img) {
registerImage(image.id, img);
}
return () => {
registerImage(image.id, null);
};
}, [image.id, registerImage]);
const handleClick = useCallback(() => {
onClick(index);
}, [onClick, index]);
return (
<div
role="listitem"
className={cn(
"group relative cursor-pointer",
shouldSpanTwoRows && "row-span-2",
)}
style={{ aspectRatio: shouldSpanTwoRows ? undefined : "1 / 1" }}
>
<button
type="button"
onClick={handleClick}
className="absolute inset-0 z-20 h-full w-full rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
aria-label={image.alt}
/>
<div
ref={wrapperRef}
className="bg-muted relative h-full w-full overflow-hidden rounded-lg transition-transform duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] group-hover:scale-[1.02] group-active:scale-[0.98]"
>
{hasError ? (
<ImageErrorState alt={image.alt} />
) : (
<img
src={image.src}
alt={image.alt}
width={image.width}
height={image.height}
loading="lazy"
decoding="async"
draggable={false}
onError={() => setHasError(true)}
className="h-full w-full object-cover"
/>
)}
</div>
</div>
);
}
function isPortraitImage(image: GridImage): boolean {
const aspectRatio = image.width / image.height;
const isPortrait = aspectRatio < 1;
const isSquarish = aspectRatio >= 0.9 && aspectRatio <= 1.1;
return isPortrait && !isSquarish;
}
function ImageErrorState({ alt }: { alt: string }) {
return (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 p-4">
<ImageOff className="text-muted-foreground h-8 w-8" />
<span className="text-muted-foreground line-clamp-2 text-center text-xs">
{alt}
</span>
</div>
);
}
components/tool-ui/image-gallery/gallery-lightbox.tsx
"use client";
import { useRef, useCallback } from "react";
import { cn, Button, X } from "./_adapter";
import { useImageGallery } from "./context";
import type { ImageGalleryItem } from "./schema";
import { resolveSafeNavigationHref } from "../shared/media";
type LightboxImage = Pick<ImageGalleryItem, "title" | "caption" | "source">;
export function GalleryLightbox() {
const dialogRef = useRef<HTMLDialogElement>(null);
const {
images,
activeIndex,
closeLightbox,
lightboxContentRef,
setDialogRef,
} = useImageGallery();
const isOpen = activeIndex !== null;
const currentImage = isOpen ? images[activeIndex] : null;
const handleDialogRef = useCallback(
(element: HTMLDialogElement | null) => {
dialogRef.current = element;
setDialogRef(element);
},
[setDialogRef],
);
const handleBackdropClick = useCallback(
(e: React.MouseEvent<HTMLDialogElement>) => {
if (e.target === dialogRef.current) {
closeLightbox();
}
},
[closeLightbox],
);
const handleCancel = useCallback(
(e: React.SyntheticEvent<HTMLDialogElement>) => {
e.preventDefault();
closeLightbox();
},
[closeLightbox],
);
return (
<dialog
ref={handleDialogRef}
onClick={handleBackdropClick}
onCancel={handleCancel}
className={cn(
"m-0 h-full max-h-full w-full max-w-full",
"overflow-hidden p-0",
"bg-transparent backdrop:bg-black/95 dark:backdrop:bg-black/90",
"focus-visible:outline-none",
)}
aria-label="Image lightbox"
>
<div className="relative h-full w-full">
{isOpen && <CloseButton onClose={closeLightbox} />}
<div className="relative z-10 flex h-full w-full flex-col items-center justify-center gap-4 p-8">
<div
ref={lightboxContentRef}
className={cn(
"pointer-events-auto relative w-fit max-w-full overflow-hidden rounded-lg shadow-2xl",
"[&>img]:block [&>img]:max-h-[80vh] [&>img]:max-w-full",
"[&>img]:h-auto [&>img]:w-auto [&>img]:object-contain [&>img]:select-none",
)}
/>
{currentImage && <Metadata image={currentImage} />}
</div>
</div>
</dialog>
);
}
function CloseButton({ onClose }: { onClose: () => void }) {
return (
<div className="absolute top-4 right-4 z-20">
<Button
type="button"
variant="ghost"
size="icon"
onClick={onClose}
className="text-white/80 hover:bg-white/10 hover:text-white"
aria-label="Close"
>
<X className="h-5 w-5" />
</Button>
</div>
);
}
function Metadata({ image }: { image: LightboxImage }) {
const { title, caption, source } = image;
const hasTitle = Boolean(title);
const hasCaption = Boolean(caption);
const hasSource = Boolean(source?.label);
if (!hasTitle && !hasCaption && !hasSource) {
return null;
}
return (
<div className="text-center">
{hasTitle && (
<h3 className="text-base font-medium tracking-tight text-white">
{title}
</h3>
)}
{(hasCaption || hasSource) && (
<p className="mt-1 text-sm text-white/60">
{caption}
{hasCaption && hasSource && " · "}
{hasSource && <SourceLink source={source!} />}
</p>
)}
</div>
);
}
function SourceLink({
source,
}: {
source: NonNullable<LightboxImage["source"]>;
}) {
const href = resolveSafeNavigationHref(source.url);
if (!href) {
return <>{source.label}</>;
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="hover:text-white/80 hover:underline"
>
{source.label}
</a>
);
}
components/tool-ui/image-gallery/image-gallery.tsx
"use client";
import "./styles.css";
import { cn } from "./_adapter";
import { ImageGalleryProvider } from "./context";
import { GalleryGrid } from "./gallery-grid";
import { GalleryLightbox } from "./gallery-lightbox";
import type { ImageGalleryProps } from "./schema";
export function ImageGallery({
id,
images,
title,
description,
className,
onImageClick,
}: ImageGalleryProps) {
const handleImageClick = (imageId: string) => {
if (!onImageClick) return;
const image = images.find((img) => img.id === imageId);
if (image) {
onImageClick(imageId, image);
}
};
return (
<article
className={cn("relative w-full min-w-80 max-w-lg", className)}
data-tool-ui-id={id}
data-slot="image-gallery"
>
<div
className={cn(
"@container relative isolate flex w-full min-w-0 flex-col rounded-xl",
"border border-border bg-card text-sm shadow-xs",
)}
>
<ImageGalleryProvider images={images}>
<Header title={title} description={description} />
<div className="p-3">
<GalleryGrid onImageClick={handleImageClick} />
</div>
<GalleryLightbox />
</ImageGalleryProvider>
</div>
</article>
);
}
interface HeaderProps {
title?: string;
description?: string;
}
function Header({ title, description }: HeaderProps) {
if (!title && !description) {
return null;
}
return (
<div className="border-border/60 border-b px-4 pt-4 pb-3">
{title && (
<h3 className="text-[15px] leading-tight font-semibold tracking-tight">
{title}
</h3>
)}
{description && (
<p className="text-muted-foreground mt-1 text-sm leading-snug">
{description}
</p>
)}
</div>
);
}
components/tool-ui/image-gallery/index.tsx
export { ImageGallery } from "./image-gallery";
export type {
ImageGalleryProps,
ImageGalleryItem,
SerializableImageGallery,
} from "./schema";
components/tool-ui/image-gallery/README.md
# Image Gallery
Implementation for the "image-gallery" Tool UI surface.
## Files
- public exports: components/tool-ui/image-gallery/index.tsx
- serializable schema + parse helpers: components/tool-ui/image-gallery/schema.ts
## Companion assets
- Docs page: app/docs/image-gallery/content.mdx
- Preset payload: lib/presets/image-gallery.ts
## Quick check
Run this after edits:
pnpm test
components/tool-ui/image-gallery/schema.ts
import { z } from "zod";
import { defineToolUiContract } from "../shared/contract";
import {
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
export const ImageGallerySourceSchema = z.object({
label: z.string(),
url: z.string().url().optional(),
});
export type ImageGallerySource = z.infer<typeof ImageGallerySourceSchema>;
export const ImageGalleryItemSchema = z.object({
id: z.string().min(1),
src: z.string().url(),
alt: z.string().min(1, "Images require alt text for accessibility"),
width: z.number().positive(),
height: z.number().positive(),
title: z.string().optional(),
caption: z.string().optional(),
source: ImageGallerySourceSchema.optional(),
});
export type ImageGalleryItem = z.infer<typeof ImageGalleryItemSchema>;
export const SerializableImageGallerySchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
images: z.array(ImageGalleryItemSchema).min(1),
title: z.string().optional(),
description: z.string().optional(),
});
export type SerializableImageGallery = z.infer<
typeof SerializableImageGallerySchema
>;
export interface ImageGalleryProps extends SerializableImageGallery {
className?: string;
onImageClick?: (imageId: string, image: ImageGalleryItem) => void;
}
const SerializableImageGallerySchemaContract = defineToolUiContract(
"ImageGallery",
SerializableImageGallerySchema,
);
export const parseSerializableImageGallery: (
input: unknown,
) => SerializableImageGallery = SerializableImageGallerySchemaContract.parse;
export const safeParseSerializableImageGallery: (
input: unknown,
) => SerializableImageGallery | null =
SerializableImageGallerySchemaContract.safeParse;
components/tool-ui/image-gallery/styles.css
@supports (view-transition-name: none) {
::view-transition-group(active-gallery-image) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
overflow: clip;
border-radius: 0.75rem;
}
::view-transition-image-pair(active-gallery-image) {
overflow: clip;
border-radius: 0.75rem;
}
::view-transition-old(active-gallery-image),
::view-transition-new(active-gallery-image) {
border-radius: 0.75rem;
mix-blend-mode: normal;
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(active-gallery-image) {
animation-duration: 0ms;
}
}
}
components/tool-ui/shared/contract.ts
import { z } from "zod";
import { parseWithSchema, safeParseWithSchema } from "./parse";
export interface ToolUiContract<T> {
schema: z.ZodType<T>;
parse: (input: unknown) => T;
safeParse: (input: unknown) => T | null;
}
export function defineToolUiContract<T>(
componentName: string,
schema: z.ZodType<T>,
): ToolUiContract<T> {
return {
schema,
parse: (input: unknown) => parseWithSchema(schema, input, componentName),
safeParse: (input: unknown) => safeParseWithSchema(schema, input),
};
}
components/tool-ui/shared/media/aspect-ratio.ts
import { z } from "zod";
export const AspectRatioSchema = z
.enum(["auto", "1:1", "4:3", "16:9", "9:16"])
.default("auto");
export type AspectRatio = z.infer<typeof AspectRatioSchema>;
export const MediaFitSchema = z.enum(["cover", "contain"]).default("cover");
export type MediaFit = z.infer<typeof MediaFitSchema>;
export const RATIO_CLASS_MAP: Record<AspectRatio, string> = {
auto: "",
"1:1": "aspect-square",
"4:3": "aspect-[4/3]",
"16:9": "aspect-video",
"9:16": "aspect-[9/16]",
};
export function getRatioClass(ratio: AspectRatio): string {
return RATIO_CLASS_MAP[ratio];
}
export function getFitClass(fit: MediaFit): string {
return fit === "cover" ? "object-cover" : "object-contain";
}
components/tool-ui/shared/media/format-utils.ts
/**
* Format duration in milliseconds to human-readable string.
* @example formatDuration(128000) => "2:08"
* @example formatDuration(3661000) => "1:01:01"
*/
export function formatDuration(durationMs: number): string {
const totalSeconds = Math.round(durationMs / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds
.toString()
.padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}
/**
* Format file size in bytes to human-readable string.
* @example formatFileSize(1024) => "1 KB"
* @example formatFileSize(1536000) => "1.5 MB"
*/
export function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB"];
let size = bytes / 1024;
let unit = 0;
while (size >= 1024 && unit < units.length - 1) {
size /= 1024;
unit += 1;
}
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unit]}`;
}
components/tool-ui/shared/media/index.ts
export {
AspectRatioSchema,
MediaFitSchema,
RATIO_CLASS_MAP,
getRatioClass,
getFitClass,
type AspectRatio,
type MediaFit,
} from "./aspect-ratio";
export { OVERLAY_GRADIENT } from "./overlay-gradient";
export { formatDuration, formatFileSize } from "./format-utils";
export { sanitizeHref } from "./sanitize-href";
export {
resolveSafeNavigationHref,
openSafeNavigationHref,
} from "./safe-navigation";
components/tool-ui/shared/media/overlay-gradient.ts
/**
* Eased gradient for hover overlays on media elements.
* Creates a smooth fade from opaque black at top to transparent.
*
* @see https://larsenwork.com/easing-gradients/
*/
export const OVERLAY_GRADIENT = `linear-gradient(
to bottom,
hsl(0, 0%, 0%) 0%,
hsla(0, 0%, 0%, 0.987) 8.3%,
hsla(0, 0%, 0%, 0.951) 16.6%,
hsla(0, 0%, 0%, 0.896) 24.6%,
hsla(0, 0%, 0%, 0.825) 32.5%,
hsla(0, 0%, 0%, 0.741) 40.1%,
hsla(0, 0%, 0%, 0.648) 47.6%,
hsla(0, 0%, 0%, 0.55) 54.8%,
hsla(0, 0%, 0%, 0.45) 61.7%,
hsla(0, 0%, 0%, 0.352) 68.3%,
hsla(0, 0%, 0%, 0.259) 74.5%,
hsla(0, 0%, 0%, 0.175) 80.4%,
hsla(0, 0%, 0%, 0.104) 86%,
hsla(0, 0%, 0%, 0.049) 91.1%,
hsla(0, 0%, 0%, 0.013) 95.8%,
hsla(0, 0%, 0%, 0) 100%
)` as const;
components/tool-ui/shared/media/safe-navigation.ts
import { sanitizeHref } from "./sanitize-href";
export function resolveSafeNavigationHref(
...candidates: Array<string | null | undefined>
): string | undefined {
for (const candidate of candidates) {
const safeHref = sanitizeHref(candidate ?? undefined);
if (safeHref) {
return safeHref;
}
}
return undefined;
}
export function openSafeNavigationHref(href: string | undefined): boolean {
if (!href || typeof window === "undefined") {
return false;
}
window.open(href, "_blank", "noopener,noreferrer");
return true;
}
components/tool-ui/shared/media/sanitize-href.ts
/**
* Sanitize a URL to ensure it's safe for use in href attributes.
* Allows:
* - Absolute http(s) URLs
* - Relative URLs (/path, ./path, ../path, ?query, #hash)
*
* @returns The sanitized URL string, or undefined if invalid/unsafe
*/
export function sanitizeHref(href?: string): string | undefined {
if (!href) return undefined;
const candidate = href.trim();
if (!candidate) return undefined;
if (
candidate.startsWith("/") ||
candidate.startsWith("./") ||
candidate.startsWith("../") ||
candidate.startsWith("?") ||
candidate.startsWith("#")
) {
if (candidate.startsWith("//")) return undefined;
// eslint-disable-next-line no-control-regex -- intentionally matching control characters
if (/[\u0000-\u001F\u007F]/.test(candidate)) return undefined;
return candidate;
}
try {
const url = new URL(candidate);
if (url.protocol === "http:" || url.protocol === "https:") {
return url.toString();
}
} catch {
return undefined;
}
return undefined;
}
components/tool-ui/shared/parse.ts
import { z } from "zod";
function formatZodPath(path: Array<string | number | symbol>): string {
if (path.length === 0) return "root";
return path
.map((segment) =>
typeof segment === "number" ? `[${segment}]` : String(segment),
)
.join(".");
}
/**
* Format Zod errors into a compact `path: message` string.
*/
export function formatZodError(error: z.ZodError): string {
const parts = error.issues.map((issue) => {
const path = formatZodPath(issue.path);
return `${path}: ${issue.message}`;
});
return Array.from(new Set(parts)).join("; ");
}
/**
* Parse unknown input and throw a readable error.
*/
export function parseWithSchema<T>(
schema: z.ZodType<T>,
input: unknown,
name: string,
): T {
const res = schema.safeParse(input);
if (!res.success) {
throw new Error(`Invalid ${name} payload: ${formatZodError(res.error)}`);
}
return res.data;
}
/**
* Parse unknown input, returning `null` instead of throwing on failure.
*
* Use this in assistant-ui `render` functions where `args` stream in
* incrementally and may be incomplete until the tool call finishes.
*/
export function safeParseWithSchema<T>(
schema: z.ZodType<T>,
input: unknown,
): T | null {
const res = schema.safeParse(input);
return res.success ? res.data : null;
}
components/tool-ui/shared/schema.ts
import { z } from "zod";
import type { ReactNode } from "react";
/**
* Tool UI conventions:
* - Serializable schemas are JSON-safe (no callbacks/ReactNode/`className`).
* - Schema: `SerializableXSchema`
* - Parser: `parseSerializableX(input: unknown)` (throws on invalid)
* - Safe parser: `safeParseSerializableX(input: unknown)` (returns `null` on invalid)
* - Actions: `LocalActions` for non-receipt actions and `DecisionActions` for consequential actions
* - Root attrs: `data-tool-ui-id` + `data-slot`
*/
/**
* Schema for tool UI identity.
*
* Every tool UI should have a unique identifier that:
* - Is stable across re-renders
* - Is meaningful (not auto-generated)
* - Is unique within the conversation
*
* Format recommendation: `{component-type}-{semantic-identifier}`
* Examples: "data-table-expenses-q3", "option-list-deploy-target"
*/
export const ToolUIIdSchema = z.string().min(1);
export type ToolUIId = z.infer<typeof ToolUIIdSchema>;
/**
* Primary role of a Tool UI surface in a chat context.
*/
export const ToolUIRoleSchema = z.enum([
"information",
"decision",
"control",
"state",
"composite",
]);
export type ToolUIRole = z.infer<typeof ToolUIRoleSchema>;
export const ToolUIReceiptOutcomeSchema = z.enum([
"success",
"partial",
"failed",
"cancelled",
]);
export type ToolUIReceiptOutcome = z.infer<typeof ToolUIReceiptOutcomeSchema>;
/**
* Optional receipt metadata: a durable summary of an outcome.
*/
export const ToolUIReceiptSchema = z.object({
outcome: ToolUIReceiptOutcomeSchema,
summary: z.string().min(1),
identifiers: z.record(z.string(), z.string()).optional(),
at: z.string().datetime(),
});
export type ToolUIReceipt = z.infer<typeof ToolUIReceiptSchema>;
/**
* Base schema for Tool UI payloads (id + optional role/receipt).
*/
export const ToolUISurfaceSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
});
export type ToolUISurface = z.infer<typeof ToolUISurfaceSchema>;
export const ActionSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
/**
* Canonical narration the assistant can use after this action is taken.
*
* Example: "I exported the table as CSV." / "I opened the link in a new tab."
*/
sentence: z.string().optional(),
confirmLabel: z.string().optional(),
variant: z
.enum(["default", "destructive", "secondary", "ghost", "outline"])
.optional(),
icon: z.custom<ReactNode>().optional(),
loading: z.boolean().optional(),
disabled: z.boolean().optional(),
shortcut: z.string().optional(),
});
export type Action = z.infer<typeof ActionSchema>;
export type LocalAction = Action;
export type DecisionAction = Action;
export const DecisionResultSchema = z.object({
kind: z.literal("decision"),
version: z.literal(1),
decisionId: z.string().min(1),
actionId: z.string().min(1),
actionLabel: z.string().min(1),
at: z.string().datetime(),
payload: z.record(z.string(), z.unknown()).optional(),
});
export type DecisionResult<
TPayload extends Record<string, unknown> = Record<string, unknown>,
> = Omit<z.infer<typeof DecisionResultSchema>, "payload"> & {
payload?: TPayload;
};
export function createDecisionResult<
TPayload extends Record<string, unknown> = Record<string, unknown>,
>(args: {
decisionId: string;
action: { id: string; label: string };
payload?: TPayload;
}): DecisionResult<TPayload> {
return {
kind: "decision",
version: 1,
decisionId: args.decisionId,
actionId: args.action.id,
actionLabel: args.action.label,
at: new Date().toISOString(),
payload: args.payload,
};
}
export const ActionButtonsPropsSchema = z.object({
actions: z.array(ActionSchema).min(1),
align: z.enum(["left", "center", "right"]).optional(),
confirmTimeout: z.number().positive().optional(),
className: z.string().optional(),
});
export const SerializableActionSchema = ActionSchema.omit({ icon: true });
export const SerializableActionsSchema = ActionButtonsPropsSchema.extend({
actions: z.array(SerializableActionSchema),
}).omit({ className: true });
export interface ActionsConfig {
items: Action[];
align?: "left" | "center" | "right";
confirmTimeout?: number;
}
export const SerializableActionsConfigSchema = z.object({
items: z.array(SerializableActionSchema).min(1),
align: z.enum(["left", "center", "right"]).optional(),
confirmTimeout: z.number().positive().optional(),
});
export type SerializableActionsConfig = z.infer<
typeof SerializableActionsConfigSchema
>;
export type SerializableAction = z.infer<typeof SerializableActionSchema>;