Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -3755,9 +3755,11 @@
{
"name": "ChatSidenavComponent",
"kind": "class",
"description": "",
"description": "The conversation sidebar: thread list, projects, search, and the new-chat\naction. Pair it with a runtime's thread store (e.g. `LangGraphThreadsAdapter`)\nand a ThreadActionAdapter for the per-row rename/delete/archive menu.\n\n**This component renders the sidebar only — it is not a layout wrapper.** Its\n`<ng-content>` slots are all named (`sidenavHeader`, `sidenavPrimary`,\n`sidenavSections`, `sidenavFooterLeft`, `sidenavFooterRight`,\n`sidenavAccount`) and target regions *inside* the sidebar. There is no default\nslot, so a `<chat>` placed between the tags is silently dropped. Render the\nchat as a sibling and lay the two out yourself:",
"params": [],
"examples": [],
"examples": [
"```html\n<chat-sidenav\n [threads]=\"threads.threads()\"\n [activeThreadId]=\"activeThread()\"\n [actions]=\"threadActions\"\n [agent]=\"agent\"\n (newChat)=\"activeThread.set(null)\"\n (threadSelected)=\"activeThread.set($event)\"\n/>\n<main class=\"chat-pane\">\n <chat [agent]=\"agent\" />\n</main>\n```\n```css\n:host { display: flex; height: 100dvh; }\n.chat-pane { flex: 1; min-width: 0; }\n```"
],
"properties": [
{
"name": "actions",
Expand Down
176 changes: 176 additions & 0 deletions apps/website/content/docs/chat/components/chat-sidenav.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# ChatSidenavComponent

`ChatSidenavComponent` is the conversation sidebar: the thread list, projects, search, and the new-chat action. Pair it with a runtime's thread store to turn a single chat surface into a multi-conversation app.

**Selector:** `chat-sidenav`

**Import:**

```typescript
import { ChatSidenavComponent } from '@threadplane/chat';
```

## When to Use It

Use `<chat-sidenav>` when users need more than one conversation — history they can return to, rename, archive, or organize into projects.

It expects a backend that can actually enumerate and restore threads. `@threadplane/langgraph` provides that through [`LangGraphThreadsAdapter`](/docs/langgraph/guides/persistence). AG-UI is event-stream-only and defines no thread-lookup endpoint, so on that adapter the thread list is app-owned state you maintain yourself.

<Callout type="warning" title="It renders the sidebar, not a layout wrapper">
Every `<ng-content>` slot on this component is **named**, and each one targets
a region *inside* the sidebar. There is no default slot, so a `<chat>` placed
between the tags is silently dropped — you get a sidebar and an empty pane,
with no error.

Render the chat as a **sibling** and lay the two out yourself. This is the
opposite of [`<chat-sidebar>`](/docs/chat/components/chat-sidebar), which does
project your app content.
</Callout>

## Basic Usage

```typescript
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { ChatComponent, ChatSidenavComponent, type ThreadActionAdapter } from '@threadplane/chat';
import { injectAgent, LangGraphThreadsAdapter, refreshOnRunEnd } from '@threadplane/langgraph';

@Component({
selector: 'app-shell',
standalone: true,
imports: [ChatComponent, ChatSidenavComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<chat-sidenav
[threads]="threads.threads()"
[archivedThreads]="threads.archivedThreads()"
[activeThreadId]="activeThreadId()"
[actions]="threadActions"
[agent]="agent"
(newChat)="activeThreadId.set(null)"
(threadSelected)="activeThreadId.set($event)"
/>
<main class="chat-pane">
<chat [agent]="agent" />
</main>
`,
styles: `
:host { display: flex; height: 100dvh; }
.chat-pane { flex: 1; min-width: 0; }
`,
})
export class AppShellComponent {
protected readonly agent = injectAgent();
protected readonly threads = inject(LangGraphThreadsAdapter);
protected readonly activeThreadId = ACTIVE_THREAD; // module-scope signal

protected readonly threadActions: ThreadActionAdapter = {
rename: async (id, title) => {
await this.threads.rename(id, title);
await this.threads.refresh();
},
delete: async (id) => {
await this.threads.delete(id);
await this.threads.refresh();
},
};

constructor() {
refreshOnRunEnd(this.agent, () => this.threads.refresh());
void this.threads.refresh();
}
}
```

Selecting a conversation is a signal write. When `provideAgent({ threadId: ACTIVE_THREAD })` is wired to the same signal, the adapter watches it and switches conversations — the sidebar never talks to the agent directly. See [Thread Routing](/docs/chat/guides/thread-routing) for keeping that signal in sync with the URL.

## Inputs

| Input | Type | Default | Description |
|---|---|---|---|
| `mode` | `ChatSidenavMode` | `'expanded'` | `'expanded'`, `'collapsed'` (icon rail), or `'drawer'` (overlay). |
| `open` | `boolean` | `false` | Drawer visibility. Supports two-way binding via `openChange`. |
| `threads` | `Thread[] \| null` | `null` | Active conversations, in display order. |
| `archivedThreads` | `Thread[] \| null` | `null` | Threads shown under the Archived disclosure. |
| `activeThreadId` | `string \| null` | `null` | Highlights the matching row. |
| `actions` | `ThreadActionAdapter \| null` | `null` | Per-row menu handlers. Omitted methods hide their menu items. |
| `projects` | `Project[] \| null` | `null` | Optional project grouping. |
| `selectedProjectId` | `string \| null` | `null` | Currently selected project. |
| `projectActions` | `ProjectActionAdapter \| null` | `null` | Project menu handlers. |
| `agent` | `Agent \| AgentWithHistory \| null` | `null` | Powers the devtools panel and history search. |
| `debug` | `boolean` | `true` | Shows the devtools launcher in the footer. |

## Outputs

| Output | Payload | Fires when |
|---|---|---|
| `newChat` | `void` | The new-chat button is clicked. |
| `threadSelected` | `string` | A thread row is chosen. |
| `searchOpened` | `void` | The search affordance is activated. |
| `openChange` | `boolean` | Drawer opens or closes. |
| `modeChange` | `ChatSidenavMode` | The user collapses or expands the rail. |
| `projectSelected` | `string` | A project is chosen. |
| `newProjectRequested` | `void` | The new-project action is clicked. |

## The Thread contract

```typescript
export type Thread = {
id: string;
title?: string; // falls back to a slice of the id
updatedAt?: number; // epoch ms; renders a relative-time line
status?: 'active' | 'archived';
pinned?: boolean;
projectId?: string | null;
[key: string]: unknown;
};
```

Two of these fields are **documentation of intent, not behavior** — the component does not act on them for you:

- **`status` is not auto-filtered.** Pre-filter your list and pass archived rows through the separate `archivedThreads` input.
- **`pinned` is not auto-sorted.** The pin icon renders, but you sort pinned threads to the top yourself.

`LangGraphThreadsAdapter` already does both, which is why the example above passes `threads()` and `archivedThreads()` straight through.

## Row actions

```typescript
export interface ThreadActionAdapter {
delete?(threadId: string): Promise<void>;
rename?(threadId: string, newTitle: string): Promise<void>;
archive?(threadId: string): Promise<void>;
unarchive?(threadId: string): Promise<void>;
pin?(threadId: string): Promise<void>;
unpin?(threadId: string): Promise<void>;
moveToProject?(threadId: string, projectId: string | null): Promise<void>;
reorderPinned?(threadId: string, beforeId: string | null): Promise<void>;
}
```

The framework handles the confirmation dialog for `delete`, the inline editor for `rename`, and optimistic UI with rollback on rejection.

<Callout type="warning" title="Refresh your thread list after every successful action">
Optimistic overrides are cleared in a `finally` block. If an adapter method
resolves but the `threads` input still holds the old data, the row snaps back
to its previous state — which reads as "rename didn't work."
</Callout>

## Drawer mode

On narrow viewports, switch `mode` to `'drawer'` and pair the sidenav with `<chat-sidenav-scrim>` for the dismissable backdrop:

```html
<chat-sidenav-scrim [open]="mode() === 'drawer' && open()" (dismiss)="open.set(false)" />
<chat-sidenav [mode]="mode()" [(open)]="open" … />
```

## What's next

<CardGroup cols={2}>
<Card title="Thread Routing" href="/docs/chat/guides/thread-routing">
Bind the active-thread signal to the URL so links and reloads land correctly.
</Card>
<Card title="LangGraph Persistence" href="/docs/langgraph/guides/persistence">
The server-side thread store behind `LangGraphThreadsAdapter`.
</Card>
</CardGroup>
39 changes: 39 additions & 0 deletions apps/website/content/docs/chat/guides/client-tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,45 @@ export class ClientToolsComponent {

When the cap trips, tools that already produced a real result keep it; tools that never ran are recorded with a limit error so the thread stays valid. The run does not continue.

## Reading client-tool results on the server

When a client tool resolves, its result travels back to your graph as a **tool message keyed by tool-call id, with no tool name on it**. Both adapters send the same minimal shape:

```json
{ "id": "client-tool-result-call_abc", "role": "tool", "tool_call_id": "call_abc", "content": "{\"saved\":1}" }
```

That matters the moment a node tries to find those results. The intuitive filter is by name, and it fails silently — matching nothing, forever, with no error:

```python
# Wrong: client-tool results carry no name, so this is always empty.
saved = [m for m in state["messages"] if isinstance(m, ToolMessage) and m.name == "add_link"]
```

Resolve the name through the AI message that requested the call, then match on the id:

```python
def results_for(messages: list, tool_name: str) -> list:
call_ids = {
call["id"]
for m in messages
for call in getattr(m, "tool_calls", None) or []
if call.get("name") == tool_name
}
return [
m for m in messages
if isinstance(m, ToolMessage) and m.tool_call_id in call_ids
]
```

<Callout type="info" title="Why not just send the name?">
For AG-UI this is fixed by the protocol: `ToolMessageSchema` in `@ag-ui/core`
defines exactly `id`, `role`, `content`, `toolCallId`, and optional `error` /
`encryptedValue`, and parses in strip mode — an extra `name` would be dropped
on the wire. Matching on the call id is the portable approach across both
adapters.
</Callout>

## Typed agent state

Tool handlers and components often read agent state. Pair the registry with a typed `AgentRef` so `agent.state()` / `agent.value()` carry your state shape instead of `Record<string, unknown>` — see [Typed state via AgentRef](/docs/langgraph/api/provide-agent#typed-state-via-agentref):
Expand Down
21 changes: 20 additions & 1 deletion apps/website/content/docs/langgraph/guides/persistence.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,26 @@ LangGraph checkpoints agent state at every super-step. Each checkpoint is keyed

## Python: Checkpointer Setup

Every LangGraph agent needs a checkpointer to persist state between invocations. Which one you choose depends on your environment.
Where the checkpointer comes from depends on how the graph is served, and getting this wrong is the fastest way to a server that won't boot.

<Callout type="warning" title="Serving through langgraph dev or LangGraph Platform? Do not compile a checkpointer.">
The platform provides persistence itself, and it rejects a graph that brings its own. Compile with `builder.compile()` and no argument.

Passing one is not a soft warning — `langgraph dev` fails to load the graph and exits:

```text
ValueError: Heads up! Your graph 'graph' from './graph.py' includes a custom
checkpointer (type <class 'langgraph.checkpoint.memory.InMemorySaver'>). With
LangGraph API, persistence is handled automatically by the platform…
Application startup failed. Exiting.
```

To point the platform at your own database, set the `POSTGRES_URI` environment variable rather than constructing a saver in code.
</Callout>

The checkpointers below apply when you **embed** the graph in your own process — a FastAPI app calling `graph.ainvoke()`, a worker, a script, or an AG-UI server built with `ag-ui-langgraph` (which needs a checkpointer to read state via `aget_state`).

`@threadplane/langgraph` connects to a LangGraph *server*, so if you're following the [Quick Start](/docs/langgraph/getting-started/quickstart) and running `langgraph dev`, you're in the first case and can skip this section.

<Tabs>
<Tab label="MemorySaver (dev)">
Expand Down
1 change: 1 addition & 0 deletions apps/website/src/lib/docs-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,7 @@ export const docsConfig: DocsLibrary[] = [
{ title: 'ChatComponent', slug: 'chat', section: 'components' },
{ title: 'ChatPopup', slug: 'chat-popup', section: 'components' },
{ title: 'ChatSidebar', slug: 'chat-sidebar', section: 'components' },
{ title: 'ChatSidenav', slug: 'chat-sidenav', section: 'components' },
{ title: 'ChatMessageList', slug: 'chat-message-list', section: 'components' },
{ title: 'ChatTrace', slug: 'chat-trace', section: 'components' },
{ title: 'ChatInput', slug: 'chat-input', section: 'components' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,37 @@ interface ChatDebugInstance {
};
}

/**
* The conversation sidebar: thread list, projects, search, and the new-chat
* action. Pair it with a runtime's thread store (e.g. `LangGraphThreadsAdapter`)
* and a {@link ThreadActionAdapter} for the per-row rename/delete/archive menu.
*
* **This component renders the sidebar only — it is not a layout wrapper.** Its
* `<ng-content>` slots are all named (`sidenavHeader`, `sidenavPrimary`,
* `sidenavSections`, `sidenavFooterLeft`, `sidenavFooterRight`,
* `sidenavAccount`) and target regions *inside* the sidebar. There is no default
* slot, so a `<chat>` placed between the tags is silently dropped. Render the
* chat as a sibling and lay the two out yourself:
*
* @example
* ```html
* <chat-sidenav
* [threads]="threads.threads()"
* [activeThreadId]="activeThread()"
* [actions]="threadActions"
* [agent]="agent"
* (newChat)="activeThread.set(null)"
* (threadSelected)="activeThread.set($event)"
* />
* <main class="chat-pane">
* <chat [agent]="agent" />
* </main>
* ```
* ```css
* :host { display: flex; height: 100dvh; }
* .chat-pane { flex: 1; min-width: 0; }
* ```
*/
@Component({
selector: 'chat-sidenav',
standalone: true,
Expand Down
Loading