diff --git a/cockpit/chat/threads/angular/src/app/threads.component.ts b/cockpit/chat/threads/angular/src/app/threads.component.ts
index 222c26502..0b68bfc56 100644
--- a/cockpit/chat/threads/angular/src/app/threads.component.ts
+++ b/cockpit/chat/threads/angular/src/app/threads.component.ts
@@ -1,13 +1,22 @@
// SPDX-License-Identifier: MIT
-import { Component, signal } from '@angular/core';
-import { ChatComponent, ChatThreadListComponent, type Thread } from '@ngaf/chat';
+import { Component, effect, inject, signal } from '@angular/core';
+import {
+ ChatComponent,
+ ChatThreadListComponent,
+ type ThreadActionAdapter,
+} from '@ngaf/chat';
import { agent } from '@ngaf/langgraph';
import { ExampleChatLayoutComponent } from '@ngaf/example-layouts';
import { environment } from '../environments/environment';
+import { ThreadsService } from './threads.service';
/**
* ThreadsComponent demonstrates multi-thread conversation management
- * with ChatComponent and ChatThreadListComponent in a sidebar.
+ * backed by the real LangGraph SDK — mirrors the canonical demo's
+ * shell/threads.service.ts wiring pattern (rename / delete / archive
+ * action adapter + run-status refresh trigger). LLM-generated titles
+ * surface via `metadata.thread_title`, written by the cap's
+ * `generate_title` graph node on each thread's first turn.
*/
@Component({
selector: 'app-threads',
@@ -15,34 +24,93 @@ import { environment } from '../environments/environment';
imports: [ChatComponent, ChatThreadListComponent, ExampleChatLayoutComponent],
template: `
-
+
-
Threads
+
+
Threads
+
+
`,
})
export class ThreadsComponent {
+ protected readonly threadsSvc = inject(ThreadsService);
+
+ /** Writable signal the agent watches — assigning to it switches the
+ * active thread without forcing a full agent rebuild. */
+ protected readonly activeThreadId = signal(null);
+
protected readonly agent = agent({
apiUrl: environment.langGraphApiUrl,
assistantId: environment.streamingAssistantId,
+ threadId: this.activeThreadId,
+ // When the agent auto-creates a thread on first submit, the
+ // adapter calls back with its id; mirror that into our signal so
+ // the sidenav highlights it immediately.
+ onThreadId: (id: string) => this.activeThreadId.set(id),
});
- protected readonly threads = signal([
- { id: 'thread-1', title: 'First Conversation' },
- { id: 'thread-2', title: 'Second Conversation' },
- { id: 'thread-3', title: 'Third Conversation' },
- ]);
+ /** Action adapter: framework calls these on rename / delete / archive
+ * after confirmation. Service handles SDK round-trip + refresh. */
+ protected readonly threadActions: ThreadActionAdapter = {
+ delete: async (id) => {
+ await this.threadsSvc.delete(id);
+ if (this.activeThreadId() === id) this.activeThreadId.set(null);
+ },
+ rename: (id, title) => this.threadsSvc.rename(id, title),
+ archive: async (id) => {
+ await this.threadsSvc.archive(id);
+ if (this.activeThreadId() === id) this.activeThreadId.set(null);
+ },
+ unarchive: (id) => this.threadsSvc.unarchive(id),
+ };
+
+ constructor() {
+ // Initial fetch.
+ void this.threadsSvc.refresh();
- protected readonly activeThreadId = signal('thread-1');
+ // Re-fetch when an agent run completes. The graph's generate_title
+ // node writes metadata.thread_title on the first turn; refreshing
+ // on the running→idle transition surfaces it in the sidenav
+ // without a manual reload.
+ let lastStatus = this.agent.status();
+ effect(() => {
+ const status = this.agent.status();
+ if (lastStatus === 'running' && status !== 'running') {
+ void this.threadsSvc.refresh();
+ }
+ lastStatus = status;
+ });
+ }
protected onThreadSelected(threadId: string): void {
+ // switchThread is the LangGraph adapter's canonical thread-switch API
+ // (resets derived state + reloads server messages for the new thread).
+ this.agent.switchThread(threadId);
this.activeThreadId.set(threadId);
}
+
+ protected async onNewThread(): Promise {
+ const id = await this.threadsSvc.create();
+ if (id) {
+ this.agent.switchThread(id);
+ this.activeThreadId.set(id);
+ }
+ }
}
diff --git a/cockpit/chat/threads/angular/src/app/threads.service.ts b/cockpit/chat/threads/angular/src/app/threads.service.ts
new file mode 100644
index 000000000..362c4d818
--- /dev/null
+++ b/cockpit/chat/threads/angular/src/app/threads.service.ts
@@ -0,0 +1,93 @@
+// SPDX-License-Identifier: MIT
+import { Injectable, signal } from '@angular/core';
+import { Client, type Thread as SdkThread } from '@langchain/langgraph-sdk';
+import type { Thread } from '@ngaf/chat';
+import { environment } from '../environments/environment';
+
+/**
+ * SDK-backed thread store for the c-threads cap.
+ *
+ * Mirrors the canonical demo's ThreadsService (examples/chat/angular/
+ * src/app/shell/threads.service.ts) — the same pattern is duplicated
+ * across consumers because we don't yet expose a shared
+ * `LangGraphThreadsAdapter` from `@ngaf/langgraph`. See the DX notes
+ * in the PR description for the planned hoist.
+ *
+ * Reads `metadata.thread_title` (written by the cap's `generate_title`
+ * graph node — spec 2026-05-19-llm-generated-labels-design.md), not
+ * `metadata.title` like the demo. The two backends will be converged
+ * in a follow-up.
+ */
+
+/** SDK requires an absolute URL; rewrite `/api`-style relative paths
+ * against `window.location.origin` (matches the streaming transport
+ * in fetch-stream.transport.ts). */
+function toAbsoluteApiUrl(apiUrl: string): string {
+ if (apiUrl.startsWith('http://') || apiUrl.startsWith('https://')) return apiUrl;
+ return typeof window !== 'undefined' ? `${window.location.origin}${apiUrl}` : apiUrl;
+}
+
+@Injectable({ providedIn: 'root' })
+export class ThreadsService {
+ private readonly client = new Client({ apiUrl: toAbsoluteApiUrl(environment.langGraphApiUrl) });
+
+ readonly threads = signal([]);
+ readonly archivedThreads = signal([]);
+
+ async refresh(): Promise {
+ try {
+ const list = await this.client.threads.search({ limit: 50 });
+ const mapped = list.map((t) => this.toThread(t));
+ this.threads.set(mapped.filter((t) => t.status !== 'archived'));
+ this.archivedThreads.set(mapped.filter((t) => t.status === 'archived'));
+ } catch {
+ // Backend may be down; leave signals as-is.
+ }
+ }
+
+ async create(): Promise {
+ try {
+ const t = await this.client.threads.create();
+ await this.refresh();
+ return t.thread_id;
+ } catch {
+ return null;
+ }
+ }
+
+ async delete(threadId: string): Promise {
+ await this.client.threads.delete(threadId);
+ await this.refresh();
+ }
+
+ async rename(threadId: string, newTitle: string): Promise {
+ await this.client.threads.update(threadId, { metadata: { thread_title: newTitle } });
+ await this.refresh();
+ }
+
+ async archive(threadId: string): Promise {
+ await this.client.threads.update(threadId, { metadata: { archived: true } });
+ await this.refresh();
+ }
+
+ async unarchive(threadId: string): Promise {
+ await this.client.threads.update(threadId, { metadata: { archived: false } });
+ await this.refresh();
+ }
+
+ /** Best-effort title from thread metadata. Falls back to "Untitled"
+ * for brand-new threads where the generate_title node hasn't run
+ * yet (matches the demo's convention — easier on the eye than a
+ * UUID slice). */
+ private toThread(t: SdkThread): Thread {
+ const meta = (t.metadata ?? {}) as { thread_title?: unknown; archived?: unknown };
+ const title = meta.thread_title;
+ const archived = meta.archived === true;
+ return {
+ id: t.thread_id,
+ title: typeof title === 'string' && title.length > 0 ? title : 'Untitled',
+ status: archived ? 'archived' : 'active',
+ updatedAt: t.updated_at ? Date.parse(t.updated_at) : undefined,
+ };
+ }
+}
diff --git a/cockpit/chat/threads/python/src/graph.py b/cockpit/chat/threads/python/src/graph.py
index 0d0b03c2d..89dd7c8df 100644
--- a/cockpit/chat/threads/python/src/graph.py
+++ b/cockpit/chat/threads/python/src/graph.py
@@ -1,23 +1,79 @@
"""
Chat Threads Graph
-A standard conversational agent. Thread management (creating, switching,
-persisting) is handled by the frontend and LangGraph SDK, not the graph itself.
+Conversational agent with inline thread-title generation. Each new
+thread gets an LLM-generated 3-5 word title written to LangGraph
+thread metadata on the first turn (idempotent — subsequent turns skip
+the write). The chat-threads frontend reads `metadata.thread_title`
+from `client.threads.search()` and displays it in the sidenav.
+
+Pattern D from spec 2026-05-19-llm-generated-labels-design.md: the
+generate_title node lives inline in this file (not extracted to a
+shared helper) so a developer reading this cap sees the entire agent
+in one place.
"""
+import os
from pathlib import Path
-from langgraph.graph import StateGraph, MessagesState, END
+from langchain_core.messages import HumanMessage, SystemMessage
from langchain_openai import ChatOpenAI
-from langchain_core.messages import SystemMessage
+from langgraph.graph import StateGraph, MessagesState, END
+from langgraph_sdk import get_client
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
+# ── generate_title node (inline; matches Pattern D from spec
+# 2026-05-19-llm-generated-labels-design.md) ──────────────────────────────
-def build_threads_graph():
- """
- Constructs a standard conversational agent.
- Threads are managed by the LangGraph SDK on the frontend side.
+_TITLE_PROMPT = (
+ "In 3-5 words, summarize what the user is asking about. "
+ "Output ONLY the title — no quotes, no period, no prefix."
+)
+_TITLE_MODEL = "gpt-5-mini"
+
+
+async def generate_title(state: MessagesState, config) -> dict:
+ """Background title generation: on the first turn, summarize the user's
+ intent into 3-5 words and persist to LangGraph thread metadata so the
+ sidenav shows something meaningful instead of a UUID slice.
+
+ Idempotent — skips when metadata.thread_title already exists. Errors
+ are swallowed (title is a UX nicety, never a blocker). Runs after the
+ user-visible turn so it never blocks the response.
"""
+ thread_id = (config.get("configurable") or {}).get("thread_id")
+ if not thread_id:
+ return {}
+ sdk_url = os.environ.get("LANGGRAPH_API_URL", "http://localhost:2024")
+ try:
+ client = get_client(url=sdk_url)
+ thread = await client.threads.get(thread_id)
+ if (thread.get("metadata") or {}).get("thread_title"):
+ return {}
+ first_user = next(
+ (m for m in state["messages"] if getattr(m, "type", None) == "human"),
+ None,
+ )
+ if not first_user or not isinstance(first_user.content, str):
+ return {}
+ # Skip action-message JSON (those flow as human-role too)
+ if first_user.content.lstrip().startswith("{"):
+ return {}
+ llm = ChatOpenAI(model=_TITLE_MODEL, temperature=0)
+ response = await llm.ainvoke([
+ SystemMessage(content=_TITLE_PROMPT),
+ HumanMessage(content=first_user.content),
+ ])
+ title = (response.content or "").strip().strip('"').strip("'")[:80]
+ if title:
+ await client.threads.update(thread_id, metadata={"thread_title": title})
+ except Exception: # noqa: BLE001 — title is a UX nicety; never block
+ pass
+ return {}
+
+
+def build_threads_graph():
+ """Standard conversational agent + inline title gen on first turn."""
llm = ChatOpenAI(model="gpt-5-mini", streaming=True)
async def generate(state: MessagesState) -> dict:
@@ -28,8 +84,10 @@ async def generate(state: MessagesState) -> dict:
graph = StateGraph(MessagesState)
graph.add_node("generate", generate)
+ graph.add_node("generate_title", generate_title)
graph.set_entry_point("generate")
- graph.add_edge("generate", END)
+ graph.add_edge("generate", "generate_title")
+ graph.add_edge("generate_title", END)
return graph.compile()