LangGraphThreadsAdapter
LangGraphThreadsAdapter is a root-provided service that wraps client.threads.* from @langchain/langgraph-sdk and maps every SDK thread onto the Thread type that <chat-thread-list> and <chat-sidenav> consume. It is the store behind a thread sidebar: list, create, rename, archive, pin, move, reorder, and delete, without hand-rolling a local thread cache.
The service is declared @Injectable({ providedIn: 'root' }), so inject(LangGraphThreadsAdapter) works anywhere once its configuration token is provided.
Configure
LANGGRAPH_THREADS_CONFIG is the only required provider. It is separate from provideAgent() because the thread store and the agent can point at the same server while living at different injector levels.
// app.config.ts
import type { ApplicationConfig } from '@angular/core';
import { LANGGRAPH_THREADS_CONFIG } from '@threadplane/langgraph';
export const appConfig: ApplicationConfig = {
providers: [
{
provide: LANGGRAPH_THREADS_CONFIG,
useValue: {
apiUrl: 'http://localhost:2024',
titleFallback: 'New chat',
},
},
],
};| Field | Type | Detail |
|---|---|---|
apiUrl | string | Base URL for the LangGraph Platform API. Accepts absolute URLs and relative /api-style paths, the same shapes provideAgent() accepts. |
titleFallback | string (optional) | Label used for a thread whose metadata.title has not been written yet — a thread created but never sent, for instance. Defaults to 'Untitled'. |
The adapter reads the thread label from metadata.title, so the backend has to write it there. The threads example does that from a generate_title node after the user-visible turn; see Thread Routing for that node and its idempotency guard.
What the store exposes
Two read-only signals hold the last fetched snapshot. Nothing subscribes to the server, so both change only when a fetch resolves.
| Signal | Contents |
|---|---|
threads() | Threads whose metadata.archived is not true, sorted with pinned rows first and pinned rows ordered by metadata.pinnedOrder. |
archivedThreads() | Threads whose metadata.archived === true, in server order. |
Each SDK thread is mapped onto the framework Thread shape:
Thread field | Source |
|---|---|
id | thread_id |
title | metadata.title, or titleFallback when it is missing or empty |
status | 'archived' when metadata.archived === true, otherwise 'active' |
pinned | metadata.pinned === true |
projectId | metadata.projectId when it is a non-empty string, otherwise null |
pinnedOrder | metadata.pinnedOrder when it is a number |
updatedAt | Date.parse(updated_at) |
Methods
Every mutation writes through client.threads.update() and then calls refresh() itself, which is exactly what ThreadActionAdapter requires of a consumer: the thread list clears its optimistic row overrides once the adapter promise settles, so the input list has to already carry the new value.
| Method | Returns | Detail |
|---|---|---|
refresh() | Promise<void> | Fetches up to 50 threads and repopulates both signals. Failures are logged through console.error and never rethrown, so a failed refresh leaves the previous snapshot in place. |
getThread(threadId) | Promise<Thread | null> | Fetches one thread. Resolves null for the two outcomes the server reports as missing — 404 for an unknown id and 422 for an id that is not a valid UUID — and rethrows genuine network errors. This is what injectThreadRouting({ validate }) calls. |
create(metadata?) | Promise<string | null> | Creates a thread with the supplied metadata, refreshes, and resolves the new id. Resolves null when the request fails, so check the result before using it. |
rename(threadId, newTitle) | Promise<void> | Writes metadata.title. |
archive(threadId) | Promise<void> | Writes metadata.archived = true, which moves the row from threads() to archivedThreads() on the next refresh. |
unarchive(threadId) | Promise<void> | Writes metadata.archived = false. |
pin(threadId) | Promise<void> | Writes metadata.pinned = true. |
unpin(threadId) | Promise<void> | Writes metadata.pinned = false. |
moveToProject(threadId, projectId) | Promise<void> | Writes metadata.projectId. Pass null to remove the thread from every project. |
reorderPinned(threadId, beforeId) | Promise<void> | Re-stamps metadata.pinnedOrder as 0, 1, 2, … across the whole pinned slice so the new ordering survives a reload. beforeId is the pinned thread the moved row lands in front of, or null to move it to the end. |
Unlike refresh() and create(), the mutating methods reject on failure. <chat-thread-list> awaits each call and rolls its optimistic row change back when the promise rejects, so let the rejection propagate rather than swallowing it.
Pair with the thread list
<chat-thread-list> renders the rows; the application owns the active-thread signal. That signal is the same one handed to provideAgent({ threadId }), which is what makes selecting a row switch the conversation: the agent watches the signal, resets its derived state, and re-fetches that thread's server-side history.
import { Component, inject, signal } from '@angular/core';
import { ChatThreadListComponent, type ThreadActionAdapter } from '@threadplane/chat';
import {
injectAgent,
provideAgent,
LangGraphThreadsAdapter,
refreshOnRunEnd,
} from '@threadplane/langgraph';
/** Source of truth for the active thread. Module scope, because the
* `provideAgent()` config below is evaluated at provider-registration
* time and has to close over it. */
const activeThreadIdState = signal<string | null>(null);
@Component({
selector: 'app-threads-panel',
standalone: true,
imports: [ChatThreadListComponent],
providers: [
provideAgent({
apiUrl: 'http://localhost:2024',
assistantId: 'agent',
threadId: activeThreadIdState,
onThreadId: (id: string) => activeThreadIdState.set(id),
}),
],
template: `
<chat-thread-list
[threads]="threads.threads()"
[activeThreadId]="activeThreadId() ?? ''"
[actions]="actions"
[showNewThreadButton]="true"
(threadSelected)="onThreadSelected($event)"
(newThreadRequested)="onNewThread()"
/>
`,
})
export class ThreadsPanel {
protected readonly agent = injectAgent();
protected readonly threads = inject(LangGraphThreadsAdapter);
protected readonly activeThreadId = activeThreadIdState;
protected readonly actions: ThreadActionAdapter = {
rename: (id, title) => this.threads.rename(id, title),
pin: (id) => this.threads.pin(id),
unpin: (id) => this.threads.unpin(id),
archive: async (id) => {
await this.threads.archive(id);
if (activeThreadIdState() === id) activeThreadIdState.set(null);
},
delete: async (id) => {
await this.threads.delete(id);
if (activeThreadIdState() === id) activeThreadIdState.set(null);
},
};
constructor() {
void this.threads.refresh();
refreshOnRunEnd(this.agent, () => this.threads.refresh());
}
protected onThreadSelected(id: string): void {
activeThreadIdState.set(id);
}
protected async onNewThread(): Promise<void> {
const id = await this.threads.create();
if (id !== null) activeThreadIdState.set(id);
}
}Three details in that template are easy to get wrong:
activeThreadIdisinput<string>(''), not a nullable input, so anullactive thread has to become''. Passingnullis a template type error.- The selection output is
threadSelected, and<chat>re-emits an output of the same name, so either surface can drive the switch. - The agent has no
threadIdmember of its own. The id lives in the application's signal; reading it back off the agent is not possible.
An application that does not bind a threadId signal can call agent.switchThread(id) from the same handler instead. Binding the signal is the better default, because it also survives a provideAgent() factory that reads the id from the router.
<chat-thread-list> builds its row menu from the keys the adapter actually defines, and from [mode]. A list in the default active mode offers Rename, Pin or Unpin, Move up and Move down for a pinned row with reorderPinned, Move to project when [projects] is bound, Archive, and Delete. A list rendered with [mode]="'archived'" — the natural home for archivedThreads() — offers only Unarchive and Delete.
Keeping the list fresh
The store is a snapshot, not a subscription, so something has to re-fetch it. refreshOnRunEnd() fires on every transition out of the running status, which is precisely when a title-writing node has finished.
refreshOnRunEnd(this.agent, () => this.threads.refresh());refreshOnTransition(watch, isActive, fn) is the generic form for any other signal that has an active state: it calls fn whenever isActive(watch()) goes from true to false.
Both helpers use an Angular effect() under the hood, so both must be called from an injection context — a constructor body or a field initializer. refresh() itself is a plain asynchronous call with no such requirement.
Shared SDK client
Provide LANGGRAPH_CLIENT to hand the adapter an SDK Client you already own, or a test double; when the token is absent the adapter builds its own client from apiUrl. Provide LANGGRAPH_CLIENT_OPTIONS once at the application root to tune the SDK retry budget and auth headers shared by FetchStreamTransport and this adapter.
Never generate a thread id in the browser. Use the id create() resolves, the one handed to onThreadId when the transport allocates a thread on first submit, or one the LangGraph Threads API returned earlier.