AI Reasoning
A trace of what the model was doing, collapsed behind a one-line summary. It
builds on Nuxt UI's ChatReasoning, which already handles the parts that are easy
to get subtly wrong: it times the trace, opens the moment streaming starts,
auto-closes when the answer lands, and swaps its label for a shimmer only while
work is actually happening.
What this component adds is the label — seconds, to a decimal, in the module's own voice — and a left rule that doubles as the indent, so the trace reads as an aside rather than as part of the answer it is explaining.
Use it inside an AI Message body, usually with
bubble: false so the trace is not boxed inside a bubble.
Usage
Put the trace in the default slot and drive isStreaming from your turn state.
The summary, the duration and the auto-collapse are all handled; there is no
timing prop you need to compute.
<template>
<div class="w-full max-w-lg rounded-2xl border border-default p-5">
<MAiReasoning :is-streaming="streaming">
The user asked why the audit needs a flag. Blast radius first: the audit
walks every theme token, so a cold landing produces a long tail of visual
diffs. Then the alternative — a flag adds one branch and one flag to read
later, and nothing here is dangerous without review. So: flag first, remove
it once the diffs are clean.
</MAiReasoning>
<div class="mt-4 flex items-center gap-3">
<UButton
:label="streaming ? 'Finish thinking' : 'Start thinking'"
size="sm"
variant="soft"
@click="streaming = !streaming"
/>
<span class="text-xs text-muted">
isStreaming = {{ streaming }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
const streaming = ref(true)
</script>
Customize
The user asked why the audit needs a flag. Blast radius first: the audit walks every theme token, so a cold landing produces a long tail of visual diffs. Then the alternative — a flag adds one branch and one flag to read later, and nothing here is dangerous without review. So: flag first, remove it once the diffs are clean.
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
isStreaming | boolean | false | Whether the trace is still being produced. It opens the block on the rising
edge, drives the shimmer, and — with collapseWhenDone — triggers the
auto-close on the falling edge. |
collapseWhenDone | boolean | true | Collapse itself once isStreaming goes false. The trace is scaffolding:
interesting while it happens, clutter once the answer has landed. Setting it
false turns the underlying auto-close off entirely and leaves the block
wherever the reader put it. |
duration | number | - | Seconds the model spent, in seconds. Omit it and the component times the trace
itself between the isStreaming edges and reports its own measurement. |
defaultOpen | boolean | - | Start expanded. Omit it and the block opens or closed depending on what
isStreaming starts as — which is the behaviour you want in a live transcript
and the wrong one for a static page. |
class | string | '' | Merged onto the underlying reasoning block. |
Slots
The default slot is the trace itself. Its container is styled for you: a left
border in border-default, pl-4, relaxed leading and text-muted. The block's
own top padding is forced to 0 so the rule, not a gap, is what separates the
trace from the answer below it.
UI
This component's theme exposes the slots below. Override any of them per instance with the ui prop, or app-wide under ui.aiReasoning in app.config.ts.
| Prop | Type | Default | Description |
|---|---|---|---|
ui | object | - | Per-slot classes merged over the theme defaults. |
| Slot | Props | Description |
|---|---|---|
root | - | w-full |
body | - | !pt-0 text-sm text-muted |
trace | - | border-default border-l pl-4 leading-relaxed text-muted |
Accessibility
- The disclosure and its label come from
UChatReasoning, which renders a real button witharia-expandedand a region bound to it. The trace is not simply hidden with CSS. - The summary text is the label, and it is meaningful at every state:
Thought for 4.2sonce a duration is known,Reasoningbefore one is. The block is never unlabelled. - The shimmer that replaces the label appears only while
isStreamingis true, so a settled trace is not advertising work that has stopped. - The 600ms delay before auto-collapsing is a readability decision: snapping shut the instant a trace finishes feels like the content was yanked away mid-read.
- Duration is derived from
Date.now(), not a frame loop, so the measurement costs nothing per frame and a backgrounded tab does not produce a wildly inflated number beyond the real elapsed time. - A trace longer than a screen is not virtualised. It is scrolled, not truncated.
- There is no ARIA live region here. Announcing a growing trace would be unusable; if a reader needs to know the model finished, that belongs in the message that follows.
Performance
- The trace is timed by a
watchonisStreamingwithimmediate: true, so a trace that starts already streaming is measured from mount rather than never. - The measurement runs once on the falling edge and is kept in a
ref. It is the same measurementChatReasoningmakes for its own sake, duplicated only so the summary can say how long it took. durationwins over the internal measurement when both exist, which is what lets a stored transcript render its original timing instead of re-timing a replay.autoCloseDelayis a singlecomputedoffcollapseWhenDone.0is the underlying component's own "do not auto-close" signal, so the opt-out needs no separate branch.- The
uioverride is a static object, not acomputed— it never changes, and rebuilding it per render would be pure waste.