ChatDebugComponent
ChatDebugComponent is a docked development panel for inspecting an agent. It reads the agent's checkpoint history and current state and renders them in a floating panel, and it ships from a debug-only secondary entry point so applications can keep the implementation out of production bundles unless they opt in. The running example mounts it beside the prebuilt <chat> composition, which is how it is meant to be used: the chat produces the runs, the panel inspects them.
Selector: chat-debug
Import:
import { ChatDebugComponent } from '@threadplane/chat/debug';What the demo does
The Run tab mounts <chat> and <chat-debug>, both bound to the same LangGraph agent, inside the shared example layout shell, <example-chat-layout>. The panel keeps itself out of the layout — its host renders display: contents, and the launcher and the panel are both fixed-position — so all it adds to the page is a small round status pill in the top-right corner above the chat. Click the pill and the docked Chat Devtools panel opens, with a Timeline tab listing the agent's checkpoints and a State tab pretty-printing the agent's current state under a Copy button. Pressing Escape or clicking outside the panel closes it.
Before the first turn the Timeline tab shows its empty state: "No checkpoints yet. Send a message to populate the timeline." Send a message through the composer and it fills, one row per checkpoint the run wrote. The dock buttons in the header move the panel between the left, bottom and right edges, and the choice is persisted, as is the open state and the selected tab.
How it is built
The example is three files: the graph that produces the checkpoints, the provider that points Angular at it, and the component that mounts the panel. Open the Code tab to read them in place.
A graph with several nodes per turn
The backend is deliberately multi-step, because one node per turn produces one checkpoint and very little to look at. generate answers with the model, process measures that answer and records the result, and summarize asks the model for a one-sentence summary of the conversation so far. The system prompt read by generate frames the assistant as an aviation helper; the graph binds no tools, so every answer comes from the model alone.
async def generate(state: DebugState) -> dict:
system_prompt = (PROMPTS_DIR / "debug.md").read_text()
messages = [SystemMessage(content=system_prompt)] + state["messages"]
response = await llm.ainvoke(messages)
return {"messages": [response]}
async def process(state: DebugState) -> dict:
last = state["messages"][-1].content
analysis = {
"characters": len(last),
"words": last.count(" ") + 1,
}
processed = AIMessage(
content=f"[Processing] Analyzed {analysis['characters']} characters. "
f"Found {analysis['words']} words. Processing complete."
)
return {"messages": [processed], "analysis": analysis}
async def summarize(state: DebugState) -> dict:
messages = [
SystemMessage(content="Provide a brief one-sentence summary of the conversation so far.")
] + state["messages"]
response = await llm.ainvoke(messages)
return {"messages": [response]}Each node returns a partial state update, and each of those updates becomes a checkpoint on the thread.
State the inspector can show
agent.state() is the LangGraph values bag with messages projected out into the transcript, so a graph that carries nothing but its messages leaves the State tab printing an empty object. This graph widens MessagesState with the metrics process computes, which is what gives the second tab something to inspect.
class DebugState(MessagesState):
"""MessagesState plus the metrics `process` computes.
The devtools State tab pretty-prints whatever the graph keeps in state.
A graph that only carries `messages` has nothing to show there, because
the transcript is rendered as the conversation rather than as state.
"""
analysis: dict
Wiring the nodes into a linear pipeline
The nodes are registered on a StateGraph over DebugState and chained: generate to process to summarize to generate_title, then to the end. generate_title is a background node that summarizes the first user message into a thread title; it returns an empty update, so it changes the message list not at all while still adding a step to the run.
graph = StateGraph(DebugState)
graph.add_node("generate", generate)
graph.add_node("process", process)
graph.add_node("summarize", summarize)
graph.add_node("generate_title", generate_title)
graph.set_entry_point("generate")
graph.add_edge("generate", "process")
graph.add_edge("process", "summarize")
graph.add_edge("summarize", "generate_title")
graph.add_edge("generate_title", END)
return graph.compile()The last line calls compile() with no checkpointer. The checkpoints the Timeline tab reads come from the LangGraph API server, which persists thread state for every graph it serves. langgraph dev refuses to load a graph that compiles its own saver, and a deployment ignores one — see Persistence.
The agent provider
provideAgent() registers the agent once for the whole application, and it is the only provider the chat compositions require. The 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.
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 passes the two values directly instead:
provideAgent({
apiUrl: 'https://your-deployment.langgraph.app',
assistantId: 'debug',
}),Mounting the panel
The component projects <chat> and <chat-debug> into <example-chat-layout> and binds one field on each. injectAgent() returns the agent registered above, and it is handed straight to both [agent] inputs. Nothing else is bound, so the panel runs on its defaults: docked right, closed on first load, with the floating launcher visible.
import { Component } from '@angular/core';
import { ChatComponent } from '@threadplane/chat';
import { ChatDebugComponent } from '@threadplane/chat/debug';
import { injectAgent } from '@threadplane/langgraph';
import { ExampleChatLayoutComponent } from '@threadplane/example-layouts';
/**
* DebugComponent pairs the standard `<chat>` composition with
* `<chat-debug>`, the devtools dock that carries the timeline, state
* inspector, and diff viewer.
*
* `<chat-debug>` renders `display: contents` and mounts its own fixed
* launcher, so it adds no layout of its own; both elements carry the
* layout's `main` projection attribute, and `<chat>` supplies the
* transcript and the composer that produce the runs to inspect.
*/
@Component({
selector: 'app-debug',
standalone: true,
imports: [ChatComponent, ChatDebugComponent, ExampleChatLayoutComponent],
template: `
<example-chat-layout>
<chat main [agent]="agent" />
<chat-debug main [agent]="agent" />
</example-chat-layout>
`,
})
export class DebugPageComponent {
protected readonly agent = injectAgent();
}The agent returned by injectAgent() exposes a history() signal as well as state(), which is what makes the Timeline tab appear; an agent without history() gets the State tab alone.
Inputs
| Input | Type | Default | Description |
|---|---|---|---|
agent | DebugAgent | DebugAgentWithHistory | null | null | Agent to inspect. The panel renders nothing while this is null; the floating launcher still appears, but opening it shows no content. An agent that also exposes history() enables the Timeline tab. |
dock | 'right' | 'bottom' | 'left' | 'right' | Initial dock position, used when no persisted position exists. When a sibling <chat-sidebar> is on the page and the user has not clicked a dock button this session, an auto-dock effect forces bottom on open. |
defaultOpen | boolean | false | Initial open state, used when no persisted state exists. |
launcher | 'floating' | 'none' | 'floating' | Shows the built-in floating launcher, or hides it when another surface opens the panel. |
storageKey | string | 'chat-debug' | Local storage key prefix for the persisted open, dock and tab state. |
DebugAgent is a structural type: signals for messages, status, isLoading, error, toolCalls and state. DebugAgentWithHistory adds history. The agents returned by injectAgent() satisfy the second.
Outputs
| Output | Type | Description |
|---|---|---|
replayRequested | string | Emits a checkpoint id when Replay is pressed on a selected checkpoint in the Timeline tab. |
forkRequested | string | Emits a checkpoint id when Fork is pressed on a selected checkpoint in the Timeline tab. |
openChange | boolean | Emits when the panel opens or closes. |
dockChange | 'right' | 'bottom' | 'left' | Emits when the dock position changes. |
replayRequested and forkRequested are integration hooks, and the example demonstrates the panel without them. The panel never mutates the agent by itself: selecting a checkpoint reveals a Replay button, a Fork button and a before-and-after diff of that step's state, and the host application decides whether an emitted id opens a replay view, starts a forked thread, or maps to a backend-specific time travel operation.
<chat-debug
[agent]="agent"
(replayRequested)="replay($event)"
(forkRequested)="fork($event)"
/>Sidenav integration
ChatSidenavComponent can own the launcher for applications that already use the sidenav footer. Pass the same agent and leave [debug] at its default of true:
<chat-sidenav [agent]="agent" [debug]="true" />The footer button appears only when an agent is bound and the debug entry point is included in the build. It is labeled "Devtools" in expanded and drawer modes; in collapsed mode it is the status dot alone, and the dot pulses while agent.status() is running. Pressing it lazily imports ChatDebugComponent, mounts it with launcher="none" and the storage key chat-sidenav-debug, and opens it. The sidenav does not re-expose replayRequested or forkRequested, so mount <chat-debug> yourself when you need those hooks.
Production bundles
The implementation lives under @threadplane/chat/debug; the main @threadplane/chat entry point does not export it. Importing it yourself, as the example does, always puts it in the bundle. The sidenav's lazy import is different: it is gated on an internal flag that is true whenever ngDevMode is true, and otherwise only when a THREADPLANE_CHAT_DEBUG compile-time constant is defined as true. The canonical Angular demo defines that constant per build configuration — false for production, and true for a separate production-debug configuration.
Keep debug controls of your own outside the panel. The panel intentionally exposes a small fixed surface, so consumers do not expect application-specific controls to appear in it.
What's Next
What the replay and fork checkpoint hooks plug into, and how to wire them against the agent's history.
Where the checkpoints come from, and why this graph compiles without a checkpointer.
The layout composition that can own the debug launcher.
An in-conversation view of graph execution, for surfaces your users see.