Chat ยท Components

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.

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>, 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

InputTypeDefaultDescription
modeChatSidenavMode'expanded''expanded', 'collapsed' (icon rail), or 'drawer' (overlay).
openbooleanfalseDrawer visibility. Supports two-way binding via openChange.
threadsThread[] | nullnullActive conversations, in display order.
archivedThreadsThread[] | nullnullThreads shown under the Archived disclosure.
activeThreadIdstring | nullnullHighlights the matching row.
actionsThreadActionAdapter | nullnullPer-row menu handlers. Omitted methods hide their menu items.
projectsProject[] | nullnullOptional project grouping.
selectedProjectIdstring | nullnullCurrently selected project.
projectActionsProjectActionAdapter | nullnullProject menu handlers.
agentAgent | AgentWithHistory | nullnullPowers the devtools panel and history search.
debugbooleantrueShows the devtools launcher in the footer.

#Outputs

OutputPayloadFires when
newChatvoidThe new-chat button is clicked.
threadSelectedstringA thread row is chosen.
searchOpenedvoidThe search affordance is activated.
openChangebooleanDrawer opens or closes.
modeChangeChatSidenavModeThe user collapses or expands the rail.
projectSelectedstringA project is chosen.
newProjectRequestedvoidThe 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:

  • 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

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.

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."

#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" โ€ฆ />

#What's next