diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index c3cf53adf..e62fe6d67 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -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`` 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 `` placed between the tags is silently dropped. Render the\nchat as a sibling and lay the two out yourself:", "params": [], - "examples": [], + "examples": [ + "```html\n\n
\n \n
\n```\n```css\n:host { display: flex; height: 100dvh; }\n.chat-pane { flex: 1; min-width: 0; }\n```" + ], "properties": [ { "name": "actions", diff --git a/apps/website/content/docs/chat/components/chat-sidenav.mdx b/apps/website/content/docs/chat/components/chat-sidenav.mdx new file mode 100644 index 000000000..cc4d56e2f --- /dev/null +++ b/apps/website/content/docs/chat/components/chat-sidenav.mdx @@ -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 `` 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. + + + Every `` slot on this component is **named**, and each one targets + a region *inside* the sidebar. There is no default slot, so a `` 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 [``](/docs/chat/components/chat-sidebar), which does + project your app content. + + +## 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: ` + +
+ +
+ `, + 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; + rename?(threadId: string, newTitle: string): Promise; + archive?(threadId: string): Promise; + unarchive?(threadId: string): Promise; + pin?(threadId: string): Promise; + unpin?(threadId: string): Promise; + moveToProject?(threadId: string, projectId: string | null): Promise; + reorderPinned?(threadId: string, beforeId: string | null): Promise; +} +``` + +The framework handles the confirmation dialog for `delete`, the inline editor for `rename`, and optimistic UI with rollback on rejection. + + + 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." + + +## Drawer mode + +On narrow viewports, switch `mode` to `'drawer'` and pair the sidenav with `` for the dismissable backdrop: + +```html + + +``` + +## What's next + + + + Bind the active-thread signal to the URL so links and reloads land correctly. + + + The server-side thread store behind `LangGraphThreadsAdapter`. + + diff --git a/apps/website/content/docs/chat/guides/client-tools.mdx b/apps/website/content/docs/chat/guides/client-tools.mdx index b083c8c20..bd834b64b 100644 --- a/apps/website/content/docs/chat/guides/client-tools.mdx +++ b/apps/website/content/docs/chat/guides/client-tools.mdx @@ -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 + ] +``` + + + 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. + + ## 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` — see [Typed state via AgentRef](/docs/langgraph/api/provide-agent#typed-state-via-agentref): diff --git a/apps/website/content/docs/langgraph/guides/persistence.mdx b/apps/website/content/docs/langgraph/guides/persistence.mdx index d7a29f6db..f3db078e8 100644 --- a/apps/website/content/docs/langgraph/guides/persistence.mdx +++ b/apps/website/content/docs/langgraph/guides/persistence.mdx @@ -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. + + +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 ). 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. + + +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. diff --git a/apps/website/src/lib/docs-config.ts b/apps/website/src/lib/docs-config.ts index f3dc85a15..d7aa6647b 100644 --- a/apps/website/src/lib/docs-config.ts +++ b/apps/website/src/lib/docs-config.ts @@ -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' }, diff --git a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts index 9d0bb394b..f99a83192 100644 --- a/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts +++ b/libs/chat/src/lib/compositions/chat-sidenav/chat-sidenav.component.ts @@ -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 + * `` slots are all named (`sidenavHeader`, `sidenavPrimary`, + * `sidenavSections`, `sidenavFooterLeft`, `sidenavFooterRight`, + * `sidenavAccount`) and target regions *inside* the sidebar. There is no default + * slot, so a `` placed between the tags is silently dropped. Render the + * chat as a sibling and lay the two out yourself: + * + * @example + * ```html + * + *
+ * + *
+ * ``` + * ```css + * :host { display: flex; height: 100dvh; } + * .chat-pane { flex: 1; min-width: 0; } + * ``` + */ @Component({ selector: 'chat-sidenav', standalone: true,