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:
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. 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 <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>, which does
project your app content.
Basic Usage
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 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
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:
statusis not auto-filtered. Pre-filter your list and pass archived rows through the separatearchivedThreadsinput.pinnedis 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
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.
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 <chat-sidenav-scrim> for the dismissable backdrop:
<chat-sidenav-scrim [open]="mode() === 'drawer' && open()" (dismiss)="open.set(false)" />
<chat-sidenav [mode]="mode()" [(open)]="open" … />