tool-ui
blockpreferences-panelsource: ok · hit
Preferences Panel
Compact settings panel for user preferences.
npx shadcn add "https://www.tool-ui.com/r/preferences-panel.json"free
Where it goes
settings-form
Use cases
dashboard
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
buttonlabelselectseparatorswitchtoggle-group
Files16 · source cached 10/14/2026, 3:42:28 AM
components/tool-ui/preferences-panel/_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
* Switch → shadcn/ui Switch
* ToggleGroup → shadcn/ui ToggleGroup
* Select → shadcn/ui Select
* Separator → shadcn/ui Separator
* Label → shadcn/ui Label
*/
export { cn } from "@/lib/utils";
export { Button } from "@/components/ui/button";
export { Switch } from "@/components/ui/switch";
export { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
export {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export { Separator } from "@/components/ui/separator";
export { Label } from "@/components/ui/label";
components/tool-ui/preferences-panel/index.tsx
export { PreferencesPanel, PreferencesPanelReceipt } from "./preferences-panel";
export {
type SerializablePreferencesPanel,
type SerializablePreferencesPanelReceipt,
type PreferencesPanelProps,
type PreferencesPanelReceiptProps,
type PreferencesValue,
type PreferenceItem,
type PreferenceSection,
} from "./schema";
components/tool-ui/preferences-panel/preferences-panel.tsx
"use client";
import { useCallback, useMemo } from "react";
import type {
PreferencesPanelProps,
PreferencesPanelReceiptProps,
PreferencesValue,
PreferenceItem,
PreferenceSection,
} from "./schema";
import { ActionButtons } from "../shared/action-buttons";
import { normalizeActionsConfig } from "../shared/actions-config";
import { type Action } from "../shared/schema";
import { useControllableState } from "../shared/use-controllable-state";
import { useSignatureReset } from "../shared/use-signature-reset";
import {
cn,
Switch,
ToggleGroup,
ToggleGroupItem,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Separator,
Label,
} from "./_adapter";
import { Check, AlertCircle } from "lucide-react";
import { createPreferencesSectionSignature } from "./signature";
function getInitialValue(item: PreferenceItem): string | boolean {
switch (item.type) {
case "switch":
return item.defaultChecked ?? false;
case "toggle":
return item.defaultValue ?? item.options?.[0]?.value ?? "";
case "select":
return item.defaultSelected ?? item.selectOptions?.[0]?.value ?? "";
}
}
function formatDisplayValue(
item: PreferenceItem,
value: string | boolean,
): string {
if (item.type === "switch") {
return typeof value === "boolean" && value ? "On" : "Off";
}
const stringValue = typeof value === "string" ? value : "";
const options = item.type === "toggle" ? item.options : item.selectOptions;
const option = options?.find((opt) => opt.value === stringValue);
return option?.label ?? stringValue;
}
function computeInitialValues(sections: PreferenceSection[]): PreferencesValue {
return sections.reduce<PreferencesValue>((acc, section) => {
section.items.forEach((item) => {
acc[item.id] = getInitialValue(item);
});
return acc;
}, {});
}
interface PreferenceControlProps {
item: PreferenceItem;
value: string | boolean;
onChange: (value: string | boolean) => void;
disabled?: boolean;
}
function SwitchControl({
id,
checked,
onChange,
disabled,
label,
}: {
id: string;
checked: boolean;
onChange: (value: boolean) => void;
disabled?: boolean;
label: string;
}) {
return (
<Switch
id={id}
checked={checked}
onCheckedChange={onChange}
disabled={disabled}
aria-label={label}
/>
);
}
function ToggleControl({
value,
options,
onChange,
disabled,
label,
}: {
value: string;
options: Array<{ value: string; label: string }>;
onChange: (value: string) => void;
disabled?: boolean;
label: string;
}) {
return (
<ToggleGroup
type="single"
value={value}
onValueChange={(v) => v && onChange(v)}
disabled={disabled}
aria-label={label}
className="gap-1"
>
{options.map((opt) => (
<ToggleGroupItem
key={opt.value}
value={opt.value}
aria-label={opt.label}
className="!rounded-full px-3 py-1.5 text-sm"
>
{opt.label}
</ToggleGroupItem>
))}
</ToggleGroup>
);
}
function SelectControl({
id,
value,
options,
onChange,
disabled,
label,
}: {
id: string;
value: string;
options: Array<{ value: string; label: string }>;
onChange: (value: string) => void;
disabled?: boolean;
label: string;
}) {
return (
<Select value={value} onValueChange={onChange} disabled={disabled}>
<SelectTrigger id={id} className="w-[180px]" aria-label={label}>
<SelectValue placeholder="Select..." />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function PreferenceControl({
item,
value,
onChange,
disabled,
}: PreferenceControlProps) {
const id = `preference-${item.id}`;
if (item.type === "switch") {
return (
<SwitchControl
id={id}
checked={typeof value === "boolean" ? value : false}
onChange={onChange}
disabled={disabled}
label={item.label}
/>
);
}
const stringValue = typeof value === "string" ? value : "";
if (item.type === "toggle" && item.options) {
return (
<ToggleControl
value={stringValue}
options={item.options}
onChange={onChange}
disabled={disabled}
label={item.label}
/>
);
}
if (item.type === "select" && item.selectOptions) {
return (
<SelectControl
id={id}
value={stringValue}
options={item.selectOptions}
onChange={onChange}
disabled={disabled}
label={item.label}
/>
);
}
return null;
}
interface PreferenceItemRowProps {
item: PreferenceItem;
value: string | boolean;
onChange?: (value: string | boolean) => void;
disabled?: boolean;
isReceipt?: boolean;
error?: string;
showSuccessIndicators?: boolean;
isFirstInSectionWithoutHeading?: boolean;
}
function ItemLabel({
item,
error,
isReceipt,
}: {
item: PreferenceItem;
error?: string;
isReceipt: boolean;
}) {
const htmlFor = `preference-${item.id}`;
if (isReceipt) {
return (
<>
<span className="text-sm leading-6 font-medium text-pretty">
{item.label}
</span>
{error ? (
<span className="text-destructive text-sm font-normal text-pretty">
{error}
</span>
) : item.description ? (
<span className="text-muted-foreground text-sm font-normal text-pretty">
{item.description}
</span>
) : null}
</>
);
}
return (
<>
<Label htmlFor={htmlFor} className="leading-6 font-medium text-pretty">
{item.label}
</Label>
{item.description && (
<p className="text-muted-foreground text-sm font-normal text-pretty">
{item.description}
</p>
)}
</>
);
}
function ItemValue({
item,
value,
error,
showSuccessIndicators,
}: {
item: PreferenceItem;
value: string | boolean;
error?: string;
showSuccessIndicators: boolean;
}) {
const displayValue = formatDisplayValue(item, value);
return (
<div className="flex shrink-0 items-center gap-2">
<span className="text-muted-foreground text-sm font-medium">
{displayValue}
</span>
{error ? (
<AlertCircle className="text-destructive size-3.5" />
) : showSuccessIndicators ? (
<Check className="size-3.5 text-emerald-600 dark:text-emerald-500" />
) : null}
</div>
);
}
function PreferenceItemRow({
item,
value,
onChange,
disabled,
isReceipt = false,
error,
showSuccessIndicators = false,
isFirstInSectionWithoutHeading = false,
}: PreferenceItemRowProps) {
const shouldStack = item.type !== "switch" && !isReceipt;
return (
<div
className={cn(
"flex items-start justify-between gap-4",
isFirstInSectionWithoutHeading ? "pt-0 pb-3" : "py-3",
shouldStack &&
"flex-col gap-3 @sm/preferences-panel:flex-row @sm/preferences-panel:gap-4",
)}
>
<div className="flex flex-col gap-1">
<ItemLabel item={item} error={error} isReceipt={isReceipt} />
</div>
{isReceipt ? (
<ItemValue
item={item}
value={value}
error={error}
showSuccessIndicators={showSuccessIndicators}
/>
) : (
<div className="flex shrink-0">
<PreferenceControl
item={item}
value={value}
onChange={onChange!}
disabled={disabled}
/>
</div>
)}
</div>
);
}
interface ItemListProps {
items: PreferenceItem[];
values: PreferencesValue;
onChangeValue?: (itemId: string, value: string | boolean) => void;
disabled?: boolean;
isReceipt?: boolean;
errors?: Record<string, string>;
showSuccessIndicators?: boolean;
hasHeading?: boolean;
hasTitle?: boolean;
}
function ItemList({
items,
values,
onChangeValue,
disabled,
isReceipt,
errors,
showSuccessIndicators,
hasHeading = false,
hasTitle = false,
}: ItemListProps) {
const shouldRemoveFirstPadding = !hasHeading && hasTitle;
return (
<div className="flex flex-col">
{items.map((item, index) => {
const isFirst = index === 0;
const itemValue = values[item.id] ?? getInitialValue(item);
const handleChange = onChangeValue
? (v: string | boolean) => onChangeValue(item.id, v)
: undefined;
return (
<div key={item.id}>
{!isFirst && <Separator className="my-1" />}
<PreferenceItemRow
item={item}
value={itemValue}
onChange={handleChange}
disabled={disabled}
isReceipt={isReceipt}
error={errors?.[item.id]}
showSuccessIndicators={showSuccessIndicators}
isFirstInSectionWithoutHeading={
isFirst && shouldRemoveFirstPadding
}
/>
</div>
);
})}
</div>
);
}
interface PreferencesSectionProps {
section: PreferenceSection;
values: PreferencesValue;
onChangeValue?: (itemId: string, value: string | boolean) => void;
disabled?: boolean;
isReceipt?: boolean;
errors?: Record<string, string>;
hasTitle?: boolean;
}
function PreferencesSection({
section,
values,
onChangeValue,
disabled,
isReceipt = false,
errors,
hasTitle = false,
}: PreferencesSectionProps) {
const hasErrors = !!(errors && Object.keys(errors).length > 0);
const content = (
<ItemList
items={section.items}
values={values}
onChangeValue={onChangeValue}
disabled={disabled}
isReceipt={isReceipt}
errors={errors}
showSuccessIndicators={hasErrors}
hasHeading={!!section.heading}
hasTitle={hasTitle}
/>
);
if (section.heading) {
return (
<fieldset className="flex flex-col">
<legend className="text-muted-foreground pb-1 text-xs tracking-widest uppercase">
{section.heading}
</legend>
{content}
</fieldset>
);
}
return content;
}
interface ReceiptHeaderProps {
title: string;
hasErrors: boolean;
}
function ReceiptHeader({ title, hasErrors }: ReceiptHeaderProps) {
return (
<>
<div className="flex items-center justify-between gap-3 px-5 py-4">
<h2 className="text-base leading-none font-semibold">{title}</h2>
{hasErrors === true ? (
<span className="text-destructive flex items-center gap-1.5 text-xs font-medium">
<AlertCircle className="size-3.5" />
Error
</span>
) : (
<span className="flex items-center gap-1.5 text-xs font-medium text-emerald-600 dark:text-emerald-500">
<Check className="size-3.5" />
Saved
</span>
)}
</div>
<Separator />
</>
);
}
export function PreferencesPanelReceipt({
id,
title,
sections,
choice,
error,
className,
}: PreferencesPanelReceiptProps) {
const hasErrors = error && Object.keys(error).length > 0;
return (
<article
data-slot="preferences-panel"
data-tool-ui-id={id}
data-receipt="true"
role="status"
aria-label={
hasErrors ? "Preferences with errors" : "Confirmed preferences"
}
className={cn(
"@container/preferences-panel flex w-full max-w-md min-w-80 flex-col",
className,
)}
>
<div className="bg-card/60 flex w-full flex-col overflow-hidden rounded-2xl border opacity-95 shadow-xs">
{title && <ReceiptHeader title={title} hasErrors={!!hasErrors} />}
<div
className={cn("flex flex-col gap-4 px-5", title ? "py-6" : "py-2")}
>
{sections.map((section, index) => (
<div key={index}>
<PreferencesSection
section={section}
values={choice}
errors={error}
isReceipt={true}
hasTitle={!!title}
/>
</div>
))}
</div>
</div>
</article>
);
}
function PreferencesPanelRoot({
id,
title,
sections,
value: controlledValue,
onChange,
actions,
onAction,
onBeforeAction,
className,
}: PreferencesPanelProps) {
const initialValues = useMemo(
() => computeInitialValues(sections),
[sections],
);
const sectionsSignature = useMemo(
() => createPreferencesSectionSignature(sections),
[sections],
);
const {
value: currentValue,
isControlled,
setValue,
setUncontrolledValue,
} = useControllableState<PreferencesValue>({
value: controlledValue,
defaultValue: initialValues,
onChange,
});
useSignatureReset(sectionsSignature, () => {
if (!isControlled) {
setUncontrolledValue(initialValues);
}
});
const updateValue = useCallback(
(itemId: string, newValue: string | boolean) => {
setValue((prev) => ({ ...prev, [itemId]: newValue }));
},
[setValue],
);
const isDirty = useMemo(() => {
return Object.keys(currentValue).some(
(key) => currentValue[key] !== initialValues[key],
);
}, [currentValue, initialValues]);
const handleCancel = useCallback((): PreferencesValue => {
setValue(initialValues);
return initialValues;
}, [initialValues, setValue]);
const handleAction = useCallback(
async (actionId: string) => {
let nextValue = currentValue;
if (actionId === "cancel") {
nextValue = handleCancel();
}
await onAction?.(actionId, nextValue);
},
[currentValue, handleCancel, onAction],
);
const normalizedActions = useMemo(() => {
const normalized = normalizeActionsConfig(actions);
if (normalized) {
return {
...normalized,
align: normalized.align ?? ("right" as const),
};
}
const defaultActions: Action[] = [
{ id: "cancel", label: "Cancel", variant: "ghost" },
{ id: "save", label: "Save Changes", variant: "default" },
];
return {
items: defaultActions,
align: "right" as const,
};
}, [actions]);
const actionsWithState = useMemo((): Action[] => {
return normalizedActions.items.map((action) => {
const isSaveAction = action.id === "save";
const baseDisabled = "disabled" in action ? action.disabled : false;
const shouldDisable = baseDisabled || (isSaveAction && !isDirty);
return {
...action,
disabled: shouldDisable,
};
});
}, [normalizedActions.items, isDirty]);
return (
<article
data-slot="preferences-panel"
data-tool-ui-id={id}
role="form"
className={cn(
"text-foreground @container/preferences-panel flex w-full max-w-md min-w-80 flex-col gap-3",
className,
)}
>
<div className="bg-card flex w-full flex-col overflow-hidden rounded-2xl border shadow-xs">
{title && (
<>
<div className="px-5 py-4">
<h2 className="text-base leading-none font-semibold">{title}</h2>
</div>
<Separator />
</>
)}
<div
className={cn("flex flex-col gap-4 px-5", title ? "py-6" : "py-2")}
>
{sections.map((section, sectionIndex) => (
<div key={sectionIndex}>
<PreferencesSection
section={section}
values={currentValue}
onChangeValue={updateValue}
isReceipt={false}
hasTitle={!!title}
/>
</div>
))}
</div>
</div>
<div className="@container/actions">
<ActionButtons
actions={actionsWithState}
align={normalizedActions.align}
confirmTimeout={normalizedActions.confirmTimeout}
onAction={handleAction}
onBeforeAction={
onBeforeAction
? (actionId) => onBeforeAction(actionId, currentValue)
: undefined
}
/>
</div>
</article>
);
}
type PreferencesPanelComponent = typeof PreferencesPanelRoot & {
Receipt: typeof PreferencesPanelReceipt;
};
export const PreferencesPanel = Object.assign(PreferencesPanelRoot, {
Receipt: PreferencesPanelReceipt,
}) as PreferencesPanelComponent;
components/tool-ui/preferences-panel/README.md
# Preferences Panel
Implementation for the "preferences-panel" Tool UI surface.
## Files
- public exports: components/tool-ui/preferences-panel/index.tsx
- serializable schema + parse helpers: components/tool-ui/preferences-panel/schema.ts
## Companion assets
- Docs page: app/docs/preferences-panel/content.mdx
- Preset payload: lib/presets/preferences-panel.ts
## Quick check
Run this after edits:
pnpm test
components/tool-ui/preferences-panel/schema.ts
import { z } from "zod";
import { type ActionsProp } from "../shared/actions-config";
import type { EmbeddedActionsProps } from "../shared/embedded-actions";
import { defineToolUiContract } from "../shared/contract";
import {
SerializableActionSchema,
SerializableActionsConfigSchema,
ToolUIIdSchema,
ToolUIReceiptSchema,
ToolUIRoleSchema,
} from "../shared/schema";
const PreferenceItemBaseSchema = z.object({
id: z.string().min(1),
label: z.string().min(1),
description: z.string().optional(),
});
const PreferenceSwitchSchema = PreferenceItemBaseSchema.extend({
type: z.literal("switch"),
defaultChecked: z.boolean().optional(),
});
const PreferenceToggleSchema = PreferenceItemBaseSchema.extend({
type: z.literal("toggle"),
options: z
.array(
z.object({
value: z.string().min(1),
label: z.string().min(1),
}),
)
.min(2),
defaultValue: z.string().optional(),
});
const PreferenceSelectSchema = PreferenceItemBaseSchema.extend({
type: z.literal("select"),
selectOptions: z
.array(
z.object({
value: z.string().min(1),
label: z.string().min(1),
}),
)
.min(5),
defaultSelected: z.string().optional(),
});
const PreferenceItemSchema = z.discriminatedUnion("type", [
PreferenceSwitchSchema,
PreferenceToggleSchema,
PreferenceSelectSchema,
]);
const PreferenceSectionSchema = z.object({
heading: z.string().min(1).optional(),
items: z.array(PreferenceItemSchema).min(1),
});
const PreferencesPanelBaseSchema = z.object({
id: ToolUIIdSchema,
role: ToolUIRoleSchema.optional(),
receipt: ToolUIReceiptSchema.optional(),
title: z.string().min(1).optional(),
sections: z.array(PreferenceSectionSchema).min(1),
});
export const SerializablePreferencesPanelSchema =
PreferencesPanelBaseSchema.extend({
actions: z
.union([
z.array(SerializableActionSchema),
SerializableActionsConfigSchema,
])
.optional(),
}).strict();
export const SerializablePreferencesPanelReceiptSchema =
PreferencesPanelBaseSchema.extend({
choice: z.record(z.string(), z.union([z.string(), z.boolean()])),
error: z.record(z.string(), z.string()).optional(),
}).strict();
export type SerializablePreferencesPanel = z.infer<
typeof SerializablePreferencesPanelSchema
>;
export type SerializablePreferencesPanelReceipt = z.infer<
typeof SerializablePreferencesPanelReceiptSchema
>;
const SerializablePreferencesPanelSchemaContract = defineToolUiContract(
"PreferencesPanel",
SerializablePreferencesPanelSchema,
);
const SerializablePreferencesPanelReceiptSchemaContract = defineToolUiContract(
"PreferencesPanelReceipt",
SerializablePreferencesPanelReceiptSchema,
);
export const parseSerializablePreferencesPanel: (
input: unknown,
) => SerializablePreferencesPanel =
SerializablePreferencesPanelSchemaContract.parse;
export const safeParseSerializablePreferencesPanel: (
input: unknown,
) => SerializablePreferencesPanel | null =
SerializablePreferencesPanelSchemaContract.safeParse;
export const parseSerializablePreferencesPanelReceipt: (
input: unknown,
) => SerializablePreferencesPanelReceipt =
SerializablePreferencesPanelReceiptSchemaContract.parse;
export const safeParseSerializablePreferencesPanelReceipt: (
input: unknown,
) => SerializablePreferencesPanelReceipt | null =
SerializablePreferencesPanelReceiptSchemaContract.safeParse;
export interface PreferencesValue {
[itemId: string]: string | boolean;
}
export interface PreferencesPanelProps extends Omit<
SerializablePreferencesPanel,
"actions"
> {
className?: string;
value?: PreferencesValue;
onChange?: (value: PreferencesValue) => void;
actions?: ActionsProp;
onAction?: EmbeddedActionsProps<PreferencesValue>["onAction"];
onBeforeAction?: EmbeddedActionsProps<PreferencesValue>["onBeforeAction"];
}
export interface PreferencesPanelReceiptProps extends SerializablePreferencesPanelReceipt {
className?: string;
}
export type PreferenceItem = z.infer<typeof PreferenceItemSchema>;
export type PreferenceSection = z.infer<typeof PreferenceSectionSchema>;
components/tool-ui/preferences-panel/signature.ts
import type { PreferenceSection } from "./schema";
export function createPreferencesSectionSignature(
sections: PreferenceSection[],
): string {
return JSON.stringify(
sections.map((section) => ({
heading: section.heading ?? "",
items: section.items.map((item) => {
if (item.type === "switch") {
return {
id: item.id,
type: item.type,
defaultChecked: item.defaultChecked ?? false,
};
}
if (item.type === "toggle") {
return {
id: item.id,
type: item.type,
defaultValue: item.defaultValue ?? item.options[0]?.value ?? "",
options: item.options.map((option) => option.value),
};
}
return {
id: item.id,
type: item.type,
defaultSelected:
item.defaultSelected ?? item.selectOptions[0]?.value ?? "",
options: item.selectOptions.map((option) => option.value),
};
}),
})),
);
}
components/tool-ui/shared/_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";
components/tool-ui/shared/action-buttons.tsx
"use client";
import type { Action } from "./schema";
import { cn, Button } from "./_adapter";
import { useActionButtons } from "./use-action-buttons";
export interface ActionButtonsProps {
actions: Action[];
onAction: (actionId: string) => void | Promise<void>;
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
confirmTimeout?: number;
align?: "left" | "center" | "right";
className?: string;
}
export function ActionButtons({
actions,
onAction,
onBeforeAction,
confirmTimeout = 3000,
align = "right",
className,
}: ActionButtonsProps) {
const { actions: resolvedActions, runAction } = useActionButtons({
actions,
onAction,
onBeforeAction,
confirmTimeout,
});
return (
<div
className={cn(
"flex flex-col gap-3",
"@sm/actions:flex-row @sm/actions:flex-wrap @sm/actions:items-center @sm/actions:gap-2",
align === "left" && "@sm/actions:justify-start",
align === "center" && "@sm/actions:justify-center",
align === "right" && "@sm/actions:justify-end",
className,
)}
>
{resolvedActions.map((action) => {
const label = action.currentLabel;
const variant = action.variant || "default";
return (
<Button
key={action.id}
variant={variant}
onClick={() => runAction(action.id)}
disabled={action.isDisabled}
className={cn(
"rounded-full px-4!",
"justify-center",
"min-h-11 w-full text-base",
"@sm/actions:min-h-0 @sm/actions:w-auto @sm/actions:px-3 @sm/actions:py-2 @sm/actions:text-sm",
action.isConfirming &&
"ring-destructive ring-2 ring-offset-2 motion-safe:animate-pulse",
)}
aria-label={
action.shortcut ? `${label} (${action.shortcut})` : label
}
>
{action.isLoading && (
<svg
className="mr-2 h-4 w-4 motion-safe:animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{action.icon && !action.isLoading && (
<span className="mr-2">{action.icon}</span>
)}
{label}
{action.shortcut && !action.isLoading && (
<kbd className="border-border bg-muted ml-2.5 hidden rounded-lg border px-2 py-0.5 font-mono text-xs font-medium sm:inline-block">
{action.shortcut}
</kbd>
)}
</Button>
);
})}
</div>
);
}
components/tool-ui/shared/actions-config.ts
import type { Action, ActionsConfig } from "./schema";
export type ActionsProp = ActionsConfig | Action[];
const NEGATORY_ACTION_IDS = new Set([
"cancel",
"dismiss",
"skip",
"no",
"reset",
"close",
"decline",
"reject",
"back",
"later",
"not-now",
"maybe-later",
]);
function inferVariant(action: Action): Action {
if (action.variant) return action;
if (NEGATORY_ACTION_IDS.has(action.id)) {
return { ...action, variant: "ghost" };
}
return action;
}
export function normalizeActionsConfig(
actions?: ActionsProp,
): ActionsConfig | null {
if (!actions) return null;
const rawItems = Array.isArray(actions) ? actions : (actions.items ?? []);
if (rawItems.length === 0) {
return null;
}
const items = rawItems.map(inferVariant);
return Array.isArray(actions)
? { items }
: {
items,
align: actions.align,
confirmTimeout: actions.confirmTimeout,
};
}
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/embedded-actions.ts
import type { ActionsProp } from "./actions-config";
export type EmbeddedActionHandler<TState> = (
actionId: string,
state: TState,
) => void | Promise<void>;
export type EmbeddedBeforeActionHandler<TState> = (
actionId: string,
state: TState,
) => boolean | Promise<boolean>;
export interface EmbeddedActionsProps<TState> {
actions?: ActionsProp;
onAction?: EmbeddedActionHandler<TState>;
onBeforeAction?: EmbeddedBeforeActionHandler<TState>;
}
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>;
components/tool-ui/shared/use-action-buttons.tsx
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Action } from "./schema";
export type UseActionButtonsOptions = {
actions: Action[];
onAction: (actionId: string) => void | Promise<void>;
onBeforeAction?: (actionId: string) => boolean | Promise<boolean>;
confirmTimeout?: number;
};
export type UseActionButtonsResult = {
actions: Array<
Action & {
currentLabel: string;
isConfirming: boolean;
isExecuting: boolean;
isDisabled: boolean;
isLoading: boolean;
}
>;
runAction: (actionId: string) => Promise<void>;
confirmingActionId: string | null;
executingActionId: string | null;
};
type ActionExecutionLock = {
tryAcquire: () => boolean;
release: () => void;
};
export function createActionExecutionLock(): ActionExecutionLock {
let locked = false;
return {
tryAcquire: () => {
if (locked) return false;
locked = true;
return true;
},
release: () => {
locked = false;
},
};
}
export function useActionButtons(
options: UseActionButtonsOptions,
): UseActionButtonsResult {
const { actions, onAction, onBeforeAction, confirmTimeout = 3000 } = options;
const [confirmingActionId, setConfirmingActionId] = useState<string | null>(
null,
);
const [executingActionId, setExecutingActionId] = useState<string | null>(
null,
);
const executionLockRef = useRef<ActionExecutionLock>(
createActionExecutionLock(),
);
useEffect(() => {
if (!confirmingActionId) return;
const id = setTimeout(() => setConfirmingActionId(null), confirmTimeout);
return () => clearTimeout(id);
}, [confirmingActionId, confirmTimeout]);
useEffect(() => {
if (!confirmingActionId) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setConfirmingActionId(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [confirmingActionId]);
const runAction = useCallback(
async (actionId: string) => {
const action = actions.find((a) => a.id === actionId);
if (!action) return;
const isAnyActionExecuting = executingActionId !== null;
if (action.disabled || action.loading || isAnyActionExecuting) {
return;
}
if (action.confirmLabel && confirmingActionId !== action.id) {
setConfirmingActionId(action.id);
return;
}
if (!executionLockRef.current.tryAcquire()) {
return;
}
if (onBeforeAction) {
const shouldProceed = await onBeforeAction(action.id);
if (!shouldProceed) {
setConfirmingActionId(null);
executionLockRef.current.release();
return;
}
}
try {
setExecutingActionId(action.id);
await onAction(action.id);
} finally {
executionLockRef.current.release();
setExecutingActionId(null);
setConfirmingActionId(null);
}
},
[actions, confirmingActionId, executingActionId, onAction, onBeforeAction],
);
const resolvedActions = useMemo(
() =>
actions.map((action) => {
const isConfirming = confirmingActionId === action.id;
const isThisActionExecuting = executingActionId === action.id;
const isLoading = action.loading || isThisActionExecuting;
const isDisabled =
action.disabled ||
(executingActionId !== null && !isThisActionExecuting);
const currentLabel =
isConfirming && action.confirmLabel
? action.confirmLabel
: action.label;
return {
...action,
currentLabel,
isConfirming,
isExecuting: isThisActionExecuting,
isDisabled,
isLoading,
};
}),
[actions, confirmingActionId, executingActionId],
);
return {
actions: resolvedActions,
runAction,
confirmingActionId,
executingActionId,
};
}
components/tool-ui/shared/use-controllable-state.ts
"use client";
import { useCallback, useMemo, useRef, useState } from "react";
export type UseControllableStateOptions<T> = {
value?: T;
defaultValue: T;
onChange?: (next: T) => void;
};
export function useControllableState<T>({
value,
defaultValue,
onChange,
}: UseControllableStateOptions<T>) {
const [uncontrolled, setUncontrolled] = useState<T>(defaultValue);
const isControlled = value !== undefined;
const currentValue = useMemo(
() => (isControlled ? (value as T) : uncontrolled),
[isControlled, value, uncontrolled],
);
const currentValueRef = useRef(currentValue);
currentValueRef.current = currentValue;
const setValue = useCallback(
(next: T | ((prev: T) => T)) => {
const resolved =
typeof next === "function"
? (next as (prev: T) => T)(currentValueRef.current)
: next;
currentValueRef.current = resolved;
if (!isControlled) {
setUncontrolled(resolved);
}
onChange?.(resolved);
return resolved;
},
[isControlled, onChange],
);
const setUncontrolledValue = useCallback((next: T) => {
setUncontrolled(next);
}, []);
return {
value: currentValue,
isControlled,
setValue,
setUncontrolledValue,
};
}
components/tool-ui/shared/use-signature-reset.ts
"use client";
import { useEffect, useRef } from "react";
export function useSignatureReset(
signature: string,
onSignatureChange: () => void,
) {
const previousSignature = useRef(signature);
useEffect(() => {
if (previousSignature.current === signature) return;
previousSignature.current = signature;
onSignatureChange();
}, [signature, onSignatureChange]);
}