Skip to content
edmi-ui

Chat Composer

Prompt input with the outside footer: attach, speech, disclaimer, model with effort, and mode.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
Edmi is AI and can make mistakes.
Edmi is AI and can make mistakes.
Edmi is AI and can make mistakes.

Edmi ✦ addition. Built on ai-prompt-input, button and dropdown-menu.

Terminal window
npx shadcn@latest add @edmi-ui/ai-chat-composer
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-chat-composer
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-chat-composer
Terminal window
bunx shadcn@latest add @edmi-ui/ai-chat-composer

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/ai-chat-composer
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-chat-composer
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-chat-composer
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-chat-composer

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-chat-composer.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-chat-composer.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-chat-composer.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-chat-composer.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

import { ChatComposer } from "@/components/ai/chat-composer";
<ChatComposer
className="max-w-2xl"
onSubmit={() => {}}
onAttach={() => {}}
onSpeech={() => {}}
disclaimer="Edmi is AI and can make mistakes."
models={[
{ id: "opus", label: "Opus" },
{ id: "sonnet", label: "Sonnet" },
]}
efforts={[
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High" },
]}
defaultEffort="medium"
modes={[
{ id: "auto", label: "Auto" },
{ id: "ask", label: "Ask first" },
]}
/>
<script setup lang="ts">
import { ChatComposer } from "@/components/ai/chat-composer";
</script>
<template>
<ChatComposer
class="max-w-2xl"
disclaimer="Edmi is AI and can make mistakes."
default-effort="medium"
@attach="() => {}"
@speech="() => {}"
/>
</template>
<script lang="ts">
import { ChatComposer } from "$lib/components/ai/chat-composer";
</script>
<ChatComposer
class="max-w-2xl"
onSubmit={() => {}}
onAttach={() => {}}
onSpeech={() => {}}
disclaimer="Edmi is AI and can make mistakes."
defaultEffort="medium"
/>
registry/components/ai/chat-composer.tsx
"use client";
import type { ChatStatus } from "ai";
import { cn } from "cn";
import type { ComponentProps, ReactNode } from "react";
import { useState } from "react";
import { PlusIcon, MicrophoneIcon, CaretDownIcon } from "@phosphor-icons/react";
import {
PromptInput,
PromptInputBody,
PromptInputSubmit,
PromptInputTextarea,
} from "@/registry/edmi/components/ai/prompt-input";
import { Button } from "@/registry/edmi/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/registry/edmi/ui/dropdown-menu";
import type { Elevation } from "@/registry/edmi/ui/elevation";
export type ChatComposerOption = { id: string; label: string };
export type ChatComposerProps = Omit<
ComponentProps<typeof PromptInput>,
"children" | "elevation"
> & {
/** ✦ depth of the composer plate (overlay role: floating in layered mode). */
elevation?: Elevation;
placeholder?: string;
status?: ChatStatus;
onStop?: () => void;
/** Plus button in the footer; hide the button by omitting it. */
onAttach?: () => void;
/** Mic button in the footer (speech input). Omit to hide it. */
onSpeech?: () => void;
/** Small print centered in the footer. */
disclaimer?: ReactNode;
models?: ChatComposerOption[];
/** Controlled model id; use `defaultModel` for uncontrolled. */
model?: string;
defaultModel?: string;
onModelChange?: (id: string) => void;
efforts?: ChatComposerOption[];
effort?: string;
defaultEffort?: string;
onEffortChange?: (id: string) => void;
modes?: ChatComposerOption[];
mode?: string;
defaultMode?: string;
onModeChange?: (id: string) => void;
};
const labelOf = (options: ChatComposerOption[] | undefined, id?: string) =>
options?.find((o) => o.id === id)?.label;
/**
* Prompt input with the quiet footer outside the field: attach, speech, disclaimer, model + effort, mode.
*/
export const ChatComposer = ({
placeholder = "Reply",
status,
onStop,
onAttach,
onSpeech,
disclaimer,
models,
model,
defaultModel,
onModelChange,
efforts,
effort,
defaultEffort,
onEffortChange,
modes,
mode,
defaultMode,
onModeChange,
elevation,
className,
...props
}: ChatComposerProps) => {
const [modelState, setModel] = useState(defaultModel ?? models?.[0]?.id);
const [effortState, setEffort] = useState(defaultEffort ?? efforts?.[0]?.id);
const [modeState, setMode] = useState(defaultMode ?? modes?.[0]?.id);
const modelId = model ?? modelState;
const effortId = effort ?? effortState;
const modeId = mode ?? modeState;
const ready = status === undefined || status === "ready";
return (
<div
data-slot="ai-chat-composer"
className={cn("flex w-full flex-col", className)}
>
<PromptInput elevation={elevation} {...props}>
<PromptInputBody>
<div className="flex w-full items-center pr-2">
<PromptInputTextarea
placeholder={placeholder}
rows={1}
className="min-h-12 px-4 py-3 text-[14.5px]"
/>
<PromptInputSubmit
status={status}
onStop={onStop}
variant={ready ? "ghost" : undefined}
className={ready ? "text-muted-foreground" : undefined}
>
{ready ? <span aria-hidden="true">↵</span> : undefined}
</PromptInputSubmit>
</div>
</PromptInputBody>
</PromptInput>
<div
data-slot="ai-chat-composer-footer"
className="flex items-center gap-0.5 px-1.5 pt-2 text-xs"
>
{onAttach && (
<Button
aria-label="Attach"
onClick={onAttach}
size="icon-xs"
type="button"
variant="ghost"
>
<PlusIcon
className="size-4"
/>
</Button>
)}
{onSpeech && (
<Button
aria-label="Speech input"
onClick={onSpeech}
size="xs"
type="button"
variant="ghost"
className="gap-0.5"
>
<MicrophoneIcon
className="size-4"
/>
<CaretDownIcon
className="size-3"
/>
</Button>
)}
<span className="min-w-0 flex-1 truncate text-center text-muted-foreground">
{disclaimer}
</span>
{models && models.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="xs"
type="button"
variant="ghost"
className="gap-[5px] font-medium"
/>
}
>
{labelOf(models, modelId)}
{efforts && (
<span className="font-normal text-muted-foreground">
{labelOf(efforts, effortId)}
</span>
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top">
<DropdownMenuGroup>
<DropdownMenuLabel>Model</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={modelId}
onValueChange={(v) => {
setModel(v as string);
onModelChange?.(v as string);
}}
>
{models.map((m) => (
<DropdownMenuRadioItem key={m.id} value={m.id}>
{m.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
{efforts && efforts.length > 0 && (
<>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Effort</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={effortId}
onValueChange={(v) => {
setEffort(v as string);
onEffortChange?.(v as string);
}}
>
{efforts.map((e) => (
<DropdownMenuRadioItem key={e.id} value={e.id}>
{e.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
{modes && modes.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
size="xs"
type="button"
variant="ghost"
className="text-muted-foreground"
/>
}
>
{labelOf(modes, modeId)}
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top">
<DropdownMenuGroup>
<DropdownMenuLabel>Mode</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={modeId}
onValueChange={(v) => {
setMode(v as string);
onModeChange?.(v as string);
}}
>
{modes.map((m) => (
<DropdownMenuRadioItem key={m.id} value={m.id}>
{m.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
);
};
registry/components/ai/chat-composer/ChatComposer.vue
<script setup lang="ts">
import type { ChatStatus } from "ai"
import type { HTMLAttributes } from "vue"
import type { PromptInputMessage } from "@/registry/edmi/components/ai/prompt-input"
import type { ChatComposerOption } from "./types"
import { AudioLinesIcon, ChevronDown, Plus } from "@lucide/vue"
import { useVModel } from "@vueuse/core"
import { computed, getCurrentInstance } from "vue"
import {
PromptInput,
PromptInputBody,
PromptInputSubmit,
PromptInputTextarea,
} from "@/registry/edmi/components/ai/prompt-input"
import { cn } from "@/registry/edmi/lib/utils"
import { Button } from "@/registry/edmi/ui/button"
import type { Elevation } from "@/registry/edmi/ui/elevation"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/registry/edmi/ui/dropdown-menu"
const props = withDefaults(defineProps<{
class?: HTMLAttributes["class"]
placeholder?: string
status?: ChatStatus
/** Small print centered in the footer (or use the `disclaimer` slot). */
disclaimer?: string
/** Same as PromptInput. */
accept?: string
multiple?: boolean
/** ✦ depth of the composer plate (overlay role: floating in layered mode). */
elevation?: Elevation
models?: ChatComposerOption[]
/** `v-model:model`; use `defaultModel` for uncontrolled. */
model?: string
defaultModel?: string
efforts?: ChatComposerOption[]
effort?: string
defaultEffort?: string
modes?: ChatComposerOption[]
mode?: string
defaultMode?: string
}>(), {
placeholder: "Reply",
elevation: undefined,
})
const emit = defineEmits<{
(e: "submit", message: PromptInputMessage): void
(e: "stop"): void
(e: "attach"): void
(e: "speech"): void
(e: "update:model", id: string): void
(e: "update:effort", id: string): void
(e: "update:mode", id: string): void
}>()
// The attach and speech buttons show when a listener is attached.
const vnodeProps = getCurrentInstance()?.vnode.props
const hasAttach = !!vnodeProps?.onAttach
const hasSpeech = !!vnodeProps?.onSpeech
const modelId = useVModel(props, "model", emit, { passive: true, defaultValue: props.defaultModel ?? props.models?.[0]?.id })
const effortId = useVModel(props, "effort", emit, { passive: true, defaultValue: props.defaultEffort ?? props.efforts?.[0]?.id })
const modeId = useVModel(props, "mode", emit, { passive: true, defaultValue: props.defaultMode ?? props.modes?.[0]?.id })
const labelOf = (options: ChatComposerOption[] | undefined, id?: string) =>
options?.find(o => o.id === id)?.label
const ready = computed(() => props.status === undefined || props.status === "ready")
</script>
<template>
<!-- Prompt input with the quiet footer outside the field: attach, speech, disclaimer, model + effort, mode. -->
<div data-slot="ai-chat-composer" :class="cn('flex w-full flex-col', props.class)">
<PromptInput
:accept="accept"
:multiple="multiple"
:elevation="elevation"
@submit="(m: PromptInputMessage) => emit('submit', m)"
>
<PromptInputBody>
<div class="flex w-full items-center pr-2">
<PromptInputTextarea
:placeholder="placeholder"
rows="1"
class="min-h-12 px-4 py-3 text-[14.5px]"
/>
<PromptInputSubmit
:status="status"
:variant="ready ? 'ghost' : undefined"
:class="ready ? 'text-muted-foreground' : undefined"
@stop="emit('stop')"
>
<span v-if="ready" aria-hidden="true">↵</span>
</PromptInputSubmit>
</div>
</PromptInputBody>
</PromptInput>
<div data-slot="ai-chat-composer-footer" class="flex items-center gap-0.5 px-1.5 pt-2 text-xs">
<Button v-if="hasAttach" aria-label="Attach" size="icon-xs" type="button" variant="ghost" @click="emit('attach')">
<Plus class="size-4" />
</Button>
<Button v-if="hasSpeech" aria-label="Speech input" size="xs" type="button" variant="ghost" class="gap-0.5" @click="emit('speech')">
<AudioLinesIcon class="size-4" />
<ChevronDown class="size-3" />
</Button>
<span class="min-w-0 flex-1 truncate text-center text-muted-foreground"><slot name="disclaimer">{{ disclaimer }}</slot></span>
<DropdownMenu v-if="models && models.length > 0">
<DropdownMenuTrigger as-child>
<Button size="xs" type="button" variant="ghost" class="gap-[5px] font-medium">
{{ labelOf(models, modelId) }}
<span v-if="efforts" class="font-normal text-muted-foreground">{{ labelOf(efforts, effortId) }}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top">
<DropdownMenuGroup>
<DropdownMenuLabel>Model</DropdownMenuLabel>
<DropdownMenuRadioGroup v-model="modelId">
<DropdownMenuRadioItem v-for="m in models" :key="m.id" :value="m.id">
{{ m.label }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
<template v-if="efforts && efforts.length > 0">
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel>Effort</DropdownMenuLabel>
<DropdownMenuRadioGroup v-model="effortId">
<DropdownMenuRadioItem v-for="e in efforts" :key="e.id" :value="e.id">
{{ e.label }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</template>
</DropdownMenuContent>
</DropdownMenu>
<DropdownMenu v-if="modes && modes.length > 0">
<DropdownMenuTrigger as-child>
<Button size="xs" type="button" variant="ghost" class="text-muted-foreground">
{{ labelOf(modes, modeId) }}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" side="top">
<DropdownMenuGroup>
<DropdownMenuLabel>Mode</DropdownMenuLabel>
<DropdownMenuRadioGroup v-model="modeId">
<DropdownMenuRadioItem v-for="m in modes" :key="m.id" :value="m.id">
{{ m.label }}
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</template>
registry/components/ai/chat-composer/types.ts
export type ChatComposerOption = { id: string; label: string };
registry/components/ai/chat-composer/index.ts
export { default as ChatComposer } from "./ChatComposer.vue";
export * from "./types";
src/lib/registry/ai/chat-composer/chat-composer.svelte
<script lang="ts">
import PlusIcon from "phosphor-svelte/lib/Plus";
import MicrophoneIcon from "phosphor-svelte/lib/Microphone";
import CaretDownIcon from "phosphor-svelte/lib/CaretDown";
import { Button } from "$lib/registry/ui/button/index.js";
import * as DropdownMenu from "$lib/registry/ui/dropdown-menu/index.js";
import { cn } from "$lib/utils.js";
import type { ChatStatus } from "ai";
import type { ComponentProps, Snippet } from "svelte";
import {
PromptInput,
PromptInputBody,
PromptInputSubmit,
PromptInputTextarea,
} from "../prompt-input/index.js";
import type { ChatComposerOption } from "./types.js";
let {
ref = $bindable(null),
class: className,
placeholder = "Reply",
status,
onStop,
onAttach,
onSpeech,
disclaimer,
models,
model = $bindable(),
defaultModel,
onModelChange,
efforts,
effort = $bindable(),
defaultEffort,
onEffortChange,
modes,
mode = $bindable(),
defaultMode,
onModeChange,
...restProps
}: Omit<ComponentProps<typeof PromptInput>, "children"> & {
ref?: HTMLDivElement | null;
placeholder?: string;
status?: ChatStatus;
onStop?: () => void;
/** Plus button in the footer; omit to hide it. */
onAttach?: () => void;
/** Mic button in the footer (speech input); omit to hide it. */
onSpeech?: () => void;
/** Small print centered in the footer. */
disclaimer?: string | Snippet;
models?: ChatComposerOption[];
/** `bind:model`; use `defaultModel` for uncontrolled. */
model?: string;
defaultModel?: string;
onModelChange?: (id: string) => void;
efforts?: ChatComposerOption[];
effort?: string;
defaultEffort?: string;
onEffortChange?: (id: string) => void;
modes?: ChatComposerOption[];
mode?: string;
defaultMode?: string;
onModeChange?: (id: string) => void;
} = $props();
// Uncontrolled values start from the defaults (or the first option).
$effect.pre(() => {
model ??= defaultModel ?? models?.[0]?.id;
effort ??= defaultEffort ?? efforts?.[0]?.id;
mode ??= defaultMode ?? modes?.[0]?.id;
});
const labelOf = (options: ChatComposerOption[] | undefined, id?: string) =>
options?.find((o) => o.id === id)?.label;
const ready = $derived(status === undefined || status === "ready");
</script>
<!-- Prompt input with the quiet footer outside the field: attach, speech, disclaimer, model + effort, mode. -->
<div bind:this={ref} data-slot="ai-chat-composer" class={cn("flex w-full flex-col", className)}>
<PromptInput {...restProps}>
<PromptInputBody>
<div class="flex w-full items-center pr-2">
<PromptInputTextarea {placeholder} rows={1} class="min-h-12 px-4 py-3 text-[14.5px]" />
<PromptInputSubmit
{status}
{onStop}
variant={ready ? "ghost" : undefined}
class={ready ? "text-muted-foreground" : undefined}
>
{#if ready}
<span aria-hidden="true">↵</span>
{/if}
</PromptInputSubmit>
</div>
</PromptInputBody>
</PromptInput>
<div
data-slot="ai-chat-composer-footer"
class="flex items-center gap-0.5 px-1.5 pt-2 text-xs"
>
{#if onAttach}
<Button aria-label="Attach" onclick={onAttach} size="icon-xs" type="button" variant="ghost">
<PlusIcon
class="size-4"
/>
</Button>
{/if}
{#if onSpeech}
<Button
aria-label="Speech input"
onclick={onSpeech}
size="xs"
type="button"
variant="ghost"
class="gap-0.5"
>
<MicrophoneIcon
class="size-4"
/>
<CaretDownIcon
class="size-3"
/>
</Button>
{/if}
<span class="min-w-0 flex-1 truncate text-center text-muted-foreground">{#if typeof disclaimer === "string"}{disclaimer}{:else if disclaimer}{@render disclaimer()}{/if}</span>
{#if models && models.length > 0}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} size="xs" type="button" variant="ghost" class="gap-[5px] font-medium">
{labelOf(models, model)}
{#if efforts}
<span class="font-normal text-muted-foreground">{labelOf(efforts, effort)}</span>
{/if}
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" side="top">
<DropdownMenu.Group>
<DropdownMenu.Label>Model</DropdownMenu.Label>
<DropdownMenu.RadioGroup value={model}
onValueChange={(v) => {
model = v;
onModelChange?.(v);
}}
>
{#each models as m (m.id)}
<DropdownMenu.RadioItem value={m.id}>{m.label}</DropdownMenu.RadioItem>
{/each}
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
{#if efforts && efforts.length > 0}
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Label>Effort</DropdownMenu.Label>
<DropdownMenu.RadioGroup value={effort}
onValueChange={(v) => {
effort = v;
onEffortChange?.(v);
}}
>
{#each efforts as e (e.id)}
<DropdownMenu.RadioItem value={e.id}>{e.label}</DropdownMenu.RadioItem>
{/each}
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
{#if modes && modes.length > 0}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} size="xs" type="button" variant="ghost" class="text-muted-foreground">
{labelOf(modes, mode)}
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" side="top">
<DropdownMenu.Group>
<DropdownMenu.Label>Mode</DropdownMenu.Label>
<DropdownMenu.RadioGroup value={mode}
onValueChange={(v) => {
mode = v;
onModeChange?.(v);
}}
>
{#each modes as m (m.id)}
<DropdownMenu.RadioItem value={m.id}>{m.label}</DropdownMenu.RadioItem>
{/each}
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</div>
</div>
src/lib/registry/ai/chat-composer/index.ts
import ChatComposer from "./chat-composer.svelte";
export * from "./types.js";
export { ChatComposer };
src/lib/registry/ai/chat-composer/types.ts
export type ChatComposerOption = { id: string; label: string };

See the Elevation guide for the levels, roles and layered mode.

The composer plate is an overlay surface: flat by default, floating in layered mode. sunken (-1) is a soft inset, raised (+1) a bevel and floating (+2) a bevel with a soft drop; the send button follows the Button rules.

StackBlitz ↗StackBlitz ↗StackBlitz ↗

Sunken (-1)

Edmi is AI and can make mistakes.

Flat (0)

Edmi is AI and can make mistakes.

Raised (+1)

Edmi is AI and can make mistakes.

Floating (+2)

Edmi is AI and can make mistakes.

Sunken (-1)

Edmi is AI and can make mistakes.

Flat (0)

Edmi is AI and can make mistakes.

Raised (+1)

Edmi is AI and can make mistakes.

Floating (+2)

Edmi is AI and can make mistakes.

Sunken (-1)

Edmi is AI and can make mistakes.

Flat (0)

Edmi is AI and can make mistakes.

Raised (+1)

Edmi is AI and can make mistakes.

Floating (+2)

Edmi is AI and can make mistakes.
Part Prop Type Default
ChatComposer onSubmit (message, event) => void required
ChatComposer elevation "auto" | "sunken" | "flat" | "raised" | "floating" "auto"
ChatComposer placeholder, status, onStop string, ChatStatus, () => void Reply
ChatComposer onAttach, onSpeech () => void (button hidden when omitted)
ChatComposer disclaimer ReactNode (Vue: disclaimer slot, Svelte: string | Snippet)
ChatComposer models, model, defaultModel, onModelChange { id, label }[], ids (Vue: v-model:model, Svelte: bind:model plus onModelChange) first option
ChatComposer efforts, effort, defaultEffort, onEffortChange same shape (v-model:effort, bind:effort) first option
ChatComposer modes, mode, defaultMode, onModeChange same shape (v-model:mode, bind:mode) first option
  • A one-line field with an enter hint, and a quiet footer row below it (not inside the field): attach, speech, disclaimer, model with effort, mode.