Composer

The input grows with its text. While a response generates, the submit button becomes a stop button, or a queue button once the user starts typing again.

<script lang="ts">
    import Check from '@lucide/svelte/icons/check';
    import ChevronDown from '@lucide/svelte/icons/chevron-down';
    import * as Attachment from '@sivir-ui/svelte/components/attachment';
    import * as Composer from '@sivir-ui/svelte/components/composer';
    import * as DropdownMenu from '@sivir-ui/svelte/components/dropdown-menu';
    import * as Select from '@sivir-ui/svelte/components/select';
    import { onDestroy } from 'svelte';

    const models = ['Sivir 3.1', 'Sivir Mini'];
    const modes = ['Plan', 'Build'];
    const permissions = ['Ask first', 'Auto approve'];
    const efforts = ['Low', 'Medium', 'High'];

    let files = $state<File[]>([]);
    let value = $state('Review the release notes and call out any migration risks.');
    let model = $state(models[0]);
    let mode = $state(modes[0]);
    let permission = $state(permissions[0]);
    let effort = $state(efforts[2]);
    let generating = $state(false);
    let timer: ReturnType<typeof setTimeout> | undefined;

    function clearTimer() {
        if (timer) {
            clearTimeout(timer);
        }
        timer = undefined;
    }

    async function submitPrompt() {
        await new Promise<void>((resolve) => {
            setTimeout(resolve, 600);
        });
        value = '';
        files = [];
        generating = true;
        clearTimer();
        timer = setTimeout(() => {
            generating = false;
            timer = undefined;
        }, 4000);
    }

    function stopResponse() {
        clearTimer();
        generating = false;
    }

    onDestroy(clearTimer);
</script>

<Attachment.Root bind:files class="@container flex w-full max-w-2xl flex-col">
    <Composer.Root
        bind:value
        {generating}
        onSubmit={submitPrompt}
        onStop={stopResponse}
    >
        <Attachment.List />
        <Composer.Input aria-label="Prompt" placeholder="Ask the agent..." />

        <Composer.Toolbar>
            <Composer.Actions>
                <Attachment.Trigger class="rounded-full" />

                <Select.Root bind:value={mode}>
                    <Select.Trigger
                        variant="ghost"
                        class="h-8 w-auto max-w-32 gap-1 rounded-full px-2.5 text-foreground-muted hover:text-foreground @max-md:px-2 @max-md:[&>svg]:hidden"
                    >
                        <span class="truncate">{mode}</span>
                    </Select.Trigger>
                    <Select.Content dynamic>
                        <Select.Label>Mode</Select.Label>
                        {#each modes as option (option)}
                            <Select.Item value={option}>{option}</Select.Item>
                        {/each}
                    </Select.Content>
                </Select.Root>

                <Select.Root bind:value={permission}>
                    <Select.Trigger
                        variant="ghost"
                        class="h-8 w-auto max-w-44 gap-1 rounded-full px-2.5 text-foreground-muted hover:text-foreground @max-md:px-2 @max-md:[&>svg]:hidden"
                    >
                        <span class="truncate">{permission}</span>
                    </Select.Trigger>
                    <Select.Content dynamic>
                        <Select.Label>Permission</Select.Label>
                        {#each permissions as option (option)}
                            <Select.Item value={option}>{option}</Select.Item>
                        {/each}
                    </Select.Content>
                </Select.Root>
            </Composer.Actions>

            <div class="ml-auto flex min-w-0 items-center gap-1">
                <DropdownMenu.Root>
                    <DropdownMenu.Trigger
                        variant="ghost"
                        class="h-8 w-auto max-w-52 rounded-full px-2.5"
                    >
                        <span class="flex min-w-0 flex-1 items-center gap-1.5">
                            <span class="truncate">{model}</span>
                            <span class="text-foreground-muted @max-md:hidden">{effort}</span>
                        </span>
                        <ChevronDown
                            size={12}
                            class="ml-auto shrink-0 text-foreground-muted"
                            aria-hidden="true"
                        />
                    </DropdownMenu.Trigger>
                    <DropdownMenu.Content dynamic>
                        <DropdownMenu.Label>Configuration</DropdownMenu.Label>
                        <DropdownMenu.Sub>
                            <DropdownMenu.SubTrigger>Model</DropdownMenu.SubTrigger>
                            <DropdownMenu.SubContent dynamic>
                                {#each models as option (option)}
                                    <DropdownMenu.Item callback={() => (model = option)}>
                                        <span class="flex-1">{option}</span>
                                        {#if model === option}
                                            <Check size={13} aria-hidden="true" />
                                        {/if}
                                    </DropdownMenu.Item>
                                {/each}
                            </DropdownMenu.SubContent>
                        </DropdownMenu.Sub>
                        <DropdownMenu.Sub>
                            <DropdownMenu.SubTrigger>Effort</DropdownMenu.SubTrigger>
                            <DropdownMenu.SubContent dynamic>
                                {#each efforts as option (option)}
                                    <DropdownMenu.Item callback={() => (effort = option)}>
                                        <span class="flex-1">{option}</span>
                                        {#if effort === option}
                                            <Check size={13} aria-hidden="true" />
                                        {/if}
                                    </DropdownMenu.Item>
                                {/each}
                            </DropdownMenu.SubContent>
                        </DropdownMenu.Sub>
                    </DropdownMenu.Content>
                </DropdownMenu.Root>

                <Composer.Submit />
            </div>
        </Composer.Toolbar>
    </Composer.Root>
</Attachment.Root>

Installation

Copy the command below and run it in your terminal.

bunx @sivir-ui/svelte add composer

Usage

onSubmit is required. Empty prompts are not submitted unless you set allowEmpty. While an async onSubmit runs, the input is read-only and the submit button shows a pending state. Pass generating and onStop to show the stop button while a response streams.

import * as Composer from '@sivir-ui/svelte/components/composer';

let value = $state('');

async function sendPrompt(prompt: string) {
  await chat.send(prompt);
  value = '';
}

<Composer.Root bind:value onSubmit={sendPrompt}>
  <Composer.Input placeholder="Ask about this repository..." />
  <Composer.Toolbar>
    <Composer.Actions>
      <!-- Attachment, model, or permission controls -->
    </Composer.Actions>
    <Composer.Submit />
  </Composer.Toolbar>
</Composer.Root>

To accept files, wrap Root in Attachment.Root and place Attachment.List directly before Input. Dropped and pasted files show as chips beside the prompt.

By default, submits and inserts a new line. Set submitOnEnter=false on Input when Enter should always create a new line.

Examples

Idle

<script lang="ts">
    import * as Composer from '@sivir-ui/svelte/components/composer';
</script>

<Composer.Root value="Compare the two implementation options." onSubmit={() => {}}>
    <Composer.Input aria-label="Idle prompt" />
    <Composer.Toolbar>
        <Composer.Actions>
            <span class="px-2 text-xs text-foreground-muted">Sivir 3.1</span>
        </Composer.Actions>
        <Composer.Submit />
    </Composer.Toolbar>
</Composer.Root>

Submitting

<script lang="ts">
    import * as Composer from '@sivir-ui/svelte/components/composer';
</script>

<Composer.Root
    value="Check the deployment plan for missing rollbacks."
    status="submitting"
    onSubmit={() => {}}
    onStop={() => {}}
>
    <Composer.Input aria-label="Submitting prompt" />
    <Composer.Toolbar>
        <Composer.Actions>
            <span class="px-2 text-xs text-foreground-muted">Sivir 3.1</span>
        </Composer.Actions>
        <Composer.Submit />
    </Composer.Toolbar>
</Composer.Root>

Error

<script lang="ts">
    import * as Composer from '@sivir-ui/svelte/components/composer';
    import { onMount } from 'svelte';

    let status = $state<'idle' | 'error'>('idle');

    onMount(() => {
        const frame = requestAnimationFrame(() => {
            status = 'error';
        });

        return () => cancelAnimationFrame(frame);
    });
</script>

<Composer.Root
    value="Summarize the incident timeline."
    {status}
    onSubmit={() => {}}
    onStop={() => {}}
>
    <Composer.Input aria-label="Error prompt" />
    <Composer.Toolbar>
        <Composer.Actions>
            <span class="px-2 text-xs text-foreground-muted">Sivir 3.1</span>
        </Composer.Actions>
        <Composer.Submit />
    </Composer.Toolbar>
</Composer.Root>

Set status when your app tracks submission itself. "error" shows “Message could not be sent.” above the form.

Anatomy

Composer.Root - Submits the prompt and tracks its status.

Composer.Input - Grows with the prompt text.

Composer.Toolbar - Holds the actions and submit button.

Composer.Actions - Holds controls such as attach or model pickers.

Composer.Submit - Sends, queues, or stops the prompt.