Page actions

ChatTraceComponent

ChatTraceComponent is the disclosure primitive behind the collapsible rows in a transcript. It renders one toggle button — a chevron, an optional icon, a label and optional metadata — and reveals its projected content only while the row is expanded. Tool-call cards and subagent cards are built on it, so the expansion rules below are the rules those cards inherit. The running example is the timeline demo, which mounts the full <chat> composition beside a checkpoint sidebar, and this page walks the files that put that surface on screen.

Selector: chat-trace

Import:

import { ChatTraceComponent } from '@threadplane/chat';

What the demo does

The Run tab shows a conversation on the left and a Timeline sidebar on the right. The agent is an aviation assistant whose mock dataset covers ten United States airports — LAX, JFK, SFO, ORD, BOS, ATL, DFW, SEA, MIA and DEN — and four airlines. There are no welcome suggestions, so type the first message yourself: ask which airlines fly out of BOS, then follow up with a different origin airport. The prompt keeps every answer to two or three sentences, so each turn is a clean unit of history.

Once a turn finishes, the sidebar lists the checkpoints the adapter loaded for the current thread and counts them as "N checkpoint(s)". Each entry shows the node the run would take next, or the literal Step N when there is none, the checkpoint identifier underneath it, and a Replay and a Fork button.

The graph behind this demo binds no tools, so its transcript is plain messages and no trace row appears in it. The section on where trace rows appear says which components put one there.

How it is built

Three files carry the example: the graph that streams the answers, the provider that points Angular at it, and the component that mounts the two chat components side by side. Open the Code tab to read them in place.

The conversational graph

The backend is an ordinary two-node graph. generate reads the capability's prompt file, prepends it as a system message, and awaits a model constructed with streaming=True. generate_title runs after it and writes a short thread title back through the LangGraph SDK. Checkpoints are not built here: the API server records one per node as the run advances.

graph.py — the conversational graph
def build_timeline_graph():
    """
    Constructs a standard conversational agent.
    Timeline/history navigation is handled by the Angular agent() frontend.
    """
    llm = ChatOpenAI(model="gpt-5-mini", streaming=True)
 
    async def generate(state: MessagesState) -> dict:
        system_prompt = (PROMPTS_DIR / "timeline.md").read_text()
        messages = [SystemMessage(content=system_prompt)] + state["messages"]
        response = await llm.ainvoke(messages)
        return {"messages": [response]}
 
    graph = StateGraph(MessagesState)
    graph.add_node("generate", generate)
    graph.add_node("generate_title", generate_title)
    graph.set_entry_point("generate")
    graph.add_edge("generate", "generate_title")
    graph.add_edge("generate_title", END)
 
    return graph.compile()
 

The compiled graph is exported as graph, which is the symbol langgraph.json points at.

The agent and the chat configuration

provideAgent() registers the agent for the whole application, and it is the only provider the chat compositions require. This example resolves its connection details at runtime from the host that serves the demo, which is why its factory reads them rather than hard-coding them.

app.config.ts
import { injectCockpitRuntimeConnection } from '@threadplane/cockpit-telemetry';
import { ApplicationConfig } from '@angular/core';
import { provideAgent } from '@threadplane/langgraph';
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideAgent(() => {
      const connection = injectCockpitRuntimeConnection();
      if (connection.adapter !== 'langgraph') {
        throw new Error('incompatible runtime');
      }
      return {
        apiUrl: connection.apiUrl,
        assistantId: connection.assistantId,
        clientOptions: connection.clientOptions,
      };
    }),
  ],
};

Your own application does not need the factory: pass apiUrl and assistantId directly, where assistantId is the graph name declared in langgraph.json.

The chat surface

<chat> is the prebuilt composition: message list, streaming markdown, tool calls, input strip. It takes the agent and nothing else here.

timeline.component.ts — the chat surface
<chat main [agent]="agent" class="flex-1 min-w-0" />

Every trace row a run produces is rendered inside this element.

The timeline sidebar

The sidebar is <chat-timeline-slider> under a heading, with a static note beneath it. Its Replay and Fork buttons emit replayRequested and forkRequested with the checkpoint identifier; this example binds neither, so Fork only marks the entry it was pressed on, and dispatching either back to the runtime is the host application's job.

timeline.component.ts — the timeline sidebar
<div sidebar class="panel">
  <h3 class="cap">Timeline</h3>
  <chat-timeline-slider [agent]="agent" />
  <div>
    <h4 class="cap">How It Works</h4>
    <p class="info">
      Each message creates a checkpoint. Use the slider to navigate
      through conversation history and branch from any point.
    </p>
  </div>
</div>

The slider requires an AgentWithHistory, the sub-contract that adds a history signal of AgentCheckpoint values to Agent.

One agent, two components

Both components bind to the same injected reference, so the sidebar describes exactly the conversation on the left.

timeline.component.ts — the agent
protected readonly agent = injectAgent();

Where trace rows appear

<chat> never renders <chat-trace> directly. Rows reach the transcript through <chat-tool-calls>, which renders each of an assistant message's tool calls as a <chat-tool-call-card>, or as a <chat-subagent-card> when the call spawned a subagent. Both cards wrap <chat-trace>: the card supplies the label, the status pill and the body, and the trace supplies the button, the chevron and the expansion behavior. When a tool call has a registered chatToolCallTemplate, that template replaces the card entirely, so those calls produce no trace row at all.

That is why the demo above shows no rows. Its graph calls no tools, so there are no tool calls to render. The tool-calls and subagent pages each run an example whose graph does call tools, and the rows in those transcripts are this component.

Mounting a row yourself is worthwhile when the thing you want to disclose is not a tool call — a retrieval step, a plan, a validation pass.

Basic usage

<chat-trace [state]="state()" [defaultExpanded]="true">
  <span traceLabel>search_flights</span>
  <span traceMeta>320ms</span>
 
  <pre>{{ output() }}</pre>
</chat-trace>

API

Inputs

InputTypeDefaultDescription
stateTraceState'pending'Execution state of the step. Drives auto-expansion and the running and error styling. TraceState is exported from @threadplane/chat and is 'pending' | 'running' | 'done' | 'error'
defaultExpandedbooleanfalseExpansion used while the state is neither running nor error, and while the reader has not clicked the header

Outputs

None. The row owns its expanded state and reports it through the data-expanded host attribute.

Slots

All slots are optional.

SlotSelectorDescription
Default(none)Body of the row. Rendered only while the row is expanded
Icon[traceIcon]Leading icon, projected into the header before the label
Label[traceLabel]Header label. There is no fallback: leave it out and the header shows only the chevron and the meta slot
Meta[traceMeta]Trailing header content, such as a status pill, a duration or a count

Methods

MethodDescription
toggle()Flips the expanded state and sets the manual override, exactly as clicking the header does

Host attributes

AttributeValue
data-stateThe current state
data-expanded"true" or "false"

Both are the styling hooks: the chevron rotation, the running animation and the error color are all selectors on them.

Expansion rules

The row is expanded when the state is running or error. Otherwise it follows defaultExpanded.

Clicking the header sets a manual override that wins over both, so a reader can close a running row or open a finished one. The override is cleared when the state re-enters running or error from a different state, which means a step that starts again opens again. There is no collapse-on-done rule: the row falls back to defaultExpanded once the state leaves running or error, which with the default false means an untouched row closes again, while a row left open by a click stays open.

Collapsing removes the body from the DOM rather than hiding it, so a collapsed row costs nothing to keep in a long transcript.

Driving a row from a tool call

ToolCall.status and TraceState share three of their four names. ToolCall reports 'complete' where TraceState expects 'done', so map that one explicitly:

import type { ToolCallStatus, TraceState } from '@threadplane/chat';
 
function toTraceState(status: ToolCallStatus): TraceState {
  return status === 'complete' ? 'done' : status;
}

This is the mapping <chat-tool-call-card> performs internally, and the one to repeat when driving a row from agent.toolCalls() yourself.

Styling

The row is deliberately plain: it has no background, border or radius of its own, so it sits inside whatever card or transcript hosts it. It reads these custom properties:

VariableApplied to
--tplane-chat-font-size-smRow font size
--tplane-chat-text-mutedHeader text and chevron
--tplane-chat-error-textHeader label while the state is error
--tplane-chat-separatorLeft rule beside the expanded body

The chevron is 12 pixels square and rotates a quarter turn when the row expands. While the state is running, the header label pulses through the shared tplane-chat-pulse animation. The body is indented under that left rule and scrolls past 250 pixels tall, so a large tool result cannot push the rest of the transcript off screen. Setting the variables is covered in the theming guide.

Accessibility

The header is a real <button> carrying aria-expanded, so the row is operable from the keyboard and announced as a disclosure. The chevron is marked aria-hidden="true". Anything the reader needs announced — a status, a duration — belongs in the label or meta slot, which are inside the button.

What's Next

Looking for something specific?