Files
vercel__workflow/workbench/nextjs-turbopack/components/ai-elements/prompt-input.tsx
Pranay Prakash 74aea7b0af Add DurableAgent compat tests, e2e tests, and migrate to AI SDK v6 (#1362)
* Add DurableAgent compat tests, e2e agent tests, and migrate to AI SDK v6

- Port ToolLoopAgent test suite as DurableAgent compatibility spec (34 tests,
  all expected to fail — each maps to a feature gap to implement)
- Add e2e workflow definitions using mock LLM providers (no API keys needed)
- Add e2e test file for DurableAgent workflows
- Migrate all AI SDK types from V2 to V3 (LanguageModelV2 → V3, etc.)
- Drop AI SDK v5 support: ai peer dep ^5||^6 → ^6, @ai-sdk/provider ^2||^3 → ^3
- Update ai catalog version from 5.0.104 to 6.0.116
- Simplify CompatibleLanguageModel to just LanguageModelV3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address PR review feedback

- Remove providerExecuted guard on tool-result stream parts (V3: all
  tool-results are provider-executed by definition)
- Remove providerExecuted spread from tool-output-available UI chunks
- Replace inline MockLanguageModelV3 with import from ai/test (works
  without msw in AI SDK v6)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Remove streamTextIterator mock from compat tests, use it.fails for gaps

Tests now exercise the real DurableAgent code path instead of mocking
the core iterator. 5 tests pass (features DurableAgent already has),
29 are marked it.fails() for known API gaps that will alert when fixed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Implement Tier 1+2 gaps, add @workflow/ai/test mock provider, wire e2e in CI

DurableAgent API additions:
- Add `instructions` (string | SystemModelMessage | SystemModelMessage[])
  as alias for deprecated `system` on constructor
- Add `onStepFinish` and `onFinish` on constructor, merged with stream
  options (constructor first, then stream — matching ToolLoopAgent)
- Add `timeout` on stream options (converted to AbortSignal)
- Add `text`, `finishReason`, `totalUsage` to onFinish event

Test infrastructure:
- Add @workflow/ai/test export with `mockModel()` wrapper that wraps
  MockLanguageModelV3 from ai/test as an async step function
- E2e workflows now use mockModel() + convertArrayToReadableStream
  from @workflow/ai/test instead of inline V2 mock models
- Add e2e-agent.test.ts to test:e2e script so it runs in CI
- Flip 6 compat tests from it.fails → it (now passing)

Score: 11 passing / 23 it.fails (was 5/29)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Remove e2e agent tests — mock models can't serialize across step boundary

The workflow runtime serializes step arguments, and function closures
(like mock model doStream callbacks) aren't serializable. Mock models
only work in unit tests where 'use step' is a no-op. Real e2e agent
tests would need either a mock HTTP server or real provider credentials.

Also removes 'use step' from mockModel wrapper (closures aren't
serializable) and reverts test:e2e script and example workbench dep.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add working e2e agent tests with mock model step factories

Mock model factories use the same 'use step' pattern as real providers
(anthropic, openai). Closure variables are bound to locals at the step
body level so the SWC plugin detects them via __private_getClosureVars.

All 4 e2e tests pass against local dev server:
- agentBasicE2e: text response (11s)
- agentToolCallE2e: single tool call + text (11s)
- agentMultiStepE2e: 3 sequential tool calls (12s)
- agentErrorToolE2e: FatalError recovery (11s)

Also adds e2e-agent.test.ts to test:e2e script for CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Use @workflow/ai/test package imports for e2e mock models

Split mock provider into two files to work around SWC constructor
closure bug: mock-create.ts has the model creation logic,
mock.ts has the 'use step' wrappers that capture only serializable
args (strings, plain object arrays).

Exports mockTextModel(text) and mockSequenceModel(responses) —
same 'use step' pattern as real providers (anthropic, openai, etc.).
E2e workflows now import directly from @workflow/ai/test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Simplify mock provider, add comprehensive e2e tests for all features + gaps

Mock provider:
- Replace mock-create.ts with mock-function-wrapper.ts that simply wraps
  MockLanguageModelV3 constructor in a function (SWC class closure bug)
- mockTextModel/mockSequenceModel use mockProvider() from wrapper file
- Bind closure vars at step body level (_text = text) for SWC detection
- Fix AbortController not available in workflow VM sandbox

E2e tests (13 total, all passing):
- Core: basic text, tool call, multi-step, error recovery (4)
- Callbacks: onStepFinish constructor+stream, onFinish constructor+stream (2)
- Features: instructions, timeout (2)
- GAPs documented: onStart, onStepStart, onToolCallStart,
  onToolCallFinish, prepareCall (5 — complete but callbacks not called)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add tool approval (needsApproval) gap tests, fix SWC closure var binding

Unit tests: 2 new it.fails() tests for tool approval
- needsApproval: true should pause agent (pending tool call, no result)
- needsApproval as function should receive tool input

E2e tests: 1 new test for tool approval gap
- Documents that needsApproval is currently ignored (tool executes anyway)

Also fixes:
- Bind closure vars at step body level in mock provider (_text = text,
  _responses = responses) so SWC plugin detects them
- Guard AbortController usage in workflow VM (not available in sandbox)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add default args for agent e2e workflows in UI definitions

The nextjs-turbopack UI calls workflows with hardcoded default args.
Without these entries, agent workflows were called with no args,
causing prompt=undefined → ModelMessage validation failure.

Also removes debug logging.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add DurableAgent chat UI with tools, update docs for AI SDK v6

Chat UI:
- Tab-based layout with Workflows (existing) and DurableAgent Chat tabs
- Chat powered by DurableAgent + WorkflowChatTransport + ai-elements
- Tools: getWeather (fake data), calculate (math expressions)
- Uses createUIMessageStreamResponse for proper stream serialization
- Reconnect route at /api/chat/[runId]/stream
- ai-elements components: conversation, message, prompt-input, tool
- onStepFinish + onFinish callbacks with console logging

Docs (AI SDK v6 migration):
- system → instructions in DurableAgent constructor examples (10 places)
- LanguageModelV2Prompt → LanguageModelV3Prompt in type references (3 places)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix tool rendering, add reasoning support, model picker, observability links

- Fix tool part rendering: use `input`/`output` props (not `args`/`result`)
  and `tool-{name}` part type (AI SDK v6 format)
- Add reasoning support for Opus 4.5 via providerOptions
- Model picker: Haiku 4.5, Sonnet 4, Opus 4.5 (reasoning), GPT-5.2, GPT-5.3
- Fix observability links: localhost:3456 for local, Vercel dashboard for prod
- Add suggestions above prompt input
- Add MessageParts component handling text, tool, reasoning, step-start
- Add loading spinner for submitted state
- Update docs: system → instructions, LanguageModelV2Prompt → V3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix tool output rendering: use input/output props on ToolInput/ToolOutput

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix: Documentation for `PrepareStepInfo` and `PrepareStepResult` interfaces references the obsolete `LanguageModelV2` type while the codebase has fully migrated to `LanguageModelV3`.

This commit fixes the issue reported at docs/content/docs/ai/message-queueing.mdx:36

**Bug explanation:**

The codebase migrated from AI SDK V2 to V3. In `packages/ai/src/agent/types.ts`, `CompatibleLanguageModel` is defined as `LanguageModelV3` (from `@ai-sdk/provider`). The actual TypeScript interfaces in `packages/ai/src/agent/durable-agent.ts` use `string | (() => Promise<CompatibleLanguageModel>)` which resolves to `LanguageModelV3`.

However, the documentation in `docs/content/docs/ai/message-queueing.mdx` at lines 36 and 43 still referenced `LanguageModelV2` for the `model` field in both `PrepareStepInfo` and `PrepareStepResult`. This is inconsistent because:
1. The `messages` fields in the same interfaces were correctly updated to `LanguageModelV3Prompt`
2. The actual source code uses `LanguageModelV3` via `CompatibleLanguageModel`
3. There is no `LanguageModelV2` type anywhere in the codebase

This would mislead developers reading the documentation into using the wrong type.

**Fix explanation:**

Changed both `LanguageModelV2` references to `LanguageModelV3` on lines 36 and 43 of the documentation file, matching the actual codebase types. Verified no other stale `LanguageModelV2` references remain in the docs directory.

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: pranaygp <pranay.gp@gmail.com>

* Fix instructions tests: flip from it.fails to it, update snapshots

The 3 instructions tests (string, SystemModelMessage, array) now pass.
The snapshots include the assistant reply message from the agent loop,
which is a behavioral difference from ToolLoopAgent (DurableAgent
captures the prompt after the full loop iteration).

Score: 14 passing / 22 it.fails (was 11/25)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix getReadable call: pass startIndex as options object

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix docs type errors and turbopack build

Docs:
- Add await to convertToModelMessages() calls (now async in AI SDK v6)
- Change LanguageModelV3Prompt → ModelMessage[] in type references
- Change LanguageModelV3 → LanguageModel in PrepareStepInfo
- Update docs-globals.d.ts convertToModelMessages return type
- Add LanguageModel to import inference map

DurableAgent:
- Update OutputSpecification to match AI SDK v6 Output interface
  (type→name, parsePartial→parsePartialOutput, parseOutput→parseCompleteOutput,
  responseFormat now PromiseLike)

Turbopack build:
- Remove streamdown plugins from MessageResponse (plugins prop API
  changed in streamdown 2.4.0, causing type mismatch in CI)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix pnpm-workspace.yaml: use double quotes for catalog entries

The stage-workbench-with-tarballs.mjs script only strips double quotes
when parsing catalog keys. Single-quoted @-scoped entries (e.g.,
'@types/node') weren't matched, causing "unresolved catalog dependencies"
errors in CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix CI build failures, add changeset

- Remove streamdown plugins from reasoning.tsx (same CI type mismatch)
- Cast ToolHeader type prop and WorkflowChatTransport to fix type errors
- Fix pnpm-workspace.yaml single→double quotes for staging script
- Add minor changeset for @workflow/ai (breaking: AI SDK v6 migration)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Sync webpack workbench with turbopack: add chat UI deps and symlinks

- Symlink app-shell.tsx, chat-client.tsx, agent_chat workflow,
  chat API routes into nextjs-webpack
- Add matching deps: streamdown, @streamdown/*, shiki, cmdk, nanoid,
  motion, @radix-ui/react-use-controllable-state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix circular symlink: restore chat-client.tsx as real file in turbopack

The previous commit accidentally converted turbopack's chat-client.tsx
into a circular symlink pointing to itself. Webpack's symlink to it
then couldn't resolve, breaking both builds on Vercel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add missing deps to webpack: use-stick-to-bottom, radix-ui, @vercel/blob

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix model picker: merge body params via prepareSendMessagesRequest

WorkflowChatTransport sends { messages } by default, ignoring the
body option from ChatRequestOptions. Use prepareSendMessagesRequest
to merge { messages, ...body } so the model selection reaches the API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix WorkflowChatTransport: forward body/headers from ChatRequestOptions

The transport hardcoded body: undefined when calling
prepareSendMessagesRequest, so extra body params (like model selection)
from sendMessage({ body: { model } }) were silently dropped.

Now forwards options.body and options.headers to both
prepareSendMessagesRequest and the default request body.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix model IDs: use real AI Gateway model names

gpt-5.2 and gpt-5.3 don't exist in the AI Gateway.
Replace with gpt-4o and gpt-4o-mini.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Use correct AI Gateway model IDs: Opus 4.5, GPT-5.2, GPT-5.3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Enable reasoning for all model providers

- Anthropic: thinking.type='enabled' with 10k token budget
- OpenAI: reasoningEffort='high'
- Instructions kept for all models (no longer conditionally removed)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix OpenAI reasoning: use 'medium' effort (GPT-5.3 doesn't support 'high')

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Address all PR review comments

- Change changeset from minor to patch (repo convention)
- Use ?? instead of || for system/instructions fallback
- Clean up timeout: store ID, clearTimeout in finally, { once: true } listeners
- Update class docstring example to use instructions
- Map unrecognized finish reasons to 'other' with validation
- Fix duplicate test, align assertion for unrecognized type
- Support ^ exponentiation in calculate tool
- Remove debug console.log from chat client
- Fix ReactNode/ComponentProps type imports in UI components
- Remove unused MockLanguageModelV3 re-exports from mock.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Remove accidentally created empty mock2.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Change changeset back to minor for breaking AI SDK v6 migration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix agent e2e tests: add Vercel world setup for CI

The agent e2e tests only configured the local filesystem world but not
the Vercel world backend. On CI (Vercel prod tests), this caused
VercelOidcTokenError because the world wasn't initialized.

Now matches the setup pattern from e2e.test.ts: configures Vercel world
with OIDC token and project config from CI environment variables.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Deduplicate e2e test utilities: extract shared code to utils.ts

Extract manifest fetching, workflow lookup, world setup, and types
into shared utils.ts. Both e2e.test.ts and e2e-agent.test.ts now
import from the same source, eliminating ~200 lines of duplication.

Shared utilities:
- WorkflowManifest interface
- fetchManifest() with caching
- getWorkflowMetadata() with retry and fallback
- setupWorld() handling local/Vercel/Postgres backends

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix missing deploymentUrl args in e2e.test.ts after utils refactor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Add @workflow/ai dep to all workbenches for agent e2e tests

All workbenches now have @workflow/ai as a dependency and the
100_durable_agent_e2e.ts symlink, so agent e2e tests run everywhere.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Fix missing imports in e2e.test.ts: add fetchManifest and sleep

The utils refactor removed these imports but they're still used:
- fetchManifest: used in stepFunctionAsStartArgWorkflow test
- sleep (setTimeout): used in webhookWorkflow test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
2026-03-13 15:20:06 -07:00

1464 lines
38 KiB
TypeScript

'use client';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card';
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from '@/components/ui/input-group';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Spinner } from '@/components/ui/spinner';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import type { ChatStatus, FileUIPart, SourceDocumentUIPart } from 'ai';
import {
CornerDownLeftIcon,
ImageIcon,
Monitor,
PlusIcon,
SquareIcon,
XIcon,
} from 'lucide-react';
import { nanoid } from 'nanoid';
import type {
ChangeEvent,
ChangeEventHandler,
ClipboardEventHandler,
ComponentProps,
FormEvent,
FormEventHandler,
HTMLAttributes,
KeyboardEventHandler,
PropsWithChildren,
ReactNode,
RefObject,
} from 'react';
import {
Children,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
// ============================================================================
// Helpers
// ============================================================================
const convertBlobUrlToDataUrl = async (url: string): Promise<string | null> => {
try {
const response = await fetch(url);
const blob = await response.blob();
// FileReader uses callback-based API, wrapping in Promise is necessary
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
return new Promise((resolve) => {
const reader = new FileReader();
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
reader.onloadend = () => resolve(reader.result as string);
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
reader.onerror = () => resolve(null);
reader.readAsDataURL(blob);
});
} catch {
return null;
}
};
const captureScreenshot = async (): Promise<File | null> => {
if (
typeof navigator === 'undefined' ||
!navigator.mediaDevices?.getDisplayMedia
) {
return null;
}
let stream: MediaStream | null = null;
const video = document.createElement('video');
video.muted = true;
video.playsInline = true;
try {
stream = await navigator.mediaDevices.getDisplayMedia({
audio: false,
video: true,
});
video.srcObject = stream;
// Video element uses callback-based API, wrapping in Promise is necessary
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
await new Promise<void>((resolve, reject) => {
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
video.onloadedmetadata = () => resolve();
// oxlint-disable-next-line eslint-plugin-unicorn(prefer-add-event-listener)
video.onerror = () => reject(new Error('Failed to load screen stream'));
});
await video.play();
const width = video.videoWidth;
const height = video.videoHeight;
if (!width || !height) {
return null;
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
if (!context) {
return null;
}
context.drawImage(video, 0, 0, width, height);
// canvas.toBlob uses callback-based API, wrapping in Promise is necessary
// oxlint-disable-next-line eslint-plugin-promise(avoid-new)
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob(resolve, 'image/png');
});
if (!blob) {
return null;
}
const timestamp = new Date()
.toISOString()
.replaceAll(/[:.]/g, '-')
.replace('T', '_')
.replace('Z', '');
return new File([blob], `screenshot-${timestamp}.png`, {
lastModified: Date.now(),
type: 'image/png',
});
} finally {
if (stream) {
for (const track of stream.getTracks()) {
track.stop();
}
}
video.pause();
video.srcObject = null;
}
};
// ============================================================================
// Provider Context & Types
// ============================================================================
export interface AttachmentsContext {
files: (FileUIPart & { id: string })[];
add: (files: File[] | FileList) => void;
remove: (id: string) => void;
clear: () => void;
openFileDialog: () => void;
fileInputRef: RefObject<HTMLInputElement | null>;
}
export interface TextInputContext {
value: string;
setInput: (v: string) => void;
clear: () => void;
}
export interface PromptInputControllerProps {
textInput: TextInputContext;
attachments: AttachmentsContext;
/** INTERNAL: Allows PromptInput to register its file textInput + "open" callback */
__registerFileInput: (
ref: RefObject<HTMLInputElement | null>,
open: () => void
) => void;
}
const PromptInputController = createContext<PromptInputControllerProps | null>(
null
);
const ProviderAttachmentsContext = createContext<AttachmentsContext | null>(
null
);
export const usePromptInputController = () => {
const ctx = useContext(PromptInputController);
if (!ctx) {
throw new Error(
'Wrap your component inside <PromptInputProvider> to use usePromptInputController().'
);
}
return ctx;
};
// Optional variants (do NOT throw). Useful for dual-mode components.
const useOptionalPromptInputController = () =>
useContext(PromptInputController);
export const useProviderAttachments = () => {
const ctx = useContext(ProviderAttachmentsContext);
if (!ctx) {
throw new Error(
'Wrap your component inside <PromptInputProvider> to use useProviderAttachments().'
);
}
return ctx;
};
const useOptionalProviderAttachments = () =>
useContext(ProviderAttachmentsContext);
export type PromptInputProviderProps = PropsWithChildren<{
initialInput?: string;
}>;
/**
* Optional global provider that lifts PromptInput state outside of PromptInput.
* If you don't use it, PromptInput stays fully self-managed.
*/
export const PromptInputProvider = ({
initialInput: initialTextInput = '',
children,
}: PromptInputProviderProps) => {
// ----- textInput state
const [textInput, setTextInput] = useState(initialTextInput);
const clearInput = useCallback(() => setTextInput(''), []);
// ----- attachments state (global when wrapped)
const [attachmentFiles, setAttachmentFiles] = useState<
(FileUIPart & { id: string })[]
>([]);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// oxlint-disable-next-line eslint(no-empty-function)
const openRef = useRef<() => void>(() => {});
const add = useCallback((files: File[] | FileList) => {
const incoming = [...files];
if (incoming.length === 0) {
return;
}
setAttachmentFiles((prev) => [
...prev,
...incoming.map((file) => ({
filename: file.name,
id: nanoid(),
mediaType: file.type,
type: 'file' as const,
url: URL.createObjectURL(file),
})),
]);
}, []);
const remove = useCallback((id: string) => {
setAttachmentFiles((prev) => {
const found = prev.find((f) => f.id === id);
if (found?.url) {
URL.revokeObjectURL(found.url);
}
return prev.filter((f) => f.id !== id);
});
}, []);
const clear = useCallback(() => {
setAttachmentFiles((prev) => {
for (const f of prev) {
if (f.url) {
URL.revokeObjectURL(f.url);
}
}
return [];
});
}, []);
// Keep a ref to attachments for cleanup on unmount (avoids stale closure)
const attachmentsRef = useRef(attachmentFiles);
useEffect(() => {
attachmentsRef.current = attachmentFiles;
}, [attachmentFiles]);
// Cleanup blob URLs on unmount to prevent memory leaks
useEffect(
() => () => {
for (const f of attachmentsRef.current) {
if (f.url) {
URL.revokeObjectURL(f.url);
}
}
},
[]
);
const openFileDialog = useCallback(() => {
openRef.current?.();
}, []);
const attachments = useMemo<AttachmentsContext>(
() => ({
add,
clear,
fileInputRef,
files: attachmentFiles,
openFileDialog,
remove,
}),
[attachmentFiles, add, remove, clear, openFileDialog]
);
const __registerFileInput = useCallback(
(ref: RefObject<HTMLInputElement | null>, open: () => void) => {
fileInputRef.current = ref.current;
openRef.current = open;
},
[]
);
const controller = useMemo<PromptInputControllerProps>(
() => ({
__registerFileInput,
attachments,
textInput: {
clear: clearInput,
setInput: setTextInput,
value: textInput,
},
}),
[textInput, clearInput, attachments, __registerFileInput]
);
return (
<PromptInputController.Provider value={controller}>
<ProviderAttachmentsContext.Provider value={attachments}>
{children}
</ProviderAttachmentsContext.Provider>
</PromptInputController.Provider>
);
};
// ============================================================================
// Component Context & Hooks
// ============================================================================
const LocalAttachmentsContext = createContext<AttachmentsContext | null>(null);
export const usePromptInputAttachments = () => {
// Prefer local context (inside PromptInput) as it has validation, fall back to provider
const provider = useOptionalProviderAttachments();
const local = useContext(LocalAttachmentsContext);
const context = local ?? provider;
if (!context) {
throw new Error(
'usePromptInputAttachments must be used within a PromptInput or PromptInputProvider'
);
}
return context;
};
// ============================================================================
// Referenced Sources (Local to PromptInput)
// ============================================================================
export interface ReferencedSourcesContext {
sources: (SourceDocumentUIPart & { id: string })[];
add: (sources: SourceDocumentUIPart[] | SourceDocumentUIPart) => void;
remove: (id: string) => void;
clear: () => void;
}
export const LocalReferencedSourcesContext =
createContext<ReferencedSourcesContext | null>(null);
export const usePromptInputReferencedSources = () => {
const ctx = useContext(LocalReferencedSourcesContext);
if (!ctx) {
throw new Error(
'usePromptInputReferencedSources must be used within a LocalReferencedSourcesContext.Provider'
);
}
return ctx;
};
export type PromptInputActionAddAttachmentsProps = ComponentProps<
typeof DropdownMenuItem
> & {
label?: string;
};
export const PromptInputActionAddAttachments = ({
label = 'Add photos or files',
...props
}: PromptInputActionAddAttachmentsProps) => {
const attachments = usePromptInputAttachments();
const handleSelect = useCallback(
(e: Event) => {
e.preventDefault();
attachments.openFileDialog();
},
[attachments]
);
return (
<DropdownMenuItem {...props} onSelect={handleSelect}>
<ImageIcon className="mr-2 size-4" /> {label}
</DropdownMenuItem>
);
};
export type PromptInputActionAddScreenshotProps = ComponentProps<
typeof DropdownMenuItem
> & {
label?: string;
};
export const PromptInputActionAddScreenshot = ({
label = 'Take screenshot',
onSelect,
...props
}: PromptInputActionAddScreenshotProps) => {
const attachments = usePromptInputAttachments();
const handleSelect = useCallback(
async (event: Event) => {
onSelect?.(event);
if (event.defaultPrevented) {
return;
}
try {
const screenshot = await captureScreenshot();
if (screenshot) {
attachments.add([screenshot]);
}
} catch (error) {
if (
error instanceof DOMException &&
(error.name === 'NotAllowedError' || error.name === 'AbortError')
) {
return;
}
throw error;
}
},
[onSelect, attachments]
);
return (
<DropdownMenuItem {...props} onSelect={handleSelect}>
<Monitor className="mr-2 size-4" />
{label}
</DropdownMenuItem>
);
};
export interface PromptInputMessage {
text: string;
files: FileUIPart[];
}
export type PromptInputProps = Omit<
HTMLAttributes<HTMLFormElement>,
'onSubmit' | 'onError'
> & {
// e.g., "image/*" or leave undefined for any
accept?: string;
multiple?: boolean;
// When true, accepts drops anywhere on document. Default false (opt-in).
globalDrop?: boolean;
// Render a hidden input with given name and keep it in sync for native form posts. Default false.
syncHiddenInput?: boolean;
// Minimal constraints
maxFiles?: number;
// bytes
maxFileSize?: number;
onError?: (err: {
code: 'max_files' | 'max_file_size' | 'accept';
message: string;
}) => void;
onSubmit: (
message: PromptInputMessage,
event: FormEvent<HTMLFormElement>
) => void | Promise<void>;
};
export const PromptInput = ({
className,
accept,
multiple,
globalDrop,
syncHiddenInput,
maxFiles,
maxFileSize,
onError,
onSubmit,
children,
...props
}: PromptInputProps) => {
// Try to use a provider controller if present
const controller = useOptionalPromptInputController();
const usingProvider = !!controller;
// Refs
const inputRef = useRef<HTMLInputElement | null>(null);
const formRef = useRef<HTMLFormElement | null>(null);
// ----- Local attachments (only used when no provider)
const [items, setItems] = useState<(FileUIPart & { id: string })[]>([]);
const files = usingProvider ? controller.attachments.files : items;
// ----- Local referenced sources (always local to PromptInput)
const [referencedSources, setReferencedSources] = useState<
(SourceDocumentUIPart & { id: string })[]
>([]);
// Keep a ref to files for cleanup on unmount (avoids stale closure)
const filesRef = useRef(files);
useEffect(() => {
filesRef.current = files;
}, [files]);
const openFileDialogLocal = useCallback(() => {
inputRef.current?.click();
}, []);
const matchesAccept = useCallback(
(f: File) => {
if (!accept || accept.trim() === '') {
return true;
}
const patterns = accept
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return patterns.some((pattern) => {
if (pattern.endsWith('/*')) {
// e.g: image/* -> image/
const prefix = pattern.slice(0, -1);
return f.type.startsWith(prefix);
}
return f.type === pattern;
});
},
[accept]
);
const addLocal = useCallback(
(fileList: File[] | FileList) => {
const incoming = [...fileList];
const accepted = incoming.filter((f) => matchesAccept(f));
if (incoming.length && accepted.length === 0) {
onError?.({
code: 'accept',
message: 'No files match the accepted types.',
});
return;
}
const withinSize = (f: File) =>
maxFileSize ? f.size <= maxFileSize : true;
const sized = accepted.filter(withinSize);
if (accepted.length > 0 && sized.length === 0) {
onError?.({
code: 'max_file_size',
message: 'All files exceed the maximum size.',
});
return;
}
setItems((prev) => {
const capacity =
typeof maxFiles === 'number'
? Math.max(0, maxFiles - prev.length)
: undefined;
const capped =
typeof capacity === 'number' ? sized.slice(0, capacity) : sized;
if (typeof capacity === 'number' && sized.length > capacity) {
onError?.({
code: 'max_files',
message: 'Too many files. Some were not added.',
});
}
const next: (FileUIPart & { id: string })[] = [];
for (const file of capped) {
next.push({
filename: file.name,
id: nanoid(),
mediaType: file.type,
type: 'file',
url: URL.createObjectURL(file),
});
}
return [...prev, ...next];
});
},
[matchesAccept, maxFiles, maxFileSize, onError]
);
const removeLocal = useCallback(
(id: string) =>
setItems((prev) => {
const found = prev.find((file) => file.id === id);
if (found?.url) {
URL.revokeObjectURL(found.url);
}
return prev.filter((file) => file.id !== id);
}),
[]
);
// Wrapper that validates files before calling provider's add
const addWithProviderValidation = useCallback(
(fileList: File[] | FileList) => {
const incoming = [...fileList];
const accepted = incoming.filter((f) => matchesAccept(f));
if (incoming.length && accepted.length === 0) {
onError?.({
code: 'accept',
message: 'No files match the accepted types.',
});
return;
}
const withinSize = (f: File) =>
maxFileSize ? f.size <= maxFileSize : true;
const sized = accepted.filter(withinSize);
if (accepted.length > 0 && sized.length === 0) {
onError?.({
code: 'max_file_size',
message: 'All files exceed the maximum size.',
});
return;
}
const currentCount = files.length;
const capacity =
typeof maxFiles === 'number'
? Math.max(0, maxFiles - currentCount)
: undefined;
const capped =
typeof capacity === 'number' ? sized.slice(0, capacity) : sized;
if (typeof capacity === 'number' && sized.length > capacity) {
onError?.({
code: 'max_files',
message: 'Too many files. Some were not added.',
});
}
if (capped.length > 0) {
controller?.attachments.add(capped);
}
},
[matchesAccept, maxFileSize, maxFiles, onError, files.length, controller]
);
const clearAttachments = useCallback(
() =>
usingProvider
? controller?.attachments.clear()
: setItems((prev) => {
for (const file of prev) {
if (file.url) {
URL.revokeObjectURL(file.url);
}
}
return [];
}),
[usingProvider, controller]
);
const clearReferencedSources = useCallback(
() => setReferencedSources([]),
[]
);
const add = usingProvider ? addWithProviderValidation : addLocal;
const remove = usingProvider ? controller.attachments.remove : removeLocal;
const openFileDialog = usingProvider
? controller.attachments.openFileDialog
: openFileDialogLocal;
const clear = useCallback(() => {
clearAttachments();
clearReferencedSources();
}, [clearAttachments, clearReferencedSources]);
// Let provider know about our hidden file input so external menus can call openFileDialog()
useEffect(() => {
if (!usingProvider) {
return;
}
controller.__registerFileInput(inputRef, () => inputRef.current?.click());
}, [usingProvider, controller]);
// Note: File input cannot be programmatically set for security reasons
// The syncHiddenInput prop is no longer functional
useEffect(() => {
if (syncHiddenInput && inputRef.current && files.length === 0) {
inputRef.current.value = '';
}
}, [files, syncHiddenInput]);
// Attach drop handlers on nearest form and document (opt-in)
useEffect(() => {
const form = formRef.current;
if (!form) {
return;
}
if (globalDrop) {
// when global drop is on, let the document-level handler own drops
return;
}
const onDragOver = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes('Files')) {
e.preventDefault();
}
};
const onDrop = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes('Files')) {
e.preventDefault();
}
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
add(e.dataTransfer.files);
}
};
form.addEventListener('dragover', onDragOver);
form.addEventListener('drop', onDrop);
return () => {
form.removeEventListener('dragover', onDragOver);
form.removeEventListener('drop', onDrop);
};
}, [add, globalDrop]);
useEffect(() => {
if (!globalDrop) {
return;
}
const onDragOver = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes('Files')) {
e.preventDefault();
}
};
const onDrop = (e: DragEvent) => {
if (e.dataTransfer?.types?.includes('Files')) {
e.preventDefault();
}
if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) {
add(e.dataTransfer.files);
}
};
document.addEventListener('dragover', onDragOver);
document.addEventListener('drop', onDrop);
return () => {
document.removeEventListener('dragover', onDragOver);
document.removeEventListener('drop', onDrop);
};
}, [add, globalDrop]);
useEffect(
() => () => {
if (!usingProvider) {
for (const f of filesRef.current) {
if (f.url) {
URL.revokeObjectURL(f.url);
}
}
}
},
[usingProvider]
);
const handleChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
if (event.currentTarget.files) {
add(event.currentTarget.files);
}
// Reset input value to allow selecting files that were previously removed
event.currentTarget.value = '';
},
[add]
);
const attachmentsCtx = useMemo<AttachmentsContext>(
() => ({
add,
clear: clearAttachments,
fileInputRef: inputRef,
files: files.map((item) => ({ ...item, id: item.id })),
openFileDialog,
remove,
}),
[files, add, remove, clearAttachments, openFileDialog]
);
const refsCtx = useMemo<ReferencedSourcesContext>(
() => ({
add: (incoming: SourceDocumentUIPart[] | SourceDocumentUIPart) => {
const array = Array.isArray(incoming) ? incoming : [incoming];
setReferencedSources((prev) => [
...prev,
...array.map((s) => ({ ...s, id: nanoid() })),
]);
},
clear: clearReferencedSources,
remove: (id: string) => {
setReferencedSources((prev) => prev.filter((s) => s.id !== id));
},
sources: referencedSources,
}),
[referencedSources, clearReferencedSources]
);
const handleSubmit: FormEventHandler<HTMLFormElement> = useCallback(
async (event) => {
event.preventDefault();
const form = event.currentTarget;
const text = usingProvider
? controller.textInput.value
: (() => {
const formData = new FormData(form);
return (formData.get('message') as string) || '';
})();
// Reset form immediately after capturing text to avoid race condition
// where user input during async blob conversion would be lost
if (!usingProvider) {
form.reset();
}
try {
// Convert blob URLs to data URLs asynchronously
const convertedFiles: FileUIPart[] = await Promise.all(
files.map(async ({ id: _id, ...item }) => {
if (item.url?.startsWith('blob:')) {
const dataUrl = await convertBlobUrlToDataUrl(item.url);
// If conversion failed, keep the original blob URL
return {
...item,
url: dataUrl ?? item.url,
};
}
return item;
})
);
const result = onSubmit({ files: convertedFiles, text }, event);
// Handle both sync and async onSubmit
if (result instanceof Promise) {
try {
await result;
clear();
if (usingProvider) {
controller.textInput.clear();
}
} catch {
// Don't clear on error - user may want to retry
}
} else {
// Sync function completed without throwing, clear inputs
clear();
if (usingProvider) {
controller.textInput.clear();
}
}
} catch {
// Don't clear on error - user may want to retry
}
},
[usingProvider, controller, files, onSubmit, clear]
);
// Render with or without local provider
const inner = (
<>
<input
accept={accept}
aria-label="Upload files"
className="hidden"
multiple={multiple}
onChange={handleChange}
ref={inputRef}
title="Upload files"
type="file"
/>
<form
className={cn('w-full', className)}
onSubmit={handleSubmit}
ref={formRef}
{...props}
>
<InputGroup className="overflow-hidden">{children}</InputGroup>
</form>
</>
);
const withReferencedSources = (
<LocalReferencedSourcesContext.Provider value={refsCtx}>
{inner}
</LocalReferencedSourcesContext.Provider>
);
// Always provide LocalAttachmentsContext so children get validated add function
return (
<LocalAttachmentsContext.Provider value={attachmentsCtx}>
{withReferencedSources}
</LocalAttachmentsContext.Provider>
);
};
export type PromptInputBodyProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputBody = ({
className,
...props
}: PromptInputBodyProps) => (
<div className={cn('contents', className)} {...props} />
);
export type PromptInputTextareaProps = ComponentProps<
typeof InputGroupTextarea
>;
export const PromptInputTextarea = ({
onChange,
onKeyDown,
className,
placeholder = 'What would you like to know?',
...props
}: PromptInputTextareaProps) => {
const controller = useOptionalPromptInputController();
const attachments = usePromptInputAttachments();
const [isComposing, setIsComposing] = useState(false);
const handleKeyDown: KeyboardEventHandler<HTMLTextAreaElement> = useCallback(
(e) => {
// Call the external onKeyDown handler first
onKeyDown?.(e);
// If the external handler prevented default, don't run internal logic
if (e.defaultPrevented) {
return;
}
if (e.key === 'Enter') {
if (isComposing || e.nativeEvent.isComposing) {
return;
}
if (e.shiftKey) {
return;
}
e.preventDefault();
// Check if the submit button is disabled before submitting
const { form } = e.currentTarget;
const submitButton = form?.querySelector(
'button[type="submit"]'
) as HTMLButtonElement | null;
if (submitButton?.disabled) {
return;
}
form?.requestSubmit();
}
// Remove last attachment when Backspace is pressed and textarea is empty
if (
e.key === 'Backspace' &&
e.currentTarget.value === '' &&
attachments.files.length > 0
) {
e.preventDefault();
const lastAttachment = attachments.files.at(-1);
if (lastAttachment) {
attachments.remove(lastAttachment.id);
}
}
},
[onKeyDown, isComposing, attachments]
);
const handlePaste: ClipboardEventHandler<HTMLTextAreaElement> = useCallback(
(event) => {
const items = event.clipboardData?.items;
if (!items) {
return;
}
const files: File[] = [];
for (const item of items) {
if (item.kind === 'file') {
const file = item.getAsFile();
if (file) {
files.push(file);
}
}
}
if (files.length > 0) {
event.preventDefault();
attachments.add(files);
}
},
[attachments]
);
const handleCompositionEnd = useCallback(() => setIsComposing(false), []);
const handleCompositionStart = useCallback(() => setIsComposing(true), []);
const controlledProps = controller
? {
onChange: (e: ChangeEvent<HTMLTextAreaElement>) => {
controller.textInput.setInput(e.currentTarget.value);
onChange?.(e);
},
value: controller.textInput.value,
}
: {
onChange,
};
return (
<InputGroupTextarea
className={cn('field-sizing-content max-h-48 min-h-16', className)}
name="message"
onCompositionEnd={handleCompositionEnd}
onCompositionStart={handleCompositionStart}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
placeholder={placeholder}
{...props}
{...controlledProps}
/>
);
};
export type PromptInputHeaderProps = Omit<
ComponentProps<typeof InputGroupAddon>,
'align'
>;
export const PromptInputHeader = ({
className,
...props
}: PromptInputHeaderProps) => (
<InputGroupAddon
align="block-end"
className={cn('order-first flex-wrap gap-1', className)}
{...props}
/>
);
export type PromptInputFooterProps = Omit<
ComponentProps<typeof InputGroupAddon>,
'align'
>;
export const PromptInputFooter = ({
className,
...props
}: PromptInputFooterProps) => (
<InputGroupAddon
align="block-end"
className={cn('justify-between gap-1', className)}
{...props}
/>
);
export type PromptInputToolsProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTools = ({
className,
...props
}: PromptInputToolsProps) => (
<div
className={cn('flex min-w-0 items-center gap-1', className)}
{...props}
/>
);
export type PromptInputButtonTooltip =
| string
| {
content: ReactNode;
shortcut?: string;
side?: ComponentProps<typeof TooltipContent>['side'];
};
export type PromptInputButtonProps = ComponentProps<typeof InputGroupButton> & {
tooltip?: PromptInputButtonTooltip;
};
export const PromptInputButton = ({
variant = 'ghost',
className,
size,
tooltip,
...props
}: PromptInputButtonProps) => {
const newSize =
size ?? (Children.count(props.children) > 1 ? 'sm' : 'icon-sm');
const button = (
<InputGroupButton
className={cn(className)}
size={newSize}
type="button"
variant={variant}
{...props}
/>
);
if (!tooltip) {
return button;
}
const tooltipContent =
typeof tooltip === 'string' ? tooltip : tooltip.content;
const shortcut = typeof tooltip === 'string' ? undefined : tooltip.shortcut;
const side = typeof tooltip === 'string' ? 'top' : (tooltip.side ?? 'top');
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side={side}>
{tooltipContent}
{shortcut && (
<span className="ml-2 text-muted-foreground">{shortcut}</span>
)}
</TooltipContent>
</Tooltip>
);
};
export type PromptInputActionMenuProps = ComponentProps<typeof DropdownMenu>;
export const PromptInputActionMenu = (props: PromptInputActionMenuProps) => (
<DropdownMenu {...props} />
);
export type PromptInputActionMenuTriggerProps = PromptInputButtonProps;
export const PromptInputActionMenuTrigger = ({
className,
children,
...props
}: PromptInputActionMenuTriggerProps) => (
<DropdownMenuTrigger asChild>
<PromptInputButton className={className} {...props}>
{children ?? <PlusIcon className="size-4" />}
</PromptInputButton>
</DropdownMenuTrigger>
);
export type PromptInputActionMenuContentProps = ComponentProps<
typeof DropdownMenuContent
>;
export const PromptInputActionMenuContent = ({
className,
...props
}: PromptInputActionMenuContentProps) => (
<DropdownMenuContent align="start" className={cn(className)} {...props} />
);
export type PromptInputActionMenuItemProps = ComponentProps<
typeof DropdownMenuItem
>;
export const PromptInputActionMenuItem = ({
className,
...props
}: PromptInputActionMenuItemProps) => (
<DropdownMenuItem className={cn(className)} {...props} />
);
// Note: Actions that perform side-effects (like opening a file dialog)
// are provided in opt-in modules (e.g., prompt-input-attachments).
export type PromptInputSubmitProps = ComponentProps<typeof InputGroupButton> & {
status?: ChatStatus;
onStop?: () => void;
};
export const PromptInputSubmit = ({
className,
variant = 'default',
size = 'icon-sm',
status,
onStop,
onClick,
children,
...props
}: PromptInputSubmitProps) => {
const isGenerating = status === 'submitted' || status === 'streaming';
let Icon = <CornerDownLeftIcon className="size-4" />;
if (status === 'submitted') {
Icon = <Spinner />;
} else if (status === 'streaming') {
Icon = <SquareIcon className="size-4" />;
} else if (status === 'error') {
Icon = <XIcon className="size-4" />;
}
const handleClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
if (isGenerating && onStop) {
e.preventDefault();
onStop();
return;
}
onClick?.(e);
},
[isGenerating, onStop, onClick]
);
return (
<InputGroupButton
aria-label={isGenerating ? 'Stop' : 'Submit'}
className={cn(className)}
onClick={handleClick}
size={size}
type={isGenerating && onStop ? 'button' : 'submit'}
variant={variant}
{...props}
>
{children ?? Icon}
</InputGroupButton>
);
};
export type PromptInputSelectProps = ComponentProps<typeof Select>;
export const PromptInputSelect = (props: PromptInputSelectProps) => (
<Select {...props} />
);
export type PromptInputSelectTriggerProps = ComponentProps<
typeof SelectTrigger
>;
export const PromptInputSelectTrigger = ({
className,
...props
}: PromptInputSelectTriggerProps) => (
<SelectTrigger
className={cn(
'border-none bg-transparent font-medium text-muted-foreground shadow-none transition-colors',
'hover:bg-accent hover:text-foreground aria-expanded:bg-accent aria-expanded:text-foreground',
className
)}
{...props}
/>
);
export type PromptInputSelectContentProps = ComponentProps<
typeof SelectContent
>;
export const PromptInputSelectContent = ({
className,
...props
}: PromptInputSelectContentProps) => (
<SelectContent className={cn(className)} {...props} />
);
export type PromptInputSelectItemProps = ComponentProps<typeof SelectItem>;
export const PromptInputSelectItem = ({
className,
...props
}: PromptInputSelectItemProps) => (
<SelectItem className={cn(className)} {...props} />
);
export type PromptInputSelectValueProps = ComponentProps<typeof SelectValue>;
export const PromptInputSelectValue = ({
className,
...props
}: PromptInputSelectValueProps) => (
<SelectValue className={cn(className)} {...props} />
);
export type PromptInputHoverCardProps = ComponentProps<typeof HoverCard>;
export const PromptInputHoverCard = ({
openDelay = 0,
closeDelay = 0,
...props
}: PromptInputHoverCardProps) => (
<HoverCard closeDelay={closeDelay} openDelay={openDelay} {...props} />
);
export type PromptInputHoverCardTriggerProps = ComponentProps<
typeof HoverCardTrigger
>;
export const PromptInputHoverCardTrigger = (
props: PromptInputHoverCardTriggerProps
) => <HoverCardTrigger {...props} />;
export type PromptInputHoverCardContentProps = ComponentProps<
typeof HoverCardContent
>;
export const PromptInputHoverCardContent = ({
align = 'start',
...props
}: PromptInputHoverCardContentProps) => (
<HoverCardContent align={align} {...props} />
);
export type PromptInputTabsListProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTabsList = ({
className,
...props
}: PromptInputTabsListProps) => <div className={cn(className)} {...props} />;
export type PromptInputTabProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTab = ({
className,
...props
}: PromptInputTabProps) => <div className={cn(className)} {...props} />;
export type PromptInputTabLabelProps = HTMLAttributes<HTMLHeadingElement>;
export const PromptInputTabLabel = ({
className,
...props
}: PromptInputTabLabelProps) => (
// Content provided via children in props
// oxlint-disable-next-line eslint-plugin-jsx-a11y(heading-has-content)
<h3
className={cn(
'mb-2 px-3 font-medium text-muted-foreground text-xs',
className
)}
{...props}
/>
);
export type PromptInputTabBodyProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTabBody = ({
className,
...props
}: PromptInputTabBodyProps) => (
<div className={cn('space-y-1', className)} {...props} />
);
export type PromptInputTabItemProps = HTMLAttributes<HTMLDivElement>;
export const PromptInputTabItem = ({
className,
...props
}: PromptInputTabItemProps) => (
<div
className={cn(
'flex items-center gap-2 px-3 py-2 text-xs hover:bg-accent',
className
)}
{...props}
/>
);
export type PromptInputCommandProps = ComponentProps<typeof Command>;
export const PromptInputCommand = ({
className,
...props
}: PromptInputCommandProps) => <Command className={cn(className)} {...props} />;
export type PromptInputCommandInputProps = ComponentProps<typeof CommandInput>;
export const PromptInputCommandInput = ({
className,
...props
}: PromptInputCommandInputProps) => (
<CommandInput className={cn(className)} {...props} />
);
export type PromptInputCommandListProps = ComponentProps<typeof CommandList>;
export const PromptInputCommandList = ({
className,
...props
}: PromptInputCommandListProps) => (
<CommandList className={cn(className)} {...props} />
);
export type PromptInputCommandEmptyProps = ComponentProps<typeof CommandEmpty>;
export const PromptInputCommandEmpty = ({
className,
...props
}: PromptInputCommandEmptyProps) => (
<CommandEmpty className={cn(className)} {...props} />
);
export type PromptInputCommandGroupProps = ComponentProps<typeof CommandGroup>;
export const PromptInputCommandGroup = ({
className,
...props
}: PromptInputCommandGroupProps) => (
<CommandGroup className={cn(className)} {...props} />
);
export type PromptInputCommandItemProps = ComponentProps<typeof CommandItem>;
export const PromptInputCommandItem = ({
className,
...props
}: PromptInputCommandItemProps) => (
<CommandItem className={cn(className)} {...props} />
);
export type PromptInputCommandSeparatorProps = ComponentProps<
typeof CommandSeparator
>;
export const PromptInputCommandSeparator = ({
className,
...props
}: PromptInputCommandSeparatorProps) => (
<CommandSeparator className={cn(className)} {...props} />
);