diff --git a/app/components/home/chat-showcase.tsx b/app/components/home/chat-showcase.tsx
index 3bf9ed1..6ae596e 100644
--- a/app/components/home/chat-showcase.tsx
+++ b/app/components/home/chat-showcase.tsx
@@ -20,10 +20,6 @@ import {
SocialPost,
type SerializableSocialPost,
} from "@/components/tool-ui/social-post";
-import {
- DecisionPrompt,
- type DecisionPromptAction,
-} from "@/components/tool-ui/decision-prompt";
type BubbleProps = {
role: "user" | "assistant";
@@ -297,10 +293,10 @@ const SOCIAL_POST: SerializableSocialPost = {
language: "en-US",
};
-const DECISION_ACTIONS: DecisionPromptAction[] = [
- { id: "cancel", label: "Discard", variant: "ghost" },
- { id: "edit", label: "Revise", variant: "outline" },
- { id: "send", label: "Post Now", variant: "default" },
+const SOCIAL_POST_ACTIONS = [
+ { id: "cancel", label: "Discard", variant: "ghost" as const },
+ { id: "edit", label: "Revise", variant: "outline" as const },
+ { id: "send", label: "Post Now", variant: "default" as const },
];
function createSceneConfigs(): SceneConfig[] {
@@ -327,18 +323,17 @@ function createSceneConfigs(): SceneConfig[] {
toolUI: ,
toolFallbackHeight: 260,
},
- // Scene 3: Open Source Release / SocialPost + DecisionPrompt
+ // Scene 3: Open Source Release / SocialPost with footerActions
{
userMessage: "Draft a tweet about our open-source release",
preamble: "Here's a draft announcement:",
toolUI: (
-
-
-
+
),
diff --git a/app/docs/[component]/page.tsx b/app/docs/[component]/page.tsx
index cb4a3ee..bef04c5 100644
--- a/app/docs/[component]/page.tsx
+++ b/app/docs/[component]/page.tsx
@@ -3,7 +3,7 @@ import { getComponentById } from "@/lib/docs/component-registry";
import { DataTablePreview } from "./previews/data-table-preview";
import { SocialPostPreview } from "./previews/social-post-preview";
import { MediaCardPreview } from "./previews/media-card-preview";
-import { DecisionPromptPreview } from "./previews/decision-prompt-preview";
+import { OptionListPreview } from "./previews/option-list-preview";
export default async function ComponentPage({
params,
@@ -24,8 +24,8 @@ export default async function ComponentPage({
return ;
case "media-card":
return ;
- case "decision-prompt":
- return ;
+ case "option-list":
+ return ;
default:
notFound();
}
diff --git a/app/docs/[component]/previews/data-table-preview.tsx b/app/docs/[component]/previews/data-table-preview.tsx
index dbe6e87..e1aed4b 100644
--- a/app/docs/[component]/previews/data-table-preview.tsx
+++ b/app/docs/[component]/previews/data-table-preview.tsx
@@ -130,12 +130,6 @@ export function DataTablePreview({
className="h-full w-full"
componentId="data-table"
config={currentConfig}
- socialPostConfig={undefined}
- mediaCardConfig={undefined}
- decisionPromptConfig={undefined}
- decisionPromptSelectedAction={undefined}
- decisionPromptSelectedActions={[]}
- mediaCardMaxWidth={undefined}
sort={sort}
isLoading={loading}
emptyMessage={emptyMessage}
diff --git a/app/docs/[component]/previews/decision-prompt-preview.tsx b/app/docs/[component]/previews/decision-prompt-preview.tsx
deleted file mode 100644
index db2a23a..0000000
--- a/app/docs/[component]/previews/decision-prompt-preview.tsx
+++ /dev/null
@@ -1,123 +0,0 @@
-"use client";
-
-import { useCallback, useState, useEffect } from "react";
-import { useSearchParams, usePathname, useRouter } from "next/navigation";
-import { ComponentPreviewShell } from "../component-preview-shell";
-import { PresetSelector } from "../../_components/preset-selector";
-import { CodePanel } from "../../_components/code-panel";
-import { DecisionPrompt } from "@/components/tool-ui/decision-prompt";
-import {
- DecisionPromptPresetName,
- decisionPromptPresets,
-} from "@/lib/presets/decision-prompt";
-
-export function DecisionPromptPreview({
- withContainer = true,
-}: {
- withContainer?: boolean;
-}) {
- const router = useRouter();
- const pathname = usePathname();
- const searchParams = useSearchParams();
-
- const presetParam = searchParams.get("preset");
- const defaultPreset = "multi-choice";
- const initialPreset: DecisionPromptPresetName =
- presetParam && presetParam in decisionPromptPresets
- ? (presetParam as DecisionPromptPresetName)
- : defaultPreset;
-
- const [currentPreset, setCurrentPreset] =
- useState(initialPreset);
- const [isLoading, setIsLoading] = useState(false);
- const [selectedAction, setSelectedAction] = useState();
- const [selectedActions, setSelectedActions] = useState([]);
-
- useEffect(() => {
- const presetParam = searchParams.get("preset");
- if (
- presetParam &&
- presetParam in decisionPromptPresets &&
- presetParam !== currentPreset
- ) {
- setCurrentPreset(presetParam as DecisionPromptPresetName);
- setSelectedAction(undefined);
- setSelectedActions([]);
- setIsLoading(false);
- }
- }, [searchParams, currentPreset]);
-
- const currentConfig = decisionPromptPresets[currentPreset];
-
- const handleSelectPreset = useCallback(
- (preset: unknown) => {
- const presetName = preset as DecisionPromptPresetName;
- setCurrentPreset(presetName);
- setSelectedAction(undefined);
- setSelectedActions([]);
- setIsLoading(false);
-
- const params = new URLSearchParams(searchParams.toString());
- params.set("preset", presetName);
- router.push(`${pathname}?${params.toString()}`, { scroll: false });
- },
- [router, pathname, searchParams],
- );
-
- return (
-
- }
- renderPreview={(_loading) => (
-
- {
- console.log("Decision prompt action:", actionId);
-
- if (actionId === "install" || actionId === "send") {
- await new Promise((resolve) => setTimeout(resolve, 1500));
- }
-
- setSelectedAction(actionId);
- }}
- onMultiAction={async (actionIds) => {
- console.log("Decision prompt multi-action:", actionIds);
-
- await new Promise((resolve) => setTimeout(resolve, 1500));
-
- setSelectedActions(actionIds);
- }}
- />
-
- )}
- renderCodePanel={(loading) => (
-
- )}
- />
- );
-}
diff --git a/app/docs/[component]/previews/media-card-preview.tsx b/app/docs/[component]/previews/media-card-preview.tsx
index 13f285b..d95b976 100644
--- a/app/docs/[component]/previews/media-card-preview.tsx
+++ b/app/docs/[component]/previews/media-card-preview.tsx
@@ -88,16 +88,9 @@ export function MediaCardPreview({
)}
diff --git a/app/docs/[component]/previews/option-list-preview.tsx b/app/docs/[component]/previews/option-list-preview.tsx
new file mode 100644
index 0000000..f3e5d2c
--- /dev/null
+++ b/app/docs/[component]/previews/option-list-preview.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { useCallback, useState, useEffect } from "react";
+import { useSearchParams, usePathname, useRouter } from "next/navigation";
+import { ComponentPreviewShell } from "../component-preview-shell";
+import { PresetSelector } from "../../_components/preset-selector";
+import { CodePanel } from "../../_components/code-panel";
+import { OptionList } from "@/components/tool-ui/option-list";
+import {
+ OptionListPresetName,
+ optionListPresets,
+} from "@/lib/presets/option-list";
+
+export function OptionListPreview({
+ withContainer = true,
+}: {
+ withContainer?: boolean;
+}) {
+ const router = useRouter();
+ const pathname = usePathname();
+ const searchParams = useSearchParams();
+
+ const presetParam = searchParams.get("preset");
+ const defaultPreset = "export";
+ const initialPreset: OptionListPresetName =
+ presetParam && presetParam in optionListPresets
+ ? (presetParam as OptionListPresetName)
+ : defaultPreset;
+
+ const [currentPreset, setCurrentPreset] =
+ useState(initialPreset);
+ const [isLoading, setIsLoading] = useState(false);
+ const [selection, setSelection] = useState(null);
+
+ useEffect(() => {
+ const presetParam = searchParams.get("preset");
+ if (
+ presetParam &&
+ presetParam in optionListPresets &&
+ presetParam !== currentPreset
+ ) {
+ setCurrentPreset(presetParam as OptionListPresetName);
+ setIsLoading(false);
+ setSelection(null);
+ }
+ }, [searchParams, currentPreset]);
+
+ const currentConfig = optionListPresets[currentPreset];
+
+ const handleSelectPreset = useCallback(
+ (preset: unknown) => {
+ const presetName = preset as OptionListPresetName;
+ setCurrentPreset(presetName);
+ setIsLoading(false);
+ setSelection(null);
+
+ const params = new URLSearchParams(searchParams.toString());
+ params.set("preset", presetName);
+ router.push(`${pathname}?${params.toString()}`, { scroll: false });
+ },
+ [router, pathname, searchParams],
+ );
+
+ return (
+
+ }
+ renderPreview={() => (
+
+ {
+ console.log("OptionList confirmed:", sel);
+ alert(`Selection confirmed: ${JSON.stringify(sel)}`);
+ }}
+ />
+
+ )}
+ renderCodePanel={() => (
+
+ )}
+ />
+ );
+}
diff --git a/app/docs/[component]/previews/social-post-preview.tsx b/app/docs/[component]/previews/social-post-preview.tsx
index cfa5582..6c8d0f1 100644
--- a/app/docs/[component]/previews/social-post-preview.tsx
+++ b/app/docs/[component]/previews/social-post-preview.tsx
@@ -86,16 +86,8 @@ export function SocialPostPreview({
)}
diff --git a/app/docs/_components/code-panel.tsx b/app/docs/_components/code-panel.tsx
index a57044a..6d437c2 100644
--- a/app/docs/_components/code-panel.tsx
+++ b/app/docs/_components/code-panel.tsx
@@ -3,7 +3,7 @@
import { DataTableConfig } from "@/lib/presets/data-table";
import { SocialPostConfig } from "@/lib/presets/social-post";
import { MediaCardConfig } from "@/lib/presets/media-card";
-import { DecisionPromptConfig } from "@/lib/presets/decision-prompt";
+import { OptionListConfig } from "@/lib/presets/option-list";
import { DynamicCodeBlock } from "fumadocs-ui/components/dynamic-codeblock";
interface CodePanelProps {
@@ -11,9 +11,8 @@ interface CodePanelProps {
config?: DataTableConfig;
socialPostConfig?: SocialPostConfig;
mediaCardConfig?: MediaCardConfig;
- decisionPromptConfig?: DecisionPromptConfig;
- decisionPromptSelectedAction?: string;
- decisionPromptSelectedActions?: string[];
+ optionListConfig?: OptionListConfig;
+ optionListSelection?: string[] | string | null;
mediaCardMaxWidth?: string;
sort?: { by?: string; direction?: "asc" | "desc" };
isLoading?: boolean;
@@ -27,9 +26,8 @@ export function CodePanel({
config,
socialPostConfig,
mediaCardConfig,
- decisionPromptConfig,
- decisionPromptSelectedAction,
- decisionPromptSelectedActions,
+ optionListConfig,
+ optionListSelection,
mediaCardMaxWidth,
sort,
isLoading,
@@ -257,85 +255,42 @@ export function CodePanel({
return ``;
};
- const generateDecisionPromptCode = () => {
- if (!decisionPromptConfig) return "";
- const prompt = decisionPromptConfig.prompt;
+ const generateOptionListCode = () => {
+ if (!optionListConfig) return "";
+ const list = optionListConfig.optionList;
const props: string[] = [];
- props.push(` prompt="${prompt.prompt}"`);
-
- if (prompt.description) {
- props.push(` description="${prompt.description}"`);
- }
-
props.push(
- ` actions={${JSON.stringify(prompt.actions, null, 4).replace(/\n/g, "\n ")}}`,
+ ` options={${JSON.stringify(list.options, null, 4).replace(/\n/g, "\n ")}}`,
);
- // Multi-select mode
- if (prompt.multiSelect) {
- if (
- decisionPromptSelectedActions &&
- decisionPromptSelectedActions.length > 0
- ) {
- props.push(
- ` selectedActions={${JSON.stringify(decisionPromptSelectedActions)}}`,
- );
- }
-
- if (prompt.align && prompt.align !== "right") {
- props.push(` align="${prompt.align}"`);
- }
-
- if (prompt.layout && prompt.layout !== "inline") {
- props.push(` layout="${prompt.layout}"`);
- }
-
- props.push(` multiSelect={true}`);
-
- if (prompt.minSelections && prompt.minSelections !== 1) {
- props.push(` minSelections={${prompt.minSelections}}`);
- }
-
- if (prompt.maxSelections) {
- props.push(` maxSelections={${prompt.maxSelections}}`);
- }
-
- if (prompt.confirmLabel && prompt.confirmLabel !== "Confirm") {
- props.push(` confirmLabel="${prompt.confirmLabel}"`);
- }
-
- if (prompt.cancelLabel && prompt.cancelLabel !== "Cancel") {
- props.push(` cancelLabel="${prompt.cancelLabel}"`);
- }
-
- props.push(
- ` onMultiAction={(actionIds) => {\n console.log("Selected actions:", actionIds);\n // Handle multi-action here\n }}`,
- );
- } else {
- // Single-select mode
- if (decisionPromptSelectedAction) {
- props.push(` selectedAction="${decisionPromptSelectedAction}"`);
- }
+ if (list.selectionMode && list.selectionMode !== "multi") {
+ props.push(` selectionMode="${list.selectionMode}"`);
+ }
- if (prompt.align && prompt.align !== "right") {
- props.push(` align="${prompt.align}"`);
- }
+ if (optionListSelection) {
+ props.push(` value={${JSON.stringify(optionListSelection)}}`);
+ }
- if (prompt.layout && prompt.layout !== "inline") {
- props.push(` layout="${prompt.layout}"`);
- }
+ if (list.minSelections && list.minSelections !== 1) {
+ props.push(` minSelections={${list.minSelections}}`);
+ }
- if (prompt.confirmTimeout && prompt.confirmTimeout !== 3000) {
- props.push(` confirmTimeout={${prompt.confirmTimeout}}`);
- }
+ if (list.maxSelections) {
+ props.push(` maxSelections={${list.maxSelections}}`);
+ }
+ if (list.footerActions) {
props.push(
- ` onAction={(actionId) => {\n console.log("Action:", actionId);\n // Handle action here\n }}`,
+ ` footerActions={${JSON.stringify(list.footerActions, null, 4).replace(/\n/g, "\n ")}}`,
);
}
- return ``;
+ props.push(
+ ` onConfirm={(selection) => {\n console.log("Selection:", selection);\n }}`,
+ );
+
+ return ``;
};
const generateCode = () => {
@@ -345,8 +300,8 @@ export function CodePanel({
return generateSocialPostCode();
} else if (componentId === "media-card") {
return generateMediaCardCode();
- } else if (componentId === "decision-prompt") {
- return generateDecisionPromptCode();
+ } else if (componentId === "option-list") {
+ return generateOptionListCode();
}
return "";
};
diff --git a/app/docs/_components/preset-selector.tsx b/app/docs/_components/preset-selector.tsx
index 59ff1d6..1d2fbf0 100644
--- a/app/docs/_components/preset-selector.tsx
+++ b/app/docs/_components/preset-selector.tsx
@@ -18,16 +18,16 @@ import {
mediaCardPresetDescriptions,
} from "@/lib/presets/media-card";
import {
- DecisionPromptPresetName,
- decisionPromptPresetDescriptions,
-} from "@/lib/presets/decision-prompt";
+ OptionListPresetName,
+ optionListPresetDescriptions,
+} from "@/lib/presets/option-list";
import { cn } from "@/lib/ui/cn";
type ComponentPreset =
| PresetName
| SocialPostPresetName
| MediaCardPresetName
- | DecisionPromptPresetName;
+ | OptionListPresetName;
interface PresetSelectorProps {
componentId: string;
@@ -57,12 +57,10 @@ const mediaCardPresetNames: MediaCardPresetName[] = [
"audio",
];
-const decisionPromptPresetNames: DecisionPromptPresetName[] = [
- "multi-choice",
- "binary",
- "destructive",
- "async",
- "workflow",
+const optionListPresetNames: OptionListPresetName[] = [
+ "export",
+ "travel",
+ "notifications",
];
export function PresetSelector({
@@ -77,7 +75,7 @@ export function PresetSelector({
? socialPostPresetNames
: componentId === "media-card"
? mediaCardPresetNames
- : decisionPromptPresetNames;
+ : optionListPresetNames;
const descriptions =
componentId === "data-table"
@@ -86,7 +84,7 @@ export function PresetSelector({
? socialPostPresetDescriptions
: componentId === "media-card"
? mediaCardPresetDescriptions
- : decisionPromptPresetDescriptions;
+ : optionListPresetDescriptions;
return (
diff --git a/app/docs/contextual-actions/content.mdx b/app/docs/contextual-actions/content.mdx
new file mode 100644
index 0000000..97851b5
--- /dev/null
+++ b/app/docs/contextual-actions/content.mdx
@@ -0,0 +1,120 @@
+import { DocsHeader } from "../_components/docs-header";
+
+
+
+Lightweight, in-flow CTA rows attached to tool UIs—meant for time-bound, conversational decisions tied to the content above.
+
+## Shape
+
+`ActionsProp` accepts either an array of actions or an object with inline layout config:
+
+```ts
+// Array form
+[
+ { id: "approve", label: "Approve", variant: "default" },
+ { id: "reject", label: "Reject", variant: "destructive", confirmLabel: "Confirm" },
+]
+
+// Config form
+{
+ items: [
+ { id: "export", label: "Export CSV", variant: "secondary" },
+ { id: "sync", label: "Sync", variant: "default", confirmLabel: "Confirm" },
+ ],
+ align: "right", // default "right"
+ confirmTimeout: 3000 // default 3000ms
+}
+```
+
+### Action fields
+
+- `id` (required): unique identifier.
+- `label` (required): button text.
+- `variant`: `"default" | "secondary" | "ghost" | "destructive" | "outline"`.
+- `confirmLabel`: triggers a two-step confirm pattern (label shown on second click).
+- `disabled`, `loading`: disable or show spinner.
+- `shortcut`: optional keyboard hint.
+- `icon`: ReactNode (runtime only; omitted in serializable shape).
+
+## Handlers
+
+- `onAction(id)`: called after confirm/preflight.
+- `onBeforeAction(id) => boolean | Promise`: return `false` to cancel (use for confirmations or auth gates).
+- Confirmations: provided by `confirmLabel` + optional `confirmTimeout` (defaults to 3000ms).
+
+## Serialization
+
+Use `SerializableAction`/`SerializableActionsConfig` when actions come from tool/LLM output. Runtime code can provide React icons and handlers.
+
+- **Tool output:** stick to the serializable shapes so payloads survive `JSON.stringify` and hydrate cleanly.
+- **Runtime overrides:** when you render the component, you can add icons or custom handlers on top of the serializable payload without mutating the tool contract.
+
+## Examples
+
+### Footer actions on DataTable
+
+```tsx
+ console.log(id)}
+/>
+```
+
+### MediaCard footer
+
+```tsx
+ console.log(id)}
+/>
+```
+
+### SocialPost footer CTA
+
+```tsx
+ console.log(id)}
+/>
+```
+
+### Standalone ActionButtons
+
+```tsx
+import { ActionButtons } from "@/components/tool-ui/shared";
+
+ console.log(id)}
+/>
+```
+
+## Design guidance
+
+- Default to 2–3 CTAs tied directly to the content above; avoid toolbar sprawl.
+- Use `confirmLabel` for destructive/high-impact actions; keep `confirmTimeout` short.
+- ActionButtons are responsive by default using container queries—they stack vertically on narrow containers and display inline on wider ones.
+- For richer controls, prefer dedicated components (OptionList, filters) and keep this strip focused on decisions.
diff --git a/app/docs/contextual-actions/page.tsx b/app/docs/contextual-actions/page.tsx
new file mode 100644
index 0000000..7f19eef
--- /dev/null
+++ b/app/docs/contextual-actions/page.tsx
@@ -0,0 +1,10 @@
+import Content from "./content.mdx";
+import { DocsArticle } from "../_components/docs-article";
+
+export default function ContextualActionsPage() {
+ return (
+
+
+
+ );
+}
diff --git a/app/docs/decision-prompt/content.mdx b/app/docs/decision-prompt/content.mdx
deleted file mode 100644
index da79fad..0000000
--- a/app/docs/decision-prompt/content.mdx
+++ /dev/null
@@ -1,153 +0,0 @@
-import { DocsHeader } from "../_components/docs-header";
-import { DecisionPrompt } from "@/components/tool-ui/decision-prompt";
-
-
-
-A decision surface for binary and multi-option confirmations inside the chat stream.
-
-
-
-
-
-
-
-
- ```tsx
- import { DecisionPrompt } from "@/components/tool-ui/decision-prompt";
-
- export function Example() {
- return (
-
- );
- }
- ```
-
-
-
-
-
-## When to Use Decision Prompt
-
-- **Good for:** Send/discard decisions, approval flows, choosing between clear options
-- **Not for:** Complex forms, low-risk toggles
-
-## Key Features
-
-- **Human-in-the-loop**: Explicit user confirmation before actions
-- **Multi-option support**: Binary or multiple choice prompts
-- **Destructive safeguards**: Extra confirmation for dangerous actions
-- **Loading states**: Show progress during async operations
-- **Receipt pattern**: Collapse to summary after decision
-
-
-## Source and Install
-
-Copy `components/decision-prompt` into your project. It should sit alongside your shadcn/ui components directory:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-### Download
-
-- [**Source on GitHub**](https://github.com/assistant-ui/tool-ui/tree/main/components/decision-prompt)
-- [**Quick download (ZIP)**](https://download-directory.github.io/?url=https://github.com/assistant-ui/tool-ui/tree/main/components/decision-prompt)
-
-
-
-## Usage
-
-```tsx
-
-import { tool } from 'ai';
-import { serializableDecisionPromptSchema } from '@tool-ui/decision-prompt';
-
-const confirmEmailSend = tool({
- description: 'Ask the user whether to send an email',
- outputSchema: serializableDecisionPromptSchema,
- async execute({ draftId }) {
- return {
- id: `confirm-email-${draftId}`,
- title: 'Send this email?',
- description: 'The assistant drafted this email based on your last message.',
- actions: [
- { id: 'send', label: 'Send', tone: 'primary' },
- { id: 'edit', label: 'Edit first', tone: 'neutral' },
- { id: 'discard', label: 'Discard', tone: 'destructive' },
- ],
- };
- },
-});
-
-// Frontend with assistant-ui
-import { makeAssistantToolUI } from '@assistant-ui/react';
-import { DecisionPrompt } from '@tool-ui/decision-prompt';
-
-export const ConfirmEmailUI = makeAssistantToolUI({
- toolName: 'confirmEmailSend',
- render: ({ result }) => (
- {
- if (id === 'send') await sendDraft(result.id);
- if (id === 'edit') openDraftEditor(result.id);
- // Handle other actions
- }}
- />
- ),
-});
-```
-
-
-
-## Props
-
-",
- required: true,
- },
- defaultActionId: { description: "Primary action", type: "string" },
- onBeforeAction: { description: "Gate actions", type: "({ id }) => boolean | Promise" },
- onAction: { description: "Handle choice", type: "(id) => void | Promise" },
- }}
-/>
-
-Follows standard lifecycle: Pending → Confirming → Executing → Receipt.
diff --git a/app/docs/gallery/page.tsx b/app/docs/gallery/page.tsx
index 7a2917b..b258be1 100644
--- a/app/docs/gallery/page.tsx
+++ b/app/docs/gallery/page.tsx
@@ -3,7 +3,7 @@ import { DocsBorderedShell } from "@/app/docs/_components/docs-bordered-shell";
import { DataTable } from "@/components/tool-ui/data-table";
import { MediaCard } from "@/components/tool-ui/media-card";
import { SocialPost } from "@/components/tool-ui/social-post";
-import { DecisionPrompt } from "@/components/tool-ui/decision-prompt";
+import { OptionList } from "@/components/tool-ui/option-list";
import { ZenField } from "@/app/components/visuals/zen-field";
import { sampleStocks, sampleMetrics } from "@/lib/presets/data-table";
import { mediaCardPresets } from "@/lib/presets/media-card";
@@ -12,7 +12,7 @@ import {
sampleInstagram,
sampleLinkedIn,
} from "@/lib/presets/social-post";
-import { decisionPromptPresets } from "@/lib/presets/decision-prompt";
+import { optionListPresets } from "@/lib/presets/option-list";
import { ArrowRightIcon } from "lucide-react";
export default function ComponentsGalleryPage() {
@@ -56,11 +56,11 @@ export default function ComponentsGalleryPage() {
-
+
-
+
diff --git a/app/docs/option-list/content.mdx b/app/docs/option-list/content.mdx
new file mode 100644
index 0000000..00c171f
--- /dev/null
+++ b/app/docs/option-list/content.mdx
@@ -0,0 +1,155 @@
+import { DocsHeader } from "../_components/docs-header";
+import { OptionList } from "@/components/tool-ui/option-list";
+
+
+
+A decision surface for single or multi-select choices. Optimized for inline selection prompts in chat.
+
+
+
+
+
+
+
+
+ ```tsx
+ import { OptionList } from "@/components/tool-ui/option-list";
+
+ export function Example() {
+ return (
+ console.log(selection)}
+ />
+ );
+ }
+ ```
+
+
+
+## When to Use Option List
+
+- **Good for:** File format selection, preference choices, confirmation prompts
+- **Not for:** Long lists (use DataTable), complex forms, free-text input
+
+## Key Features
+
+- **Single or multi-select**: Radio-style or checkbox-style selection
+- **Selection constraints**: Min/max selection limits
+- **Configurable actions**: Customizable footer buttons
+- **Controlled or uncontrolled**: Works with or without external state
+
+## Source and Install
+
+Copy `components/option-list` into your project next to your shadcn/ui components:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+### Download
+
+- [**Source on GitHub**](https://github.com/assistant-ui/tool-ui/tree/main/components/option-list)
+- [**Quick download (ZIP)**](https://download-directory.github.io/?url=https://github.com/assistant-ui/tool-ui/tree/main/components/option-list)
+
+## Usage
+
+```tsx
+// Backend tool
+import { tool } from 'ai';
+import { z } from 'zod';
+import { serializableOptionListSchema } from '@tool-ui/option-list';
+
+const selectFormat = tool({
+ description: 'Let user select export format(s)',
+ inputSchema: z.object({ formats: z.array(z.string()) }),
+ outputSchema: serializableOptionListSchema,
+ async execute({ formats }) {
+ return {
+ options: formats.map(f => ({ id: f, label: f.toUpperCase() })),
+ selectionMode: 'multi',
+ maxSelections: 2,
+ };
+ },
+});
+
+// Frontend with assistant-ui
+import { makeAssistantToolUI } from '@assistant-ui/react';
+import { OptionList } from '@tool-ui/option-list';
+
+export const SelectFormatUI = makeAssistantToolUI({
+ toolName: 'selectFormat',
+ render: ({ result }) => (
+
{
+ console.log('Selected formats:', selection);
+ }}
+ />
+ ),
+});
+```
+
+## Props
+
+ void" },
+onConfirm: { description: "Confirm button handler", type: "(value) => void | Promise" },
+onCancel: { description: "Cancel/clear handler", type: "() => void" },
+}}
+/>
+
+## Option Schema
+
+
diff --git a/app/docs/decision-prompt/page.tsx b/app/docs/option-list/page.tsx
similarity index 51%
rename from app/docs/decision-prompt/page.tsx
rename to app/docs/option-list/page.tsx
index 4e63b38..39c8000 100644
--- a/app/docs/decision-prompt/page.tsx
+++ b/app/docs/option-list/page.tsx
@@ -1,12 +1,12 @@
import Content from "./content.mdx";
import { ComponentDocsExamples } from "../_components/component-docs-examples";
-import { DecisionPromptPreview } from "../[component]/previews/decision-prompt-preview";
+import { OptionListPreview } from "../[component]/previews/option-list-preview";
-export default function DecisionPromptDocsPage() {
+export default function OptionListDocsPage() {
return (
}
- examples={}
+ examples={}
defaultTab="docs"
/>
);
diff --git a/components/tool-ui/data-table/_cn.ts b/components/tool-ui/data-table/_cn.ts
index 88283f0..ed8d149 100644
--- a/components/tool-ui/data-table/_cn.ts
+++ b/components/tool-ui/data-table/_cn.ts
@@ -1,3 +1,10 @@
+/**
+ * Utility re-export for copy-standalone portability.
+ *
+ * This file centralizes the cn() utility so the component can be easily
+ * copied to another project by updating this to use the target project's
+ * utility path (e.g., "@/lib/utils").
+ */
import type { ClassValue } from "clsx";
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
diff --git a/components/tool-ui/data-table/_ui.tsx b/components/tool-ui/data-table/_ui.tsx
index 69e0919..11d93f4 100644
--- a/components/tool-ui/data-table/_ui.tsx
+++ b/components/tool-ui/data-table/_ui.tsx
@@ -1,3 +1,10 @@
+/**
+ * UI component re-exports for copy-standalone portability.
+ *
+ * This file centralizes UI dependencies so the component can be easily
+ * copied to another project by updating these imports to match the target
+ * project's component library paths.
+ */
"use client";
export { Button } from "../../ui/button";
diff --git a/components/tool-ui/data-table/data-table.tsx b/components/tool-ui/data-table/data-table.tsx
index adf10dd..5a1c9fe 100644
--- a/components/tool-ui/data-table/data-table.tsx
+++ b/components/tool-ui/data-table/data-table.tsx
@@ -11,6 +11,7 @@ import type {
DataTableRowData,
ColumnKey,
} from "./types";
+import { ActionButtons, normalizeActionsConfig } from "../shared";
/**
* Default locale for all Intl formatting operations.
@@ -60,6 +61,9 @@ export function DataTable({
onSortChange,
className,
locale,
+ footerActions,
+ onFooterAction,
+ onBeforeFooterAction,
}: DataTableProps) {
/**
* Resolved locale with explicit default.
@@ -149,6 +153,11 @@ export function DataTable({
: "";
}, [columns, sortBy, sortDirection]);
+ const normalizedFooterActions = React.useMemo(
+ () => normalizeActionsConfig(footerActions),
+ [footerActions],
+ );
+
return (
({
{sortAnnouncement}
)}
+
+ {normalizedFooterActions ? (
+
+
onFooterAction?.(id)}
+ onBeforeAction={onBeforeFooterAction}
+ />
+
+ ) : null}
);
diff --git a/components/tool-ui/data-table/types.ts b/components/tool-ui/data-table/types.ts
index 99dfabb..43c52fc 100644
--- a/components/tool-ui/data-table/types.ts
+++ b/components/tool-ui/data-table/types.ts
@@ -1,3 +1,4 @@
+import type { ActionsProp } from "../shared";
import type { FormatConfig } from "./formatters";
/**
@@ -246,6 +247,10 @@ export interface DataTableClientProps {
by?: ColumnKey;
direction?: "asc" | "desc";
}) => void;
+ /** Optional footer actions rendered below the table */
+ footerActions?: ActionsProp;
+ onFooterAction?: (actionId: string) => void | Promise;
+ onBeforeFooterAction?: (actionId: string) => boolean | Promise;
}
/**
diff --git a/components/tool-ui/decision-prompt/_cn.ts b/components/tool-ui/decision-prompt/_cn.ts
deleted file mode 100644
index c5b5602..0000000
--- a/components/tool-ui/decision-prompt/_cn.ts
+++ /dev/null
@@ -1 +0,0 @@
-export { cn } from "@/lib/ui/cn";
diff --git a/components/tool-ui/decision-prompt/_ui.tsx b/components/tool-ui/decision-prompt/_ui.tsx
deleted file mode 100644
index 9443e23..0000000
--- a/components/tool-ui/decision-prompt/_ui.tsx
+++ /dev/null
@@ -1,2 +0,0 @@
-export { Button } from "@/components/ui/button";
-export { Badge } from "@/components/ui/badge";
diff --git a/components/tool-ui/decision-prompt/actions.tsx b/components/tool-ui/decision-prompt/actions.tsx
deleted file mode 100644
index 8793715..0000000
--- a/components/tool-ui/decision-prompt/actions.tsx
+++ /dev/null
@@ -1,205 +0,0 @@
-"use client";
-
-import { useState, useEffect, useCallback, useRef } from "react";
-import type { DecisionPromptAction } from "./schema";
-import { Button } from "./_ui";
-import { cn } from "./_cn";
-
-interface DecisionPromptActionsProps {
- actions: DecisionPromptAction[];
- onAction: (actionId: string) => void | Promise;
- onBeforeAction?: (actionId: string) => boolean | Promise;
- confirmTimeout?: number;
- align?: "left" | "center" | "right";
- layout?: "inline" | "stack";
- className?: string;
-}
-
-export function DecisionPromptActions({
- actions,
- onAction,
- onBeforeAction,
- confirmTimeout = 3000,
- align = "right",
- layout = "inline",
- className,
-}: DecisionPromptActionsProps) {
- const [confirmingActionId, setConfirmingActionId] = useState(
- null,
- );
- const [executingActionId, setExecutingActionId] = useState(
- null,
- );
- const timeoutRef = useRef(undefined);
-
- // Clear timeout on unmount
- useEffect(() => {
- return () => {
- if (timeoutRef.current) {
- clearTimeout(timeoutRef.current);
- }
- };
- }, []);
-
- // Reset confirm state after timeout
- useEffect(() => {
- if (confirmingActionId) {
- timeoutRef.current = setTimeout(() => {
- setConfirmingActionId(null);
- }, confirmTimeout);
-
- return () => {
- if (timeoutRef.current) {
- clearTimeout(timeoutRef.current);
- }
- };
- }
- }, [confirmingActionId, confirmTimeout]);
-
- const handleActionClick = useCallback(
- async (action: DecisionPromptAction) => {
- // If action is disabled or loading, do nothing
- if (action.disabled || action.loading || executingActionId) {
- return;
- }
-
- // Two-stage pattern: if action has confirmLabel and not yet confirming
- if (action.confirmLabel && confirmingActionId !== action.id) {
- setConfirmingActionId(action.id);
- return;
- }
-
- // Check if action should proceed
- if (onBeforeAction) {
- const shouldProceed = await onBeforeAction(action.id);
- if (!shouldProceed) {
- setConfirmingActionId(null);
- return;
- }
- }
-
- // Execute the action
- try {
- setExecutingActionId(action.id);
- await onAction(action.id);
- } finally {
- setExecutingActionId(null);
- setConfirmingActionId(null);
- }
- },
- [confirmingActionId, executingActionId, onAction, onBeforeAction],
- );
-
- // Handle escape key to cancel confirmation
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- if (e.key === "Escape" && confirmingActionId) {
- setConfirmingActionId(null);
- }
- };
-
- window.addEventListener("keydown", handleKeyDown);
- return () => window.removeEventListener("keydown", handleKeyDown);
- }, [confirmingActionId]);
-
- const crossAlignClass = {
- left: "items-start",
- center: "items-center",
- right: "items-end",
- }[align];
- // Button content is always centered, regardless of alignment
-
- return (
-
- {actions.map((action) => {
- const isConfirming = confirmingActionId === action.id;
- const isExecuting = executingActionId === action.id;
- const isLoading = action.loading || isExecuting;
- const isDisabled =
- action.disabled || (executingActionId !== null && !isExecuting);
-
- // Determine label: use confirmLabel if in confirm state
- const label =
- isConfirming && action.confirmLabel
- ? action.confirmLabel
- : action.label;
-
- // Determine variant: make destructive actions more prominent in confirm state
- const variant = action.variant || "default";
-
- return (
-
- );
- })}
-
- );
-}
diff --git a/components/tool-ui/decision-prompt/decision-prompt.tsx b/components/tool-ui/decision-prompt/decision-prompt.tsx
deleted file mode 100644
index c39c089..0000000
--- a/components/tool-ui/decision-prompt/decision-prompt.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-"use client";
-
-import type { DecisionPromptProps } from "./schema";
-import { DecisionPromptActions } from "./actions";
-import { MultiSelectActions } from "./multi-select-actions";
-import { DecisionPromptReceipt } from "./receipt";
-import { cn } from "./_cn";
-
-export function DecisionPrompt({
- prompt: _prompt,
- actions,
- selectedAction,
- selectedActions,
- description: _description,
- onAction = () => {},
- onMultiAction = () => {},
- onBeforeAction,
- confirmTimeout = 3000,
- align = "right",
- layout = "inline",
- multiSelect = false,
- confirmLabel = "Confirm",
- cancelLabel = "Cancel",
- minSelections = 1,
- maxSelections,
- className,
-}: DecisionPromptProps) {
- const isCompleted = multiSelect
- ? selectedActions && selectedActions.length > 0
- : !!selectedAction;
-
- return (
-
- {isCompleted ? (
-
- ) : multiSelect ? (
-
- ) : (
-
- )}
-
- );
-}
diff --git a/components/tool-ui/decision-prompt/index.tsx b/components/tool-ui/decision-prompt/index.tsx
deleted file mode 100644
index cd566a5..0000000
--- a/components/tool-ui/decision-prompt/index.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-export { DecisionPrompt } from "./decision-prompt";
-export type {
- DecisionPromptAction,
- DecisionPromptProps,
- SerializableDecisionPrompt,
-} from "./schema";
-export {
- DecisionPromptActionSchema,
- DecisionPromptPropsSchema,
- SerializableDecisionPromptSchema,
-} from "./schema";
-export { parseSerializableDecisionPrompt } from "./schema";
diff --git a/components/tool-ui/decision-prompt/multi-select-actions.tsx b/components/tool-ui/decision-prompt/multi-select-actions.tsx
deleted file mode 100644
index 8c803e3..0000000
--- a/components/tool-ui/decision-prompt/multi-select-actions.tsx
+++ /dev/null
@@ -1,198 +0,0 @@
-"use client";
-
-import { useState, useCallback } from "react";
-import type { DecisionPromptAction } from "./schema";
-import { Button } from "./_ui";
-import { cn } from "./_cn";
-import { Check } from "lucide-react";
-
-interface MultiSelectActionsProps {
- actions: DecisionPromptAction[];
- onConfirm: (actionIds: string[]) => void | Promise;
- align?: "left" | "center" | "right";
- layout?: "inline" | "stack";
- confirmLabel?: string;
- cancelLabel?: string;
- minSelections?: number;
- maxSelections?: number;
- className?: string;
-}
-
-export function MultiSelectActions({
- actions,
- onConfirm,
- align = "right",
- layout = "inline",
- confirmLabel = "Confirm",
- cancelLabel = "Cancel",
- minSelections = 1,
- maxSelections,
- className,
-}: MultiSelectActionsProps) {
- const [selectedIds, setSelectedIds] = useState>(new Set());
- const [isExecuting, setIsExecuting] = useState(false);
-
- const toggleSelection = useCallback(
- (actionId: string) => {
- setSelectedIds((prev) => {
- const next = new Set(prev);
- if (next.has(actionId)) {
- next.delete(actionId);
- } else {
- if (maxSelections && next.size >= maxSelections) {
- return prev;
- }
- next.add(actionId);
- }
- return next;
- });
- },
- [maxSelections],
- );
-
- const handleConfirm = useCallback(async () => {
- if (isExecuting) return;
-
- const selected = Array.from(selectedIds);
- if (selected.length < minSelections) return;
-
- try {
- setIsExecuting(true);
- await onConfirm(selected);
- } finally {
- setIsExecuting(false);
- }
- }, [selectedIds, minSelections, onConfirm, isExecuting]);
-
- const handleCancel = useCallback(() => {
- setSelectedIds(new Set());
- }, []);
-
- const alignClassButtons = {
- left: "justify-start",
- center: "justify-center",
- right: "justify-end",
- }[align];
-
- const isConfirmDisabled =
- isExecuting || selectedIds.size < minSelections || selectedIds.size === 0;
- const isCancelDisabled = isExecuting || selectedIds.size === 0;
-
- return (
-
-
- {actions.map((action) => {
- const isSelected = selectedIds.has(action.id);
- const isDisabled = Boolean(
- action.disabled ||
- isExecuting ||
- (!isSelected &&
- maxSelections &&
- selectedIds.size >= maxSelections),
- );
-
- return (
-
- );
- })}
-
-
-
-
-
-
-
- );
-}
diff --git a/components/tool-ui/decision-prompt/receipt.tsx b/components/tool-ui/decision-prompt/receipt.tsx
deleted file mode 100644
index 8b9f00c..0000000
--- a/components/tool-ui/decision-prompt/receipt.tsx
+++ /dev/null
@@ -1,83 +0,0 @@
-"use client";
-
-import { CheckCircle2 } from "lucide-react";
-import type { DecisionPromptAction } from "./schema";
-import { Badge } from "./_ui";
-import { cn } from "./_cn";
-
-interface DecisionPromptReceiptProps {
- selectedAction?: string;
- selectedActions?: string[];
- actions: DecisionPromptAction[];
- align?: "left" | "center" | "right";
- multiSelect?: boolean;
- className?: string;
-}
-
-export function DecisionPromptReceipt({
- selectedAction,
- selectedActions,
- actions,
- align = "right",
- multiSelect = false,
- className,
-}: DecisionPromptReceiptProps) {
- const alignClass = {
- left: "justify-start",
- center: "justify-center",
- right: "justify-end",
- }[align];
-
- // Multi-select mode
- if (multiSelect && selectedActions && selectedActions.length > 0) {
- const selectedActionObjs = actions.filter((a) =>
- selectedActions.includes(a.id),
- );
-
- return (
-
- {selectedActionObjs.map((action) => (
-
-
- {action.icon && {action.icon}}
- {action.label}
-
- ))}
-
- );
- }
-
- // Single-select mode
- const action = actions.find((a) => a.id === selectedAction);
-
- if (!action) {
- return null;
- }
-
- return (
-
-
-
- {action.icon && {action.icon}}
- {action.label}
-
-
- );
-}
diff --git a/components/tool-ui/decision-prompt/schema.ts b/components/tool-ui/decision-prompt/schema.ts
deleted file mode 100644
index 41ff7e4..0000000
--- a/components/tool-ui/decision-prompt/schema.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-import { z } from "zod";
-import type { ReactNode } from "react";
-
-export const DecisionPromptActionSchema = z.object({
- id: z.string().min(1),
- label: z.string().min(1),
- confirmLabel: z.string().optional(),
- variant: z
- .enum(["default", "destructive", "secondary", "ghost", "outline"])
- .optional(),
- icon: z.custom().optional(),
- loading: z.boolean().optional(),
- disabled: z.boolean().optional(),
- shortcut: z.string().optional(),
-});
-
-export type DecisionPromptAction = z.infer;
-
-export const DecisionPromptPropsSchema = z.object({
- prompt: z.string().min(1),
- actions: z.array(DecisionPromptActionSchema).min(1),
- selectedAction: z.string().optional(),
- selectedActions: z.array(z.string()).optional(),
- description: z.string().optional(),
- onAction: z.custom<(actionId: string) => void | Promise>().optional(),
- onMultiAction: z
- .custom<(actionIds: string[]) => void | Promise>()
- .optional(),
- onBeforeAction: z
- .custom<(actionId: string) => boolean | Promise>()
- .optional(),
- confirmTimeout: z.number().positive().optional(),
- className: z.string().optional(),
- align: z.enum(["left", "center", "right"]).optional(),
- layout: z.enum(["inline", "stack"]).optional(),
- multiSelect: z.boolean().optional(),
- confirmLabel: z.string().optional(),
- cancelLabel: z.string().optional(),
- minSelections: z.number().min(0).optional(),
- maxSelections: z.number().min(1).optional(),
-});
-
-export type DecisionPromptProps = Omit<
- z.infer,
- "onAction" | "onBeforeAction" | "onMultiAction"
-> & {
- onAction?: (actionId: string) => void | Promise;
- onMultiAction?: (actionIds: string[]) => void | Promise;
- onBeforeAction?: (actionId: string) => boolean | Promise;
-};
-
-export const SerializableDecisionPromptSchema = DecisionPromptPropsSchema.omit({
- onAction: true,
- onMultiAction: true,
- onBeforeAction: true,
-}).extend({
- actions: z.array(DecisionPromptActionSchema.omit({ icon: true })),
-});
-
-export type SerializableDecisionPrompt = z.infer<
- typeof SerializableDecisionPromptSchema
->;
-
-export function parseSerializableDecisionPrompt(
- input: unknown,
-): SerializableDecisionPrompt {
- const res = SerializableDecisionPromptSchema.safeParse(input);
- if (!res.success) {
- throw new Error(`Invalid DecisionPrompt payload: ${res.error.message}`);
- }
- return res.data;
-}
diff --git a/components/tool-ui/decision-prompt/usage-docs.md b/components/tool-ui/decision-prompt/usage-docs.md
deleted file mode 100644
index dad7eac..0000000
--- a/components/tool-ui/decision-prompt/usage-docs.md
+++ /dev/null
@@ -1,345 +0,0 @@
-# DecisionPrompt Component
-
-A flexible, inline component for presenting decisions and action choices to users. Designed specifically for tool result actions in conversation UIs, with support for binary decisions, multi-choice selection, destructive confirmations, and async operations.
-
-## Features
-
-- **Inline-only design** - No modals or dialogs, keeps users in the conversation flow
-- **Two-stage destructive actions** - Automatic "confirm" pattern for dangerous operations
-- **Receipt state** - Shows what action was taken for conversation history
-- **Async support** - Loading states and disabled states during execution
-- **Keyboard navigation** - Escape to cancel confirmations, full keyboard support
-- **Flexible styling** - Customizable alignment, variants, icons
-
-## Basic Usage
-
-```tsx
-import { DecisionPrompt } from "@/components/tool-ui/decision-prompt";
-
-function MyComponent() {
- const [selectedAction, setSelectedAction] = useState();
-
- return (
- {
- setSelectedAction(actionId);
- // Handle the action
- }}
- />
- );
-}
-```
-
-## Props
-
-### `prompt` (required)
-
-The question or prompt to display to the user.
-
-```tsx
-
-```
-
-### `actions` (required)
-
-Array of actions the user can choose from. Each action has:
-
-- `id` (required): Unique identifier
-- `label` (required): Display text
-- `confirmLabel` (optional): Label shown in two-stage confirmation
-- `variant` (optional): `"default" | "destructive" | "secondary" | "ghost"`
-- `icon` (optional): React element to display before label
-- `loading` (optional): Shows loading spinner
-- `disabled` (optional): Disables the action
-- `shortcut` (optional): Keyboard shortcut hint
-
-```tsx
-actions={[
- {
- id: "delete",
- label: "Delete",
- confirmLabel: "Confirm delete",
- variant: "destructive",
- icon:
- }
-]}
-```
-
-### `selectedAction` (optional)
-
-The ID of the action that was selected. When set, the component displays the receipt state instead of action buttons.
-
-```tsx
-
-```
-
-### `description` (optional)
-
-Additional context or description below the prompt.
-
-```tsx
-
-```
-
-### `onAction` (optional)
-
-Callback fired when user selects an action. For two-stage actions, this fires only after confirmation.
-
-```tsx
-onAction={async (actionId) => {
- if (actionId === "send") {
- await sendEmail();
- }
- setSelectedAction(actionId);
-}}
-```
-
-### `onBeforeAction` (optional)
-
-Callback fired before action executes. Return `false` or `Promise` to prevent the action.
-
-```tsx
-onBeforeAction={async (actionId) => {
- if (actionId === "delete") {
- const hasPermission = await checkPermission();
- if (!hasPermission) {
- alert("You don't have permission");
- return false;
- }
- }
- return true;
-}}
-```
-
-### `confirmTimeout` (optional)
-
-Timeout in milliseconds for two-stage confirmations to auto-reset. Default: `3000` (3 seconds).
-
-```tsx
-
-```
-
-### `align` (optional)
-
-Alignment of action buttons: `"left" | "center" | "right"`. Default: `"right"`.
-
-```tsx
-
-```
-
-### `className` (optional)
-
-Additional CSS classes for the container.
-
-## Use Cases
-
-### Binary Decision
-
-Simple yes/no or confirm/cancel choices.
-
-```tsx
- {
- if (actionId === "send") {
- sendEmail();
- }
- }}
-/>
-```
-
-### Multi-Choice Selection
-
-Choose from 3+ options.
-
-```tsx
- {
- exportData(actionId);
- }}
- align="center"
-/>
-```
-
-### Destructive Action (Two-Stage)
-
-Automatic confirmation pattern for dangerous operations.
-
-```tsx
- {
- if (actionId === "delete") {
- deleteFiles();
- }
- }}
- confirmTimeout={5000} // 5 second timeout before reset
-/>
-```
-
-**Two-stage flow:**
-
-1. User clicks "Delete" → button changes to "Confirm delete" (red, pulsing)
-2. User clicks "Confirm delete" → `onAction` fires
-3. If user doesn't click within timeout → resets to "Delete"
-4. User presses Escape → resets to "Delete"
-
-### Async with Loading
-
-Show loading state during async operations.
-
-```tsx
- {
- if (actionId === "install") {
- await installPackages(); // Shows loading spinner automatically
- }
- }}
-/>
-```
-
-### With Validation
-
-Prevent actions based on runtime conditions.
-
-```tsx
- {
- if (actionId === "deploy") {
- const canDeploy = await checkDeploymentPermission();
- if (!canDeploy) {
- alert("You don't have permission to deploy");
- return false; // Prevents action
- }
- }
- return true;
- }}
- onAction={(actionId) => {
- deployToProduction();
- }}
-/>
-```
-
-## Integration with Conversation System
-
-The component is designed to work with conversation/message systems that persist state:
-
-```tsx
-// Message data structure
-interface Message {
- id: string;
- type: "decision-prompt";
- data: {
- prompt: string;
- actions: DecisionPromptAction[];
- selectedAction?: string; // Set when user makes choice
- };
-}
-
-// Render message
-function MessageRenderer({ message }: { message: Message }) {
- const [messages, setMessages] = useState([]);
-
- return (
- {
- // Update message with selected action
- setMessages((prev) =>
- prev.map((msg) =>
- msg.id === message.id
- ? { ...msg, data: { ...msg.data, selectedAction: actionId } }
- : msg,
- ),
- );
- }}
- />
- );
-}
-```
-
-## Styling
-
-The component uses Tailwind CSS and follows the existing design system:
-
-- Uses `border`, `bg-card`, `text-card-foreground` for consistent theming
-- Receipt state uses `bg-muted/30` for visual distinction
-- Destructive confirmations show `animate-pulse` and `ring-2 ring-destructive`
-- Fully responsive with mobile-first design
-
-## Accessibility
-
-- Keyboard navigation with Tab/Shift+Tab
-- Escape key cancels two-stage confirmations
-- ARIA labels include keyboard shortcuts
-- Loading states announced to screen readers
-- Focus management during state transitions
-
-## TypeScript
-
-Full TypeScript support with Zod validation:
-
-```tsx
-import type {
- DecisionPromptAction,
- DecisionPromptProps,
-} from "@/components/tool-ui/decision-prompt";
-
-// For message persistence (without functions)
-import type { SerializableDecisionPrompt } from "@/components/tool-ui/decision-prompt";
-```
-
-## Examples
-
-See `example.tsx` for comprehensive examples of all use cases.
diff --git a/components/tool-ui/media-card/_cn.ts b/components/tool-ui/media-card/_cn.ts
index 6e96531..ed8d149 100644
--- a/components/tool-ui/media-card/_cn.ts
+++ b/components/tool-ui/media-card/_cn.ts
@@ -1 +1,14 @@
-export { cn } from "../data-table/_cn";
+/**
+ * Utility re-export for copy-standalone portability.
+ *
+ * This file centralizes the cn() utility so the component can be easily
+ * copied to another project by updating this to use the target project's
+ * utility path (e.g., "@/lib/utils").
+ */
+import type { ClassValue } from "clsx";
+import { clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/components/tool-ui/media-card/_ui.tsx b/components/tool-ui/media-card/_ui.tsx
index f209c44..b2df713 100644
--- a/components/tool-ui/media-card/_ui.tsx
+++ b/components/tool-ui/media-card/_ui.tsx
@@ -1,3 +1,10 @@
+/**
+ * UI component re-exports for copy-standalone portability.
+ *
+ * This file centralizes UI dependencies so the component can be easily
+ * copied to another project by updating these imports to match the target
+ * project's component library paths.
+ */
"use client";
export { Card, CardContent, CardFooter } from "../../ui/card";
diff --git a/components/tool-ui/media-card/context.tsx b/components/tool-ui/media-card/context.tsx
index d15bd27..0020620 100644
--- a/components/tool-ui/media-card/context.tsx
+++ b/components/tool-ui/media-card/context.tsx
@@ -2,6 +2,7 @@
import * as React from "react";
import type { SerializableMediaCard } from "./schema";
+import type { ActionsProp } from "../shared";
export type MediaCardUIState = {
playing?: boolean;
@@ -26,6 +27,15 @@ export interface MediaCardClientProps {
type: "play" | "pause" | "mute" | "unmute",
payload?: unknown,
) => void;
+ onMediaAction?: (actionId: string, card: SerializableMediaCard) => void;
+ onBeforeMediaAction?: (args: {
+ action: string;
+ card: SerializableMediaCard;
+ }) => boolean | Promise;
+ footerActions?: ActionsProp;
+ onFooterAction?: (actionId: string) => void | Promise;
+ onBeforeFooterAction?: (actionId: string) => boolean | Promise;
+ locale?: string;
}
export interface MediaCardContextValue {
@@ -37,7 +47,7 @@ export interface MediaCardContextValue {
setState: (patch: Partial) => void;
handlers: Pick<
MediaCardClientProps,
- "onNavigate" | "onAction" | "onBeforeAction" | "onMediaEvent"
+ "onNavigate" | "onMediaAction" | "onBeforeMediaAction" | "onMediaEvent"
>;
mediaElement: HTMLMediaElement | null;
setMediaElement: (node: HTMLMediaElement | null) => void;
diff --git a/components/tool-ui/media-card/footer.tsx b/components/tool-ui/media-card/footer.tsx
index a9bfaa8..bf96014 100644
--- a/components/tool-ui/media-card/footer.tsx
+++ b/components/tool-ui/media-card/footer.tsx
@@ -1,7 +1,7 @@
"use client";
import * as React from "react";
-import { Actions } from "./actions";
+import { Actions } from "./media-actions";
import { useMediaCard } from "./context";
function formatDuration(durationMs: number) {
diff --git a/components/tool-ui/media-card/actions.tsx b/components/tool-ui/media-card/media-actions.tsx
similarity index 98%
rename from components/tool-ui/media-card/actions.tsx
rename to components/tool-ui/media-card/media-actions.tsx
index 83f3790..abba809 100644
--- a/components/tool-ui/media-card/actions.tsx
+++ b/components/tool-ui/media-card/media-actions.tsx
@@ -95,7 +95,7 @@ export function Actions() {
async function run(actionId: MediaActionId) {
const shouldProceed =
- (await handlers.onBeforeAction?.({
+ (await handlers.onBeforeMediaAction?.({
action: actionId,
card,
})) ?? true;
@@ -170,7 +170,7 @@ export function Actions() {
break;
}
- handlers.onAction?.(actionId, card);
+ handlers.onMediaAction?.(actionId, card);
}
const playing =
diff --git a/components/tool-ui/media-card/media-card.tsx b/components/tool-ui/media-card/media-card.tsx
index 760e3ea..da3e4b8 100644
--- a/components/tool-ui/media-card/media-card.tsx
+++ b/components/tool-ui/media-card/media-card.tsx
@@ -16,6 +16,7 @@ import { MediaCardBody } from "./body";
import { MediaCardFooter } from "./footer";
import { LinkOverlay } from "./link-overlay";
import { MediaCardProgress } from "./progress";
+import { ActionButtons, normalizeActionsConfig } from "../shared";
const BASE_CARD_STYLE = "border border-border bg-card text-sm shadow-xs";
const DEFAULT_CONTENT_SPACING = "gap-4 p-5";
@@ -47,9 +48,12 @@ export function MediaCard(props: MediaCardProps) {
defaultState,
onStateChange,
onNavigate,
- onAction,
- onBeforeAction,
onMediaEvent,
+ footerActions,
+ onFooterAction,
+ onBeforeFooterAction,
+ onMediaAction,
+ onBeforeMediaAction,
locale: providedLocale,
...serializable
} = props;
@@ -119,8 +123,8 @@ export function MediaCard(props: MediaCardProps) {
setState: updateState,
handlers: {
onNavigate,
- onAction,
- onBeforeAction,
+ onMediaAction,
+ onBeforeMediaAction,
onMediaEvent,
},
mediaElement,
@@ -141,6 +145,11 @@ export function MediaCard(props: MediaCardProps) {
: DEFAULT_CONTENT_SPACING;
const linkContentPadding = LINK_CONTENT_SPACING;
+ const normalizedFooterActions = React.useMemo(
+ () => normalizeActionsConfig(footerActions),
+ [footerActions],
+ );
+
return (
)}
+ {normalizedFooterActions ? (
+
+
onFooterAction?.(id)}
+ onBeforeAction={onBeforeFooterAction}
+ />
+
+ ) : null}
);
diff --git a/components/tool-ui/option-list/_cn.ts b/components/tool-ui/option-list/_cn.ts
new file mode 100644
index 0000000..ed8d149
--- /dev/null
+++ b/components/tool-ui/option-list/_cn.ts
@@ -0,0 +1,14 @@
+/**
+ * Utility re-export for copy-standalone portability.
+ *
+ * This file centralizes the cn() utility so the component can be easily
+ * copied to another project by updating this to use the target project's
+ * utility path (e.g., "@/lib/utils").
+ */
+import type { ClassValue } from "clsx";
+import { clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/components/tool-ui/option-list/_ui.tsx b/components/tool-ui/option-list/_ui.tsx
new file mode 100644
index 0000000..b12c0dc
--- /dev/null
+++ b/components/tool-ui/option-list/_ui.tsx
@@ -0,0 +1,9 @@
+/**
+ * UI component re-exports for copy-standalone portability.
+ *
+ * This file centralizes UI dependencies so the component can be easily
+ * copied to another project by updating these imports to match the target
+ * project's component library paths.
+ */
+export { Button } from "../../ui/button";
+export { Separator } from "../../ui/separator";
diff --git a/components/tool-ui/option-list/index.tsx b/components/tool-ui/option-list/index.tsx
new file mode 100644
index 0000000..b24468b
--- /dev/null
+++ b/components/tool-ui/option-list/index.tsx
@@ -0,0 +1,13 @@
+export { OptionList } from "./option-list";
+export type {
+ OptionListProps,
+ OptionListOption,
+ OptionListSelection,
+ SerializableOptionList,
+} from "./schema";
+export {
+ OptionListOptionSchema,
+ OptionListPropsSchema,
+ SerializableOptionListSchema,
+ parseSerializableOptionList,
+} from "./schema";
diff --git a/components/tool-ui/option-list/option-list.tsx b/components/tool-ui/option-list/option-list.tsx
new file mode 100644
index 0000000..0c4c694
--- /dev/null
+++ b/components/tool-ui/option-list/option-list.tsx
@@ -0,0 +1,303 @@
+"use client";
+
+import { useMemo, useState, useCallback, useEffect, Fragment } from "react";
+import type {
+ OptionListProps,
+ OptionListSelection,
+ OptionListOption,
+} from "./schema";
+import { ActionButtons, normalizeActionsConfig } from "../shared";
+import type { Action } from "../shared";
+import { Button, Separator } from "./_ui";
+import { cn } from "./_cn";
+import { Check } from "lucide-react";
+
+function normalizeToSet(
+ value: OptionListSelection | undefined,
+ mode: "multi" | "single",
+ maxSelections?: number,
+): Set {
+ if (mode === "single") {
+ const single =
+ typeof value === "string"
+ ? value
+ : Array.isArray(value)
+ ? value[0]
+ : null;
+ return single ? new Set([single]) : new Set();
+ }
+
+ const arr =
+ typeof value === "string" ? [value] : Array.isArray(value) ? value : [];
+
+ return new Set(maxSelections ? arr.slice(0, maxSelections) : arr);
+}
+
+function setToSelection(
+ selected: Set,
+ mode: "multi" | "single",
+): OptionListSelection {
+ if (mode === "single") {
+ const [first] = selected;
+ return first ?? null;
+ }
+ return Array.from(selected);
+}
+
+function areSetsEqual(a: Set, b: Set) {
+ if (a.size !== b.size) return false;
+ for (const val of a) {
+ if (!b.has(val)) return false;
+ }
+ return true;
+}
+
+export function OptionList({
+ options,
+ selectionMode = "multi",
+ minSelections = 1,
+ maxSelections,
+ value,
+ defaultValue,
+ onChange,
+ onConfirm,
+ onCancel,
+ footerActions,
+ className,
+}: OptionListProps) {
+ const effectiveMaxSelections = selectionMode === "single" ? 1 : maxSelections;
+
+ const [uncontrolledSelected, setUncontrolledSelected] = useState>(
+ () => normalizeToSet(defaultValue, selectionMode, effectiveMaxSelections),
+ );
+
+ useEffect(() => {
+ setUncontrolledSelected((prev) =>
+ normalizeToSet(Array.from(prev), selectionMode, effectiveMaxSelections),
+ );
+ }, [selectionMode, effectiveMaxSelections]);
+
+ const selectedIds = useMemo(
+ () =>
+ value !== undefined
+ ? normalizeToSet(value, selectionMode, effectiveMaxSelections)
+ : uncontrolledSelected,
+ [value, uncontrolledSelected, selectionMode, effectiveMaxSelections],
+ );
+
+ const selectedCount = selectedIds.size;
+
+ const updateSelection = useCallback(
+ (next: Set) => {
+ const normalizedNext = normalizeToSet(
+ Array.from(next),
+ selectionMode,
+ effectiveMaxSelections,
+ );
+
+ if (value === undefined) {
+ if (!areSetsEqual(uncontrolledSelected, normalizedNext)) {
+ setUncontrolledSelected(normalizedNext);
+ }
+ }
+
+ onChange?.(setToSelection(normalizedNext, selectionMode));
+ },
+ [
+ effectiveMaxSelections,
+ selectionMode,
+ uncontrolledSelected,
+ value,
+ onChange,
+ ],
+ );
+
+ const toggleSelection = useCallback(
+ (optionId: string) => {
+ const next = new Set(selectedIds);
+ const isSelected = next.has(optionId);
+
+ if (selectionMode === "single") {
+ if (isSelected) {
+ next.delete(optionId);
+ } else {
+ next.clear();
+ next.add(optionId);
+ }
+ } else {
+ if (isSelected) {
+ next.delete(optionId);
+ } else {
+ if (effectiveMaxSelections && next.size >= effectiveMaxSelections) {
+ return;
+ }
+ next.add(optionId);
+ }
+ }
+
+ updateSelection(next);
+ },
+ [effectiveMaxSelections, selectedIds, selectionMode, updateSelection],
+ );
+
+ const handleConfirm = useCallback(async () => {
+ if (!onConfirm) return;
+ if (selectedCount === 0 || selectedCount < minSelections) return;
+ await onConfirm(setToSelection(selectedIds, selectionMode));
+ }, [minSelections, onConfirm, selectedCount, selectedIds, selectionMode]);
+
+ const handleCancel = useCallback(() => {
+ const empty = new Set();
+ updateSelection(empty);
+ onCancel?.();
+ }, [onCancel, updateSelection]);
+
+ const normalizedFooterActions = useMemo(() => {
+ const normalized = normalizeActionsConfig(footerActions);
+ if (normalized) return normalized;
+ return {
+ items: [
+ { id: "cancel", label: "Clear", variant: "ghost" as const },
+ { id: "confirm", label: "Confirm", variant: "default" as const },
+ ],
+ align: "right" as const,
+ } satisfies ReturnType;
+ }, [footerActions]);
+
+ const isConfirmDisabled =
+ selectedCount < minSelections || selectedCount === 0;
+ const isCancelDisabled = selectedCount === 0;
+
+ const actionsWithDisabledState = useMemo((): Action[] => {
+ return normalizedFooterActions.items.map((action) => {
+ const gatedDisabled =
+ (action.id === "confirm" && isConfirmDisabled) ||
+ (action.id === "cancel" && isCancelDisabled);
+ return {
+ ...action,
+ disabled: action.disabled || gatedDisabled,
+ label:
+ action.id === "confirm" &&
+ selectionMode === "multi" &&
+ selectedCount > 0
+ ? `${action.label} (${selectedCount})`
+ : action.label,
+ };
+ });
+ }, [
+ normalizedFooterActions.items,
+ isConfirmDisabled,
+ isCancelDisabled,
+ selectionMode,
+ selectedCount,
+ ]);
+
+ const handleFooterAction = useCallback(
+ async (actionId: string) => {
+ if (actionId === "confirm") {
+ await handleConfirm();
+ } else if (actionId === "cancel") {
+ handleCancel();
+ }
+ },
+ [handleConfirm, handleCancel],
+ );
+
+ const indicatorShape =
+ selectionMode === "single" ? "rounded-full" : "rounded";
+
+ const renderIndicator = (option: OptionListOption, isSelected: boolean) => (
+
+ {selectionMode === "multi" && isSelected && }
+ {selectionMode === "single" && isSelected && (
+
+ )}
+
+ );
+
+ return (
+
+
+ {options.map((option, index) => {
+ const isSelected = selectedIds.has(option.id);
+ const isSelectionLocked =
+ selectionMode === "multi" &&
+ effectiveMaxSelections !== undefined &&
+ selectedCount >= effectiveMaxSelections &&
+ !isSelected;
+ const isDisabled = option.disabled || isSelectionLocked;
+
+ return (
+
+ {index > 0 && (
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+ );
+}
diff --git a/components/tool-ui/option-list/schema.ts b/components/tool-ui/option-list/schema.ts
new file mode 100644
index 0000000..2cfc6dd
--- /dev/null
+++ b/components/tool-ui/option-list/schema.ts
@@ -0,0 +1,68 @@
+import { z } from "zod";
+import type { ReactNode } from "react";
+import type { ActionsProp } from "../shared";
+import {
+ ActionSchema,
+ SerializableActionSchema,
+ SerializableActionsConfigSchema,
+} from "../shared";
+
+export const OptionListOptionSchema = z.object({
+ id: z.string().min(1),
+ label: z.string().min(1),
+ description: z.string().optional(),
+ icon: z.custom().optional(),
+ disabled: z.boolean().optional(),
+});
+
+export const OptionListPropsSchema = z.object({
+ options: z.array(OptionListOptionSchema).min(1),
+ selectionMode: z.enum(["multi", "single"]).optional(),
+ value: z
+ .union([z.array(z.string()), z.string(), z.null()])
+ .optional(),
+ defaultValue: z
+ .union([z.array(z.string()), z.string(), z.null()])
+ .optional(),
+ footerActions: z
+ .union([z.array(ActionSchema), SerializableActionsConfigSchema])
+ .optional(),
+ minSelections: z.number().min(0).optional(),
+ maxSelections: z.number().min(1).optional(),
+ className: z.string().optional(),
+});
+
+export type OptionListSelection = string[] | string | null;
+
+export type OptionListOption = z.infer;
+
+export type OptionListProps = Omit<
+ z.infer,
+ "value" | "defaultValue"
+> & {
+ value?: OptionListSelection;
+ defaultValue?: OptionListSelection;
+ onChange?: (value: OptionListSelection) => void;
+ onConfirm?: (value: OptionListSelection) => void | Promise;
+ onCancel?: () => void;
+ footerActions?: ActionsProp;
+};
+
+export const SerializableOptionListSchema = OptionListPropsSchema.extend({
+ options: z.array(OptionListOptionSchema.omit({ icon: true })),
+ footerActions: z
+ .union([z.array(SerializableActionSchema), SerializableActionsConfigSchema])
+ .optional(),
+});
+
+export type SerializableOptionList = z.infer;
+
+export function parseSerializableOptionList(
+ input: unknown,
+): SerializableOptionList {
+ const res = SerializableOptionListSchema.safeParse(input);
+ if (!res.success) {
+ throw new Error(`Invalid OptionList payload: ${res.error.message}`);
+ }
+ return res.data;
+}
diff --git a/components/tool-ui/shared/_cn.ts b/components/tool-ui/shared/_cn.ts
new file mode 100644
index 0000000..ed8d149
--- /dev/null
+++ b/components/tool-ui/shared/_cn.ts
@@ -0,0 +1,14 @@
+/**
+ * Utility re-export for copy-standalone portability.
+ *
+ * This file centralizes the cn() utility so the component can be easily
+ * copied to another project by updating this to use the target project's
+ * utility path (e.g., "@/lib/utils").
+ */
+import type { ClassValue } from "clsx";
+import { clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/components/tool-ui/shared/_ui.tsx b/components/tool-ui/shared/_ui.tsx
new file mode 100644
index 0000000..d358610
--- /dev/null
+++ b/components/tool-ui/shared/_ui.tsx
@@ -0,0 +1,10 @@
+/**
+ * UI component re-exports for copy-standalone portability.
+ *
+ * This file centralizes UI dependencies so the component can be easily
+ * copied to another project by updating these imports to match the target
+ * project's component library paths.
+ */
+"use client";
+
+export { Button } from "../../ui/button";
diff --git a/components/tool-ui/shared/action-buttons.tsx b/components/tool-ui/shared/action-buttons.tsx
new file mode 100644
index 0000000..a4737e6
--- /dev/null
+++ b/components/tool-ui/shared/action-buttons.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import type { Action } from "./schema";
+import { Button } from "./_ui";
+import { cn } from "./_cn";
+import { useActionButtons } from "./use-action-buttons";
+
+export interface ActionButtonsProps {
+ actions: Action[];
+ onAction: (actionId: string) => void | Promise;
+ onBeforeAction?: (actionId: string) => boolean | Promise;
+ 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,
+ });
+
+ const crossAlignClass = {
+ left: "items-start",
+ center: "items-center",
+ right: "items-end",
+ }[align];
+
+ return (
+
+ {resolvedActions.map((action) => {
+ const label = action.currentLabel;
+ const variant = action.variant || "default";
+
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/components/tool-ui/shared/actions-config.ts b/components/tool-ui/shared/actions-config.ts
new file mode 100644
index 0000000..e3f1696
--- /dev/null
+++ b/components/tool-ui/shared/actions-config.ts
@@ -0,0 +1,23 @@
+import type { Action, ActionsConfig } from "./schema";
+
+export type ActionsProp = ActionsConfig | Action[];
+
+export function normalizeActionsConfig(
+ actions?: ActionsProp,
+): ActionsConfig | null {
+ if (!actions) return null;
+
+ const resolved = Array.isArray(actions)
+ ? { items: actions }
+ : {
+ items: actions.items ?? [],
+ align: actions.align,
+ confirmTimeout: actions.confirmTimeout,
+ };
+
+ if (!resolved.items || resolved.items.length === 0) {
+ return null;
+ }
+
+ return resolved;
+}
diff --git a/components/tool-ui/shared/index.ts b/components/tool-ui/shared/index.ts
new file mode 100644
index 0000000..7d27135
--- /dev/null
+++ b/components/tool-ui/shared/index.ts
@@ -0,0 +1,22 @@
+export { ActionButtons } from "./action-buttons";
+export type { ActionButtonsProps } from "./action-buttons";
+export { useActionButtons } from "./use-action-buttons";
+export type {
+ UseActionButtonsOptions,
+ UseActionButtonsResult,
+} from "./use-action-buttons";
+export type {
+ Action,
+ SerializableAction,
+ ActionsConfig,
+ SerializableActionsConfig,
+} from "./schema";
+export type { ActionsProp } from "./actions-config";
+export { normalizeActionsConfig } from "./actions-config";
+export {
+ ActionSchema,
+ SerializableActionSchema,
+ ActionButtonsPropsSchema,
+ SerializableActionsSchema,
+ SerializableActionsConfigSchema,
+} from "./schema";
diff --git a/components/tool-ui/shared/schema.ts b/components/tool-ui/shared/schema.ts
new file mode 100644
index 0000000..bc6e0c4
--- /dev/null
+++ b/components/tool-ui/shared/schema.ts
@@ -0,0 +1,48 @@
+import { z } from "zod";
+import type { ReactNode } from "react";
+
+export const ActionSchema = z.object({
+ id: z.string().min(1),
+ label: z.string().min(1),
+ confirmLabel: z.string().optional(),
+ variant: z
+ .enum(["default", "destructive", "secondary", "ghost", "outline"])
+ .optional(),
+ icon: z.custom().optional(),
+ loading: z.boolean().optional(),
+ disabled: z.boolean().optional(),
+ shortcut: z.string().optional(),
+});
+
+export type Action = z.infer;
+
+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 type 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;
diff --git a/components/tool-ui/shared/use-action-buttons.tsx b/components/tool-ui/shared/use-action-buttons.tsx
new file mode 100644
index 0000000..9a1f484
--- /dev/null
+++ b/components/tool-ui/shared/use-action-buttons.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+import type { Action } from "./schema";
+
+export type UseActionButtonsOptions = {
+ actions: Action[];
+ onAction: (actionId: string) => void | Promise;
+ onBeforeAction?: (actionId: string) => boolean | Promise;
+ confirmTimeout?: number;
+};
+
+export type UseActionButtonsResult = {
+ actions: Array<
+ Action & {
+ currentLabel: string;
+ isConfirming: boolean;
+ isExecuting: boolean;
+ isDisabled: boolean;
+ isLoading: boolean;
+ }
+ >;
+ runAction: (actionId: string) => Promise;
+ confirmingActionId: string | null;
+ executingActionId: string | null;
+};
+
+export function useActionButtons(
+ options: UseActionButtonsOptions,
+): UseActionButtonsResult {
+ const {
+ actions,
+ onAction,
+ onBeforeAction,
+ confirmTimeout = 3000,
+ } = options;
+
+ const [confirmingActionId, setConfirmingActionId] = useState(
+ null,
+ );
+ const [executingActionId, setExecutingActionId] = useState(
+ null,
+ );
+
+ useEffect(() => {
+ if (!confirmingActionId) return;
+ const id = setTimeout(() => setConfirmingActionId(null), confirmTimeout);
+ return () => clearTimeout(id);
+ }, [confirmingActionId, confirmTimeout]);
+
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape" && confirmingActionId) {
+ 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 isExecuting = executingActionId !== null;
+ if (action.disabled || action.loading || isExecuting) {
+ return;
+ }
+
+ if (action.confirmLabel && confirmingActionId !== action.id) {
+ setConfirmingActionId(action.id);
+ return;
+ }
+
+ if (onBeforeAction) {
+ const shouldProceed = await onBeforeAction(action.id);
+ if (!shouldProceed) {
+ setConfirmingActionId(null);
+ return;
+ }
+ }
+
+ try {
+ setExecutingActionId(action.id);
+ await onAction(action.id);
+ } finally {
+ setExecutingActionId(null);
+ setConfirmingActionId(null);
+ }
+ },
+ [actions, confirmingActionId, executingActionId, onAction, onBeforeAction],
+ );
+
+ const resolvedActions = useMemo(
+ () =>
+ actions.map((action) => {
+ const isConfirming = confirmingActionId === action.id;
+ const isExecuting = executingActionId === action.id;
+ const isLoading = action.loading || isExecuting;
+ const isDisabled =
+ action.disabled || (executingActionId !== null && !isExecuting);
+ const currentLabel =
+ isConfirming && action.confirmLabel
+ ? action.confirmLabel
+ : action.label;
+
+ return {
+ ...action,
+ currentLabel,
+ isConfirming,
+ isExecuting,
+ isDisabled,
+ isLoading,
+ };
+ }),
+ [actions, confirmingActionId, executingActionId],
+ );
+
+ return {
+ actions: resolvedActions,
+ runAction,
+ confirmingActionId,
+ executingActionId,
+ };
+}
diff --git a/components/tool-ui/social-post/_cn.ts b/components/tool-ui/social-post/_cn.ts
index 6e96531..ed8d149 100644
--- a/components/tool-ui/social-post/_cn.ts
+++ b/components/tool-ui/social-post/_cn.ts
@@ -1 +1,14 @@
-export { cn } from "../data-table/_cn";
+/**
+ * Utility re-export for copy-standalone portability.
+ *
+ * This file centralizes the cn() utility so the component can be easily
+ * copied to another project by updating this to use the target project's
+ * utility path (e.g., "@/lib/utils").
+ */
+import type { ClassValue } from "clsx";
+import { clsx } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/components/tool-ui/social-post/_ui.tsx b/components/tool-ui/social-post/_ui.tsx
index 65442f8..5cf1d72 100644
--- a/components/tool-ui/social-post/_ui.tsx
+++ b/components/tool-ui/social-post/_ui.tsx
@@ -1,3 +1,10 @@
+/**
+ * UI component re-exports for copy-standalone portability.
+ *
+ * This file centralizes UI dependencies so the component can be easily
+ * copied to another project by updating these imports to match the target
+ * project's component library paths.
+ */
"use client";
export { Button } from "../../ui/button";
@@ -14,5 +21,3 @@ export {
TooltipTrigger,
} from "../../ui/tooltip";
export { Badge } from "../../ui/badge";
-
-// We will use plain
/