View as Markdown

Trace

The building block for reasoning, tool calls, sources and more.

100%
Loading...
<div class="">
    <twig:Message align="end">
        <twig:Message:Content>
            <twig:Bubble align="end" variant="muted">
                <twig:Bubble:Content>Why does pnpm install fail in CI?</twig:Bubble:Content>
            </twig:Bubble>
        </twig:Message:Content>
    </twig:Message>
    <twig:Message>
        <twig:Message:Content class="">
            <twig:Trace>
                <twig:Trace:Collapsible>
                    <twig:Trace:Collapsible:Trigger>
                        <twig:Trace:Line>
                            <twig:Trace:Icon>
                                <twig:ux:icon name="lucide:circle-check" />
                            </twig:Trace:Icon>
                            <twig:Trace:Title>Thought for 5s</twig:Trace:Title>
                        </twig:Trace:Line>
                    </twig:Trace:Collapsible:Trigger>
                    <twig:Trace:Collapsible:Content class="">
                        CI installs with a frozen lockfile, so the install stops when package.json and pnpm-lock.yaml disagree. I should check what the last commit changed, then reproduce the install.
                    </twig:Trace:Collapsible:Content>
                </twig:Trace:Collapsible>
                <twig:Trace:Collapsible>
                    <twig:Trace:Collapsible:Trigger>
                        <twig:Trace:Line>
                            <twig:Trace:Icon>
                                <twig:ux:icon name="lucide:circle-check" />
                            </twig:Trace:Icon>
                            <twig:Trace:Title>Ran pnpm --version</twig:Trace:Title>
                        </twig:Trace:Line>
                    </twig:Trace:Collapsible:Trigger>
                    <twig:Trace:Collapsible:Content>
                        <twig:CodeBlock size="sm">
                            <twig:CodeBlock:Content wrap class="">10.18.0</twig:CodeBlock:Content>
                        </twig:CodeBlock>
                    </twig:Trace:Collapsible:Content>
                </twig:Trace:Collapsible>
                <twig:Trace:Collapsible>
                    <twig:Trace:Collapsible:Trigger>
                        <twig:Trace:Line>
                            <twig:Trace:Icon>
                                <twig:ux:icon name="lucide:circle-check" />
                            </twig:Trace:Icon>
                            <twig:Trace:Title>Ran git diff --stat HEAD~1 -- package.json pnpm-lock.yaml</twig:Trace:Title>
                        </twig:Trace:Line>
                    </twig:Trace:Collapsible:Trigger>
                    <twig:Trace:Collapsible:Content>
                        <twig:CodeBlock size="sm">
                            <twig:CodeBlock:Content wrap class=""> package.json | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)</twig:CodeBlock:Content>
                        </twig:CodeBlock>
                    </twig:Trace:Collapsible:Content>
                </twig:Trace:Collapsible>
                <twig:Trace:Collapsible open>
                    <twig:Trace:Collapsible:Trigger>
                        <twig:Trace:Line status="error">
                            <twig:Trace:Icon>
                                <twig:ux:icon name="lucide:circle-alert" />
                            </twig:Trace:Icon>
                            <twig:Trace:Title>Failed to run pnpm install --frozen-lockfile</twig:Trace:Title>
                        </twig:Trace:Line>
                    </twig:Trace:Collapsible:Trigger>
                    <twig:Trace:Collapsible:Content>
                        <twig:CodeBlock size="sm" variant="destructive">
                            <twig:CodeBlock:Content wrap class="">ERR_PNPM_OUTDATED_LOCKFILE  Cannot install with "frozen-lockfile" because pnpm-lock.yaml is not up to date with package.json</twig:CodeBlock:Content>
                        </twig:CodeBlock>
                    </twig:Trace:Collapsible:Content>
                </twig:Trace:Collapsible>
                <twig:Trace:Collapsible>
                    <twig:Trace:Collapsible:Trigger>
                        <twig:Trace:Line>
                            <twig:Trace:Icon>
                                <twig:ux:icon name="lucide:globe" />
                            </twig:Trace:Icon>
                            <twig:Trace:Title>Used 2 sources</twig:Trace:Title>
                        </twig:Trace:Line>
                    </twig:Trace:Collapsible:Trigger>
                    <twig:Trace:Collapsible:Content>
                        <twig:Trace>
                            {% for source in [
                                {title: 'pnpm install | pnpm.io', url: 'https://pnpm.io/cli/install#--frozen-lockfile'},
                                {title: 'Continuous Integration | pnpm.io', url: 'https://pnpm.io/continuous-integration'},
                            ] %}
                                <a href="{{ source.url }}" target="_blank" rel="noreferrer">
                                    <twig:Trace:Line>
                                        <twig:Trace:Icon>
                                            <twig:ux:icon name="lucide:globe" />
                                        </twig:Trace:Icon>
                                        <twig:Trace:Title>
                                            {{ source.title }}
                                            <span class=""> (opens in a new tab)</span>
                                        </twig:Trace:Title>
                                        <twig:Trace:Icon>
                                            <twig:ux:icon name="lucide:arrow-up-right" />
                                        </twig:Trace:Icon>
                                    </twig:Trace:Line>
                                </a>
                            {% endfor %}
                        </twig:Trace>
                    </twig:Trace:Collapsible:Content>
                </twig:Trace:Collapsible>
            </twig:Trace>
            <p>The last commit bumped a version in package.json without updating pnpm-lock.yaml, so the frozen install fails. Run pnpm install locally and commit the lockfile.</p>
        </twig:Message:Content>
    </twig:Message>
</div>

Installation

Note

Available since UX Toolkit 3.7.

php bin/console ux:install trace --kit shadcn

Install the following Composer dependencies:

composer require twig/extra-bundle twig/html-extra:^3.24.0 symfony/ux-twig-component:^3.5 tales-from-a-dev/twig-tailwind-extra:^1.3.0 symfony/ux-icons

Install the following front-end dependencies:

npm install shadcn tw-animate-css
# ...or with Symfony AssetMapper
php bin/console importmap:require tw-animate-css/dist/tw-animate.css shadcn/dist/tailwind.css

Copy the following file(s) into your app:

templates/components/Trace.html.twig
<div
    data-slot="trace"
    {{ attributes.defaults({
        class: 'flex w-full min-w-0 flex-col gap-2 *:has-[>[data-slot=trace-line]]:flex *:has-[>[data-slot=trace-line]]:w-fit *:has-[>[data-slot=trace-line]]:max-w-full *:has-[>[data-slot=trace-line]]:min-w-0 *:has-[>[data-slot=trace-line]]:rounded-md *:has-[>[data-slot=trace-line]]:text-start *:has-[>[data-slot=trace-line]]:outline-none *:has-[>[data-slot=trace-line]]:focus-visible:ring-3 *:has-[>[data-slot=trace-line]]:focus-visible:ring-ring/50 *:*:has-[>[data-slot=trace-line]]:flex *:*:has-[>[data-slot=trace-line]]:w-fit *:*:has-[>[data-slot=trace-line]]:max-w-full *:*:has-[>[data-slot=trace-line]]:min-w-0 *:*:has-[>[data-slot=trace-line]]:rounded-md *:*:has-[>[data-slot=trace-line]]:text-start *:*:has-[>[data-slot=trace-line]]:outline-none *:*:has-[>[data-slot=trace-line]]:focus-visible:ring-3 *:*:has-[>[data-slot=trace-line]]:focus-visible:ring-ring/50'|tailwind_classes,
    }) }}
>
    {##- The rows of the trace: `Trace:Line`, `Trace:Collapsible`, or a link or trigger wrapping a `Trace:Line`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Trace/Collapsible.html.twig
{%- props
    ## boolean Whether the row is open on initial render.
    open = false
-%}
{%- do provide('collapsible.open', open) -%}
<div
    data-slot="trace-collapsible"
    data-collapsible-open-value="{{ open ? 'true' : 'false' }}"
    data-state="{{ open ? 'open' : 'closed' }}"
    {{ attributes.defaults({
        class: 'flex w-full min-w-0 flex-col'|tailwind_classes,
        'data-controller': 'collapsible',
    }) }}
>
    {##- The row, typically includes `Trace:Collapsible:Trigger` and `Trace:Collapsible:Content`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Trace/Collapsible/Content.html.twig
{%- set _collapsible_open = inject('collapsible.open', false) -%}
<div
    data-slot="trace-collapsible-content"
    data-collapsible-target="content"
    aria-hidden="{{ not _collapsible_open ? 'true' : 'false' }}"
    data-state="{{ _collapsible_open ? 'open' : 'closed' }}"
    class="min-w-0 overflow-hidden"
    {{ not _collapsible_open ? 'hidden' }}
    {{ attributes.without('class') }}
>
    <div
        data-slot="trace-collapsible-content-inner"
        class="{{ ('mt-2 mb-1 ms-[calc(--spacing(2)-1px)] min-w-0 border-s pe-1 ps-4 leading-relaxed wrap-break-word text-muted-foreground ' ~ attributes.render('class'))|tailwind_merge }}"
    >
        {##- The detail revealed when the row is open. -#}
        {%- block content %}{% endblock -%}
    </div>
</div>
templates/components/Trace/Collapsible/Trigger.html.twig
{%- set _collapsible_open = inject('collapsible.open', false) -%}
<button
    type="button"
    data-slot="trace-collapsible-trigger"
    data-collapsible-target="trigger"
    aria-expanded="{{ _collapsible_open ? 'true' : 'false' }}"
    data-state="{{ _collapsible_open ? 'open' : 'closed' }}"
    {{ attributes.defaults({
        class: 'group/trace-collapsible-trigger flex w-fit max-w-full min-w-0 cursor-pointer items-center gap-1 rounded-md text-start text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 has-[[data-status=error]]:text-destructive'|tailwind_classes,
        'data-action': 'click->collapsible#toggle'|html_attr_type('sst'),
    }) }}
>
    {##- The `Trace:Line` that opens and closes the row. -#}
    {%- block content %}{% endblock -%}
    <twig:Trace:Icon class="transition-transform rtl:rotate-180 group-aria-expanded/trace-collapsible-trigger:rotate-90">
        <twig:ux:icon name="lucide:chevron-right" />
    </twig:Trace:Icon>
</button>
templates/components/Trace/Description.html.twig
<span
    data-slot="trace-description"
    {{ attributes.defaults({
        class: 'shrink-0 text-muted-foreground/70 tabular-nums'|tailwind_classes,
    }) }}
>
    {##- Secondary text, such as a count or a duration. -#}
    {%- block content %}{% endblock -%}
</span>
templates/components/Trace/Icon.html.twig
<span
    data-slot="trace-icon"
    aria-hidden="true"
    {{ attributes.defaults({
        class: "flex size-4 shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [[data-slot=trace-title]~&]:-ms-1"|tailwind_classes,
    }) }}
>
    {##- The decorative icon. Before the title it names the action, after it is the indicator. -#}
    {%- block content %}{% endblock -%}
</span>
templates/components/Trace/Line.html.twig
{%- props
    ## 'running'|'error'|'pending'|null The state of the work the line reports.
    status = null
-%}
<span
    data-slot="trace-line"
    {% if status %}data-status="{{ status }}"{% endif %}
    {{ attributes.defaults({
        class: 'flex w-fit max-w-full min-w-0 items-center gap-2 text-sm text-muted-foreground transition-colors in-[a,button]:cursor-pointer in-[a:hover,button:hover]:text-foreground data-[status=error]:text-destructive data-[status=error]:in-[a:hover,button:hover]:text-destructive data-[status=pending]:opacity-60 data-[status=running]:*:data-[slot=trace-title]:shimmer'|tailwind_classes,
    }) }}
>
    {##- The line content, typically includes `Trace:Icon`, `Trace:Title` and optionally `Trace:Description`. -#}
    {%- block content %}{% endblock -%}
</span>
templates/components/Trace/Title.html.twig
<span
    data-slot="trace-title"
    {{ attributes.defaults({
        class: 'min-w-0 truncate motion-reduce:shimmer-none'|tailwind_classes,
    }) }}
>
    {##- One sentence that says what happened. -#}
    {%- block content %}{% endblock -%}
</span>
assets/controllers/collapsible_controller.js
import { Controller } from '@hotwired/stimulus';

export default class extends Controller {
    static targets = ['trigger', 'content'];

    static values = {
        open: { type: Boolean, default: false },
    };

    connect() {
        this.updateState();
    }

    toggle() {
        this.openValue = !this.openValue;
    }

    openValueChanged() {
        this.updateState();
    }

    updateState() {
        const open = this.openValue;
        const state = open ? 'open' : 'closed';

        this.element.dataset.state = state;

        for (const trigger of this.triggerTargets) {
            trigger.setAttribute('aria-expanded', String(open));
            trigger.dataset.state = state;
        }

        for (const content of this.contentTargets) {
            content.dataset.state = state;
            content.setAttribute('aria-hidden', String(!open));
            if (open) {
                content.removeAttribute('hidden');
            } else {
                content.setAttribute('hidden', '');
            }
        }
    }
}
templates/components/Collapsible.html.twig
{%- props
    ## boolean Whether the collapsible is open by default.
    open = false
-%}
{%- do provide('collapsible.open', open) -%}
<div
    data-slot="collapsible"
    data-collapsible-open-value="{{ open ? 'true' : 'false' }}"
    data-state="{{ open ? 'open' : 'closed' }}"
    {{ attributes.defaults({
        'data-controller': 'collapsible',
    }) }}
>
    {##- The collapsible content, typically a `Collapsible:Trigger` and a `Collapsible:Content`. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Collapsible/Content.html.twig
{%- set _collapsible_open = inject('collapsible.open', false) -%}
<div
    data-slot="collapsible-content"
    data-collapsible-target="content"
    aria-hidden="{{ not _collapsible_open ? 'true' : 'false' }}"
    data-state="{{ _collapsible_open ? 'open' : 'closed' }}"
    {{ not _collapsible_open ? 'hidden' }}
    {{ attributes }}
>
    {##- The content revealed when the collapsible is open. -#}
    {%- block content %}{% endblock -%}
</div>
templates/components/Collapsible/Trigger.html.twig
{%- set _collapsible_open = inject('collapsible.open', false) -%}
{%- set collapsible_trigger_attrs = {
    'data-slot': 'collapsible-trigger',
    'data-collapsible-target': 'trigger',
    'data-action': 'click->collapsible#toggle'|html_attr_type('sst'),
    'aria-expanded': _collapsible_open ? 'true' : 'false',
    'data-state': _collapsible_open ? 'open' : 'closed',
} -%}
{##- The clickable element that toggles the collapsible (e.g., a `Button`). -#}
{%- block content %}{% endblock -%}

Usage

Use a Trace:Line for a single step:

<twig:Trace>
    <twig:Trace:Line>
        <twig:Trace:Icon>
            <twig:ux:icon name="lucide:search" />
        </twig:Trace:Icon>
        <twig:Trace:Title>Searched for minimumReleaseAge</twig:Trace:Title>
    </twig:Trace:Line>
</twig:Trace>

Wrap the line in a Trace:Collapsible to show more detail when it expands:

<twig:Trace>
    <twig:Trace:Collapsible>
        <twig:Trace:Collapsible:Trigger>
            <twig:Trace:Line>
                <twig:Trace:Icon>
                    <twig:ux:icon name="lucide:terminal" />
                </twig:Trace:Icon>
                <twig:Trace:Title>Ran node --version</twig:Trace:Title>
            </twig:Trace:Line>
        </twig:Trace:Collapsible:Trigger>
        <twig:Trace:Collapsible:Content>v24.18.0</twig:Trace:Collapsible:Content>
    </twig:Trace:Collapsible>
</twig:Trace>

Examples

Trace Line

Use a Trace:Line on its own for a step with no detail to show. It reads as plain text.

100%
Loading...
<twig:Trace class="max-w-sm">
    <twig:Trace:Line>
        <twig:Trace:Icon>
            <twig:ux:icon name="lucide:git-branch" />
        </twig:Trace:Icon>
        <twig:Trace:Title>Created branch fix/frozen-lockfile</twig:Trace:Title>
    </twig:Trace:Line>
</twig:Trace>

Status

Set status on Trace:Line when a step is running, has failed or hasn't started yet.

100%
Loading...
<twig:Trace class="max-w-sm">
    <twig:Trace:Line>
        <twig:Trace:Icon>
            <twig:ux:icon name="lucide:circle-check" />
        </twig:Trace:Icon>
        <twig:Trace:Title>Ran pnpm install</twig:Trace:Title>
    </twig:Trace:Line>
    <twig:Trace:Line status="error">
        <twig:Trace:Icon>
            <twig:ux:icon name="lucide:circle-alert" />
        </twig:Trace:Icon>
        <twig:Trace:Title>Failed to run pnpm lint</twig:Trace:Title>
    </twig:Trace:Line>
    <twig:Trace:Line status="running">
        <twig:Trace:Icon>
            <twig:ux:icon name="lucide:loader-circle" class="animate-spin" />
        </twig:Trace:Icon>
        <twig:Trace:Title>Running pnpm build</twig:Trace:Title>
    </twig:Trace:Line>
    <twig:Trace:Line status="pending">
        <twig:Trace:Icon>
            <twig:ux:icon name="lucide:circle" />
        </twig:Trace:Icon>
        <twig:Trace:Title>Run pnpm test</twig:Trace:Title>
    </twig:Trace:Line>
</twig:Trace>

Description

Put secondary text such as a diff stat, a duration or a count in a Trace:Description after the Trace:Title. It does not truncate.

100%
Loading...
<twig:Trace class="max-w-sm">
    <twig:Trace:Line>
        <twig:Trace:Icon>
            <twig:ux:icon name="lucide:file-pen" />
        </twig:Trace:Icon>
        <twig:Trace:Title>Edited components/settings/notification-preferences-form.tsx</twig:Trace:Title>
        <twig:Trace:Description class="font-mono">+48 −12</twig:Trace:Description>
    </twig:Trace:Line>
    <twig:Trace:Line>
        <twig:Trace:Icon>
            <twig:ux:icon name="lucide:terminal" />
        </twig:Trace:Icon>
        <twig:Trace:Title>Ran pnpm test</twig:Trace:Title>
        <twig:Trace:Description>14s</twig:Trace:Description>
    </twig:Trace:Line>
    <twig:Trace:Line>
        <twig:Trace:Icon>
            <twig:ux:icon name="lucide:search" />
        </twig:Trace:Icon>
        <twig:Trace:Title>Searched for useChat</twig:Trace:Title>
        <twig:Trace:Description>8 results</twig:Trace:Description>
    </twig:Trace:Line>
</twig:Trace>

Collapsible

Wrap the line in a Trace:Collapsible to show more detail when it expands. Put the Trace:Line inside Trace:Collapsible:Trigger and the detail in Trace:Collapsible:Content.

100%
Loading...
<div class="">
    <twig:Trace:Collapsible>
        <twig:Trace:Collapsible:Trigger>
            <twig:Trace:Line>
                <twig:Trace:Icon>
                    <twig:ux:icon name="lucide:terminal" />
                </twig:Trace:Icon>
                <twig:Trace:Title>Ran node --version</twig:Trace:Title>
            </twig:Trace:Line>
        </twig:Trace:Collapsible:Trigger>
        <twig:Trace:Collapsible:Content>
            <twig:CodeBlock size="sm">
                <twig:CodeBlock:Content wrap class="">$ node --version
v24.18.0</twig:CodeBlock:Content>
            </twig:CodeBlock>
        </twig:Trace:Collapsible:Content>
    </twig:Trace:Collapsible>
</div>

Open State

Rows start closed. Set open on Trace:Collapsible to start open. To toggle a row from another button inside it, wrap that button in Collapsible:Trigger and spread collapsible_trigger_attrs on it. That button then also reflects the open state, through its aria-expanded and data-state attributes.

100%
Loading...
<div class="">
    <twig:Trace:Collapsible open>
        <twig:Trace:Collapsible:Trigger>
            <twig:Trace:Line>
                <twig:Trace:Icon>
                    <twig:ux:icon name="lucide:terminal" />
                </twig:Trace:Icon>
                <twig:Trace:Title>Ran node --version</twig:Trace:Title>
            </twig:Trace:Line>
        </twig:Trace:Collapsible:Trigger>
        <twig:Trace:Collapsible:Content>
            <twig:CodeBlock size="sm">
                <twig:CodeBlock:Content wrap class="">$ node --version
v24.18.0</twig:CodeBlock:Content>
            </twig:CodeBlock>
        </twig:Trace:Collapsible:Content>
    </twig:Trace:Collapsible>
    <twig:Trace:Collapsible>
        <twig:Trace:Collapsible:Trigger>
            <twig:Trace:Line>
                <twig:Trace:Icon>
                    <twig:ux:icon name="lucide:terminal" />
                </twig:Trace:Icon>
                <twig:Trace:Title>Ran pnpm --version</twig:Trace:Title>
            </twig:Trace:Line>
        </twig:Trace:Collapsible:Trigger>
        <twig:Trace:Collapsible:Content>
            <twig:CodeBlock size="sm">
                <twig:CodeBlock:Content wrap class="">$ pnpm --version
10.33.4</twig:CodeBlock:Content>
            </twig:CodeBlock>
        </twig:Trace:Collapsible:Content>
        <twig:Collapsible:Trigger>
            <twig:Button variant="outline" size="sm" class="" {{ ...collapsible_trigger_attrs }}>
                <span class="">Show</span>
                <span class="">Hide</span>
                the pnpm output
            </twig:Button>
        </twig:Collapsible:Trigger>
    </twig:Trace:Collapsible>
</div>

Wrap the Trace:Line in an anchor. For a new tab, use an arrow-up-right icon and sr-only "(opens in a new tab)".

100%
Loading...
<twig:Trace class="max-w-sm">
    <a href="#link">
        <twig:Trace:Line>
            <twig:Trace:Icon>
                <twig:ux:icon name="lucide:file-text" />
            </twig:Trace:Icon>
            <twig:Trace:Title>Read apps/v4/lib/docs.ts</twig:Trace:Title>
            <twig:Trace:Icon>
                <twig:ux:icon name="lucide:chevron-right" class="rtl:rotate-180" />
            </twig:Trace:Icon>
        </twig:Trace:Line>
    </a>
    <a href="https://docs.astro.build/en/install-and-setup/" target="_blank" rel="noreferrer">
        <twig:Trace:Line>
            <twig:Trace:Icon>
                <twig:ux:icon name="lucide:globe" />
            </twig:Trace:Icon>
            <twig:Trace:Title>
                Install Astro | docs.astro.build
                <span class="sr-only"> (opens in a new tab)</span>
            </twig:Trace:Title>
            <twig:Trace:Icon>
                <twig:ux:icon name="lucide:arrow-up-right" />
            </twig:Trace:Icon>
        </twig:Trace:Line>
    </a>
</twig:Trace>

Dialog

Wrap the Trace:Line in the dialog's trigger. The dialog owns its open state, Escape and focus return.

100%
Loading...
<div class="w-full max-w-sm" style="min-height: 320px">
    <twig:Trace>
        <twig:Dialog id="trace-dialog">
            <twig:Dialog:Trigger>
                <button type="button" {{ html_attr(dialog_trigger_attrs) }}>
                    <twig:Trace:Line>
                        <twig:Trace:Icon>
                            <twig:ux:icon name="lucide:globe" />
                        </twig:Trace:Icon>
                        <twig:Trace:Title>Searched the web for cache keys</twig:Trace:Title>
                        <twig:Trace:Icon>
                            <twig:ux:icon name="lucide:chevron-right" class="rtl:rotate-180" />
                        </twig:Trace:Icon>
                    </twig:Trace:Line>
                </button>
            </twig:Dialog:Trigger>
            <twig:Dialog:Content>
                <twig:Dialog:Header>
                    <twig:Dialog:Title>Searched the web for cache keys</twig:Dialog:Title>
                    <twig:Dialog:Description>Three results were used in the response.</twig:Dialog:Description>
                </twig:Dialog:Header>
                <twig:Trace>
                    {% for result in [
                        {title: 'Caching build outputs', url: 'https://nextjs.org/docs/app/guides/caching'},
                        {title: 'Image optimization reference', url: 'https://nextjs.org/docs/app/api-reference/components/image'},
                        {title: 'Deploy hooks and cache keys', url: 'https://vercel.com/docs/deploy-hooks'},
                    ] %}
                        <a href="{{ result.url }}" target="_blank" rel="noreferrer">
                            <twig:Trace:Line>
                                <twig:Trace:Icon>
                                    <twig:ux:icon name="lucide:globe" />
                                </twig:Trace:Icon>
                                <twig:Trace:Title>
                                    {{ result.title }}
                                    <span class="sr-only"> (opens in a new tab)</span>
                                </twig:Trace:Title>
                                <twig:Trace:Icon>
                                    <twig:ux:icon name="lucide:arrow-up-right" />
                                </twig:Trace:Icon>
                            </twig:Trace:Line>
                        </a>
                    {% endfor %}
                </twig:Trace>
            </twig:Dialog:Content>
        </twig:Dialog>
    </twig:Trace>
</div>

Sheet

The same shape as the dialog. Drawer and Popover work the same way.

100%
Loading...
<div class="w-full max-w-sm" style="min-height: 400px">
    <twig:Trace>
        <twig:Sheet id="trace-sheet">
            <twig:Sheet:Trigger>
                <button type="button" {{ html_attr(sheet_trigger_attrs) }}>
                    <twig:Trace:Line>
                        <twig:Trace:Icon>
                            <twig:ux:icon name="lucide:folder-search" />
                        </twig:Trace:Icon>
                        <twig:Trace:Title>Explored 4 files</twig:Trace:Title>
                        <twig:Trace:Icon>
                            <twig:ux:icon name="lucide:chevron-right" class="rtl:rotate-180" />
                        </twig:Trace:Icon>
                    </twig:Trace:Line>
                </button>
            </twig:Sheet:Trigger>
            <twig:Sheet:Content>
                <twig:Sheet:Header>
                    <twig:Sheet:Title>Explored 4 files</twig:Sheet:Title>
                    <twig:Sheet:Description>Files read while preparing the response.</twig:Sheet:Description>
                </twig:Sheet:Header>
                <ul class="flex flex-col gap-2 px-6 font-mono text-xs">
                    <li>apps/v4/lib/docs.ts</li>
                    <li>apps/v4/lib/components.ts</li>
                    <li>apps/v4/next.config.mjs</li>
                    <li>apps/v4/package.json</li>
                </ul>
            </twig:Sheet:Content>
        </twig:Sheet>
    </twig:Trace>
</div>

Group

Put rows that sit next to each other in a Trace. It also styles any trigger or link that wraps a Trace:Line.

100%
Loading...
<twig:Trace class="max-w-sm">
    <twig:Trace:Line>
        <twig:Trace:Icon>
            <twig:ux:icon name="lucide:search" />
        </twig:Trace:Icon>
        <twig:Trace:Title>Searched for minimumReleaseAge</twig:Trace:Title>
    </twig:Trace:Line>
    <twig:Trace:Line>
        <twig:Trace:Icon>
            <twig:ux:icon name="lucide:file-text" />
        </twig:Trace:Icon>
        <twig:Trace:Title>Read pnpm-workspace.yaml</twig:Trace:Title>
    </twig:Trace:Line>
    <twig:Trace:Line>
        <twig:Trace:Icon>
            <twig:ux:icon name="lucide:terminal" />
        </twig:Trace:Icon>
        <twig:Trace:Title>Ran pnpm install</twig:Trace:Title>
    </twig:Trace:Line>
</twig:Trace>

Sources

A collapsible row whose detail is a Trace of link rows.

100%
Loading...
<div class="w-full max-w-sm">
    <twig:Trace:Collapsible open>
        <twig:Trace:Collapsible:Trigger>
            <twig:Trace:Line>
                <twig:Trace:Icon>
                    <twig:ux:icon name="lucide:globe" />
                </twig:Trace:Icon>
                <twig:Trace:Title>Used 3 sources</twig:Trace:Title>
            </twig:Trace:Line>
        </twig:Trace:Collapsible:Trigger>
        <twig:Trace:Collapsible:Content>
            <twig:Trace>
                {% for source in [
                    {title: 'Install Astro | docs.astro.build', href: 'https://docs.astro.build/en/install-and-setup/'},
                    {title: 'create-astro | npmjs.com', href: 'https://www.npmjs.com/package/create-astro'},
                    {title: 'Settings: minimumReleaseAge | pnpm.io', href: 'https://pnpm.io/settings#minimumreleaseage'},
                ] %}
                    <a href="{{ source.href }}" target="_blank" rel="noreferrer">
                        <twig:Trace:Line>
                            <twig:Trace:Icon>
                                <twig:ux:icon name="lucide:globe" />
                            </twig:Trace:Icon>
                            <twig:Trace:Title>
                                {{ source.title }}
                                <span class="sr-only"> (opens in a new tab)</span>
                            </twig:Trace:Title>
                            <twig:Trace:Icon>
                                <twig:ux:icon name="lucide:arrow-up-right" />
                            </twig:Trace:Icon>
                        </twig:Trace:Line>
                    </a>
                {% endfor %}
            </twig:Trace>
        </twig:Trace:Collapsible:Content>
    </twig:Trace:Collapsible>
</div>

Chain of Thought

A chain of thought is a collapsible row with step rows inside. Set the parent's status and title from its children.

100%
Loading...
<div class="">
    <twig:Trace:Collapsible open>
        <twig:Trace:Collapsible:Trigger>
            <twig:Trace:Line status="running">
                <twig:Trace:Title>Looking into the slow deploy</twig:Trace:Title>
            </twig:Trace:Line>
        </twig:Trace:Collapsible:Trigger>
        <twig:Trace:Collapsible:Content>
            <twig:Trace>
                <twig:Trace:Collapsible>
                    <twig:Trace:Collapsible:Trigger>
                        <twig:Trace:Line>
                            <twig:Trace:Icon>
                                <twig:ux:icon name="lucide:search" />
                            </twig:Trace:Icon>
                            <twig:Trace:Title>Searched for the checkout build log</twig:Trace:Title>
                        </twig:Trace:Line>
                    </twig:Trace:Collapsible:Trigger>
                    <twig:Trace:Collapsible:Content class="">
                        <twig:Badge variant="secondary">vercel.com</twig:Badge>
                        <twig:Badge variant="secondary">github.com</twig:Badge>
                    </twig:Trace:Collapsible:Content>
                </twig:Trace:Collapsible>
                <a href="#steps">
                    <twig:Trace:Line>
                        <twig:Trace:Icon>
                            <twig:ux:icon name="lucide:file-text" />
                        </twig:Trace:Icon>
                        <twig:Trace:Title>Read the last twelve deploys</twig:Trace:Title>
                        <twig:Trace:Icon>
                            <twig:ux:icon name="lucide:chevron-right" class="" />
                        </twig:Trace:Icon>
                    </twig:Trace:Line>
                </a>
                <twig:Trace:Collapsible>
                    <twig:Trace:Collapsible:Trigger>
                        <twig:Trace:Line status="running">
                            <twig:Trace:Icon>
                                <twig:ux:icon name="lucide:search" />
                            </twig:Trace:Icon>
                            <twig:Trace:Title>Searching for the cache key</twig:Trace:Title>
                        </twig:Trace:Line>
                    </twig:Trace:Collapsible:Trigger>
                    <twig:Trace:Collapsible:Content>
                        <twig:CodeBlock size="sm">
                            <twig:CodeBlock:Content wrap class="">cache key image optimization</twig:CodeBlock:Content>
                        </twig:CodeBlock>
                    </twig:Trace:Collapsible:Content>
                </twig:Trace:Collapsible>
            </twig:Trace>
        </twig:Trace:Collapsible:Content>
    </twig:Trace:Collapsible>
</div>

Commit

A commit is a collapsible row with a line for each changed file. Say what changed in the title, Added, Edited or Deleted, and put the line counts in the description. Each file opens its diff in a Sheet.

100%
Loading...
<div class="" style="min-height: 400px">
    <twig:Trace>
        <twig:Trace:Collapsible open>
            <twig:Trace:Collapsible:Trigger>
                <twig:Trace:Line>
                    <twig:Trace:Icon>
                        <twig:ux:icon name="lucide:git-commit-horizontal" />
                    </twig:Trace:Icon>
                    <twig:Trace:Title>Committed a1b2c3: Fix the login redirect</twig:Trace:Title>
                </twig:Trace:Line>
            </twig:Trace:Collapsible:Trigger>
            <twig:Trace:Collapsible:Content>
                <twig:Trace>
                    {% for file in [
                        {
                            id: 'login-page',
                            path: 'app/login/page.tsx',
                            added: false,
                            additions: 2,
                            deletions: 2,
                            lines: [
                                {variant: 'hunk', text: '@@ -14,7 +14,7 @@ export default async function LoginPage({'},
                                {variant: 'default', text: '  searchParams,'},
                                {variant: 'default', text: '}) {'},
                                {variant: 'default', text: '  const { next } = await searchParams'},
                                {variant: 'removed', text: '  const redirectTo ='},
                                {variant: 'removed', text: '    next && next.startsWith("/") ? next : "/"'},
                                {variant: 'added', text: '  const redirectTo = safeRedirect(next)'},
                                {variant: 'added', text: ''},
                                {variant: 'default', text: '  return <LoginForm redirectTo={redirectTo} />'},
                                {variant: 'default', text: '}'},
                            ],
                        },
                        {
                            id: 'auth-redirect',
                            path: 'lib/auth/redirect.ts',
                            added: true,
                            additions: 7,
                            deletions: 0,
                            lines: [
                                {variant: 'hunk', text: '@@ -0,0 +1,7 @@'},
                                {variant: 'added', text: 'export function safeRedirect(next: string | undefined) {'},
                                {variant: 'added', text: '  if (!next || !next.startsWith("/") || next.startsWith("//")) {'},
                                {variant: 'added', text: '    return "/"'},
                                {variant: 'added', text: '  }'},
                                {variant: 'added', text: ''},
                                {variant: 'added', text: '  return next'},
                                {variant: 'added', text: '}'},
                            ],
                        },
                    ] %}
                        <twig:Sheet id="trace-commit-{{ file.id }}">
                            <twig:Sheet:Trigger>
                                <button type="button" {{ html_attr(sheet_trigger_attrs) }}>
                                    <twig:Trace:Line>
                                        <twig:Trace:Icon>
                                            <twig:ux:icon name="{{ file.added ? 'lucide:file-plus' : 'lucide:file-pen' }}" />
                                        </twig:Trace:Icon>
                                        <twig:Trace:Title>{{ file.added ? 'Added' : 'Edited' }} {{ file.path }}</twig:Trace:Title>
                                        <twig:Trace:Description class="">+{{ file.additions }} −{{ file.deletions }}</twig:Trace:Description>
                                        <twig:Trace:Icon>
                                            <twig:ux:icon name="lucide:chevron-right" class="" />
                                        </twig:Trace:Icon>
                                    </twig:Trace:Line>
                                </button>
                            </twig:Sheet:Trigger>
                            <twig:Sheet:Content>
                                <twig:Sheet:Header>
                                    <twig:Sheet:Title class="">{{ file.path }}</twig:Sheet:Title>
                                    <twig:Sheet:Description>{{ file.additions }} additions, {{ file.deletions }} deletions</twig:Sheet:Description>
                                </twig:Sheet:Header>
                                <twig:CodeBlock class="">
                                    <twig:CodeBlock:Content>
                                        {%- for line in file.lines -%}
                                            <twig:CodeBlock:Line variant="{{ line.variant == 'hunk' ? 'default' : line.variant }}" class="">{{ line.text }}</twig:CodeBlock:Line>
                                        {%- endfor -%}
                                    </twig:CodeBlock:Content>
                                </twig:CodeBlock>
                            </twig:Sheet:Content>
                        </twig:Sheet>
                    {% endfor %}
                </twig:Trace>
            </twig:Trace:Collapsible:Content>
        </twig:Trace:Collapsible>
    </twig:Trace>
</div>

Whole Turn

Three levels: the turn, a summary, and the rows. Text between steps sits between two Trace lists.

100%
Loading...
<div class="">
    <twig:Trace:Collapsible open>
        <twig:Trace:Collapsible:Trigger>
            <twig:Trace:Line status="error">
                <twig:Trace:Title>Worked for 2m 4s, 1 command failed</twig:Trace:Title>
            </twig:Trace:Line>
        </twig:Trace:Collapsible:Trigger>
        <twig:Trace:Collapsible:Content class="">
            <twig:Trace>
                <twig:Trace:Collapsible>
                    <twig:Trace:Collapsible:Trigger>
                        <twig:Trace:Line>
                            <twig:Trace:Icon>
                                <twig:ux:icon name="lucide:brain" />
                            </twig:Trace:Icon>
                            <twig:Trace:Title>Thought</twig:Trace:Title>
                            <twig:Trace:Description>12s</twig:Trace:Description>
                        </twig:Trace:Line>
                    </twig:Trace:Collapsible:Trigger>
                    <twig:Trace:Collapsible:Content class="">
                        I should check which package managers are installed before I scaffold anything.
                    </twig:Trace:Collapsible:Content>
                </twig:Trace:Collapsible>
                <twig:Trace:Collapsible open>
                    <twig:Trace:Collapsible:Trigger>
                        <twig:Trace:Line status="error">
                            <twig:Trace:Icon>
                                <twig:ux:icon name="lucide:terminal" />
                            </twig:Trace:Icon>
                            <twig:Trace:Title>Ran 2 commands, 1 failed</twig:Trace:Title>
                        </twig:Trace:Line>
                    </twig:Trace:Collapsible:Trigger>
                    <twig:Trace:Collapsible:Content>
                        <twig:Trace>
                            <twig:Trace:Line>
                                <twig:Trace:Icon>
                                    <twig:ux:icon name="lucide:terminal" />
                                </twig:Trace:Icon>
                                <twig:Trace:Title>Ran node --version</twig:Trace:Title>
                            </twig:Trace:Line>
                            <twig:Trace:Collapsible>
                                <twig:Trace:Collapsible:Trigger>
                                    <twig:Trace:Line status="error">
                                        <twig:Trace:Icon>
                                            <twig:ux:icon name="lucide:terminal" />
                                        </twig:Trace:Icon>
                                        <twig:Trace:Title>Failed to run pnpm --version</twig:Trace:Title>
                                    </twig:Trace:Line>
                                </twig:Trace:Collapsible:Trigger>
                                <twig:Trace:Collapsible:Content>
                                    <twig:CodeBlock size="sm" variant="destructive">
                                        <twig:CodeBlock:Content wrap class="">command not found: pnpm</twig:CodeBlock:Content>
                                    </twig:CodeBlock>
                                </twig:Trace:Collapsible:Content>
                            </twig:Trace:Collapsible>
                        </twig:Trace>
                    </twig:Trace:Collapsible:Content>
                </twig:Trace:Collapsible>
            </twig:Trace>
            <p>Node is installed and pnpm is not. Reading the docs config.</p>
            <twig:Trace>
                <a href="#turn">
                    <twig:Trace:Line>
                        <twig:Trace:Icon>
                            <twig:ux:icon name="lucide:file-text" />
                        </twig:Trace:Icon>
                        <twig:Trace:Title>Read apps/v4/lib/docs.ts</twig:Trace:Title>
                        <twig:Trace:Icon>
                            <twig:ux:icon name="lucide:chevron-right" class="" />
                        </twig:Trace:Icon>
                    </twig:Trace:Line>
                </a>
            </twig:Trace>
        </twig:Trace:Collapsible:Content>
    </twig:Trace:Collapsible>
</div>

Reasoning

Render the reasoning of an assistant turn. The row is a Trace:Line until the first text arrives: give it status="running" and the title "Thinking" while it streams, then wrap it in a Trace:Collapsible with the reasoning as its content.

100%
Loading...
<div class="flex w-full max-w-sm flex-col gap-4 py-6 text-sm">
    <twig:Message align="end">
        <twig:Message:Content>
            <twig:Bubble align="end" variant="muted">
                <twig:Bubble:Content>Why is the checkout deploy so slow?</twig:Bubble:Content>
            </twig:Bubble>
        </twig:Message:Content>
    </twig:Message>
    <twig:Message>
        <twig:Message:Content class="gap-4">
            <twig:Trace>
                <twig:Trace:Collapsible>
                    <twig:Trace:Collapsible:Trigger>
                        <twig:Trace:Line>
                            <twig:Trace:Icon>
                                <twig:ux:icon name="lucide:brain" />
                            </twig:Trace:Icon>
                            <twig:Trace:Title>Thought</twig:Trace:Title>
                        </twig:Trace:Line>
                    </twig:Trace:Collapsible:Trigger>
                    <twig:Trace:Collapsible:Content class="whitespace-pre-wrap">
                        The build log shows install and compile finish in under a minute. Image optimization takes almost four, and it reruns on every deploy because the cache key includes the commit hash.
                    </twig:Trace:Collapsible:Content>
                </twig:Trace:Collapsible>
            </twig:Trace>
            <p>Image optimization is the bottleneck. Key its cache on the lockfile and the image directory instead of the commit hash.</p>
        </twig:Message:Content>
    </twig:Message>
</div>

Tool

Render the tool calls of an assistant turn. A finished call is a collapsible row with its output, a failed one has status="error", and a running one is a Trace:Line with status="running".

100%
Loading...
<div class="">
    <twig:Message align="end">
        <twig:Message:Content>
            <twig:Bubble align="end" variant="muted">
                <twig:Bubble:Content>Which package managers are installed?</twig:Bubble:Content>
            </twig:Bubble>
        </twig:Message:Content>
    </twig:Message>
    <twig:Message>
        <twig:Message:Content class="">
            <twig:Trace>
                {% for call in [
                    {command: 'node --version', output: 'v24.18.0', failed: false},
                    {command: 'npm --version', output: '11.6.2', failed: false},
                    {command: 'pnpm --version', output: 'command not found: pnpm', failed: true},
                ] %}
                    <twig:Trace:Collapsible>
                        <twig:Trace:Collapsible:Trigger>
                            <twig:Trace:Line :status="call.failed ? 'error' : null">
                                <twig:Trace:Icon>
                                    <twig:ux:icon name="lucide:terminal" />
                                </twig:Trace:Icon>
                                <twig:Trace:Title>{{ call.failed ? 'Failed to run' : 'Ran' }} {{ call.command }}</twig:Trace:Title>
                            </twig:Trace:Line>
                        </twig:Trace:Collapsible:Trigger>
                        <twig:Trace:Collapsible:Content>
                            <twig:CodeBlock size="sm">
                                <twig:CodeBlock:Content wrap class="">{{ call.output }}</twig:CodeBlock:Content>
                            </twig:CodeBlock>
                        </twig:Trace:Collapsible:Content>
                    </twig:Trace:Collapsible>
                {% endfor %}
            </twig:Trace>
            <p>Node 24 and npm 11 are installed. pnpm is not.</p>
        </twig:Message:Content>
    </twig:Message>
</div>

RTL

To enable RTL support, set the dir="rtl" attribute on the root element.

100%
Loading...
<div class="flex w-full flex-col items-center gap-4">
    {# Arabic #}
    <twig:Trace class="max-w-sm py-6 text-sm" dir="rtl">
        <twig:Trace:Collapsible open>
            <twig:Trace:Collapsible:Trigger>
                <twig:Trace:Line>
                    <twig:Trace:Icon>
                        <twig:ux:icon name="lucide:terminal" />
                    </twig:Trace:Icon>
                    <twig:Trace:Title>تم تشغيل node --version</twig:Trace:Title>
                </twig:Trace:Line>
            </twig:Trace:Collapsible:Trigger>
            <twig:Trace:Collapsible:Content>v24.18.0</twig:Trace:Collapsible:Content>
        </twig:Trace:Collapsible>
        <twig:Trace:Line status="error">
            <twig:Trace:Icon>
                <twig:ux:icon name="lucide:circle-alert" />
            </twig:Trace:Icon>
            <twig:Trace:Title>فشل تشغيل pnpm lint</twig:Trace:Title>
        </twig:Trace:Line>
        <a href="#rtl">
            <twig:Trace:Line>
                <twig:Trace:Icon>
                    <twig:ux:icon name="lucide:file-text" />
                </twig:Trace:Icon>
                <twig:Trace:Title>تمت قراءة apps/v4/lib/docs.ts</twig:Trace:Title>
                <twig:Trace:Description>12 ثانية</twig:Trace:Description>
                <twig:Trace:Icon>
                    <twig:ux:icon name="lucide:chevron-right" class="rtl:rotate-180" />
                </twig:Trace:Icon>
            </twig:Trace:Line>
        </a>
    </twig:Trace>

    {# Hebrew #}
    <twig:Trace class="max-w-sm py-6 text-sm" dir="rtl">
        <twig:Trace:Collapsible open>
            <twig:Trace:Collapsible:Trigger>
                <twig:Trace:Line>
                    <twig:Trace:Icon>
                        <twig:ux:icon name="lucide:terminal" />
                    </twig:Trace:Icon>
                    <twig:Trace:Title>הורץ node --version</twig:Trace:Title>
                </twig:Trace:Line>
            </twig:Trace:Collapsible:Trigger>
            <twig:Trace:Collapsible:Content>v24.18.0</twig:Trace:Collapsible:Content>
        </twig:Trace:Collapsible>
        <twig:Trace:Line status="error">
            <twig:Trace:Icon>
                <twig:ux:icon name="lucide:circle-alert" />
            </twig:Trace:Icon>
            <twig:Trace:Title>הרצת pnpm lint נכשלה</twig:Trace:Title>
        </twig:Trace:Line>
        <a href="#rtl">
            <twig:Trace:Line>
                <twig:Trace:Icon>
                    <twig:ux:icon name="lucide:file-text" />
                </twig:Trace:Icon>
                <twig:Trace:Title>נקרא apps/v4/lib/docs.ts</twig:Trace:Title>
                <twig:Trace:Description>12 שניות</twig:Trace:Description>
                <twig:Trace:Icon>
                    <twig:ux:icon name="lucide:chevron-right" class="rtl:rotate-180" />
                </twig:Trace:Icon>
            </twig:Trace:Line>
        </a>
    </twig:Trace>
</div>

Accessibility

  • Trace adds no roles of its own. Each line gets its semantics from the element that wraps it.
  • A Trace:Line on its own is a span. It is read as text and is not a tab stop.
  • Wrap it in a Trace:Collapsible:Trigger and it becomes a button with aria-expanded. Wrap it in a link, or in the trigger for a dialog or sheet, and it takes the keyboard and focus behavior of that element.
  • Trace:Icon is hidden from assistive tech, so Trace:Title is the accessible name. Write it as a sentence that says what happened, like "Edited trace.html.twig", not just "trace.html.twig".
  • When a link opens a new tab, add "(opens in a new tab)" to the title with sr-only.
  • Status is shown with color and motion, so put it in the title too. Write "Running pnpm build" while it runs and "Failed to run pnpm build" when it errors.
  • The shimmer on a running title stops when the reader prefers reduced motion. The title still says what is happening.
  • Trace:Collapsible:Content mutes its text. If you put a block with a muted background inside it, give the block text-foreground so it keeps its contrast.

API Reference

<twig:Trace>

Block Description
content The rows of the trace: Trace:Line, Trace:Collapsible, or a link or trigger wrapping a Trace:Line.

<twig:Trace:Collapsible>

Prop Type Default
open 
boolean false
Block Description
content The row, typically includes Trace:Collapsible:Trigger and Trace:Collapsible:Content.

<twig:Trace:Collapsible:Content>

Block Description
content The detail revealed when the row is open.

<twig:Trace:Collapsible:Trigger>

Block Description
content The Trace:Line that opens and closes the row.

<twig:Trace:Description>

Block Description
content Secondary text, such as a count or a duration.

<twig:Trace:Icon>

Block Description
content The decorative icon. Before the title it names the action, after it is the indicator.

<twig:Trace:Line>

Prop Type Default
status 
'running'|'error'|'pending'|null null
Block Description
content The line content, typically includes Trace:Icon, Trace:Title and optionally Trace:Description.

<twig:Trace:Title>

Block Description
content One sentence that says what happened.