Agent Chat Channel

Real-time conversational interface between engineers and domain agents, embedded in the Dashboard and IDE extensions.

Table of contents

  1. Overview
    1. Feature Matrix
  2. New TypeScript Types
    1. src/types/chat.ts
    2. WebSocket Event Payloads
  3. WebSocket Event Types
    1. EVENT_MAP Additions
  4. REST Endpoints
    1. src/api/endpoints/chat.ts
  5. Hooks
    1. src/hooks/use-chat.ts
  6. Zustand Store
    1. src/store/chat-store.ts
  7. Dashboard Integration
    1. Persistent Sidebar — ChatSidebar
    2. Approvals Page — ApprovalChatPanel
    3. BOM Page — ComponentChatPanel
    4. Digital Twin Page — NodeChatPanel
    5. Sessions Page — SessionChatPanel
  8. IDE Integration
    1. VS Code Extension — Chat Panel
    2. KiCad Plugin — Chat Widget
    3. Shared Types Package
  9. Backend Architecture
    1. Kafka Topic
    2. EventType Additions
    3. Temporal Activity — handle_chat_message
    4. PostgreSQL Schema
  10. Component Specifications
    1. ChatPanel — Base Composite
    2. ChatMessageBubble — Message Display
    3. TypingIndicator — Animated Dots
    4. ChatSidebar — Sheet Overlay
    5. ChatComposer — Message Input
    6. ApprovalChatPanel — Thin Wrapper
  11. Context Assembly
  12. Message Lifecycle
  13. Component Hierarchy
  14. WebSocket Streaming Flow
  15. Relationship to Existing Features
  16. Related Documents

Overview

The Agent Chat Channel enables engineers to have real-time, contextual conversations with domain agents (ME, EE, FW, SC, SIM) while working in their tools. Instead of the current one-directional interaction model (agents propose, humans approve/reject), chat turns this into a dialogue — engineers can ask questions, request re-runs, and get explanations before making decisions.

Two rendering surfaces share one backend:

  • Dashboard — persistent sidebar and contextual panels on Approvals, BOM, Digital Twin, and Sessions pages
  • IDE extensions — VS Code sidebar panel, KiCad plugin panel (same WebSocket protocol, same message types)

This feature is scoped to design-context conversations — questions about specific approvals, components, simulation results, and session traces. It is NOT a general messaging or team chat system.

Feature Matrix

Entry Point Surface Scope Default Agent
Dashboard sidebar Dashboard project Orchestrator-chosen
Approvals panel Dashboard approval Agent that proposed the change
BOM panel Dashboard bom-entry SC (Supply Chain)
Digital Twin panel Dashboard digital-twin-node ME (Mechanical)
Sessions panel Dashboard session Orchestrator-chosen
VS Code sidebar IDE project or file-scoped FW (Firmware)
KiCad plugin panel IDE bom-entry or schematic-scoped EE (Electronics)

New TypeScript Types

src/types/chat.ts

All types follow existing conventions: interface for objects, type for unions, ISO string timestamps, UUID string IDs.

// ---------------------------------------------------------------------------
// Actor types
// ---------------------------------------------------------------------------

/** Who can participate in a chat conversation */
export type ChatActorKind = 'user' | 'agent' | 'system';

/** A participant in a chat conversation */
export interface ChatActor {
  /** Unique identifier (user ID or agent code) */
  id: string;

  /** Actor classification */
  kind: ChatActorKind;

  /** Display name (e.g. "Jane Doe" or "Mechanical Agent") */
  displayName: string;

  /** Agent code when kind is 'agent' (e.g. 'ME', 'EE', 'FW') */
  agentCode?: string;
}

// ---------------------------------------------------------------------------
// Message types
// ---------------------------------------------------------------------------

/** Delivery status of a chat message */
export type MessageStatus = 'sending' | 'sent' | 'delivered' | 'error';

/** A single message in a chat thread */
export interface ChatMessage {
  /** Unique message identifier */
  id: string;

  /** Thread this message belongs to */
  threadId: string;

  /** Who sent this message */
  actor: ChatActor;

  /** Message content (Markdown supported) */
  content: string;

  /** Current delivery status */
  status: MessageStatus;

  /** ISO timestamp when the message was created */
  createdAt: string;

  /** ISO timestamp when the message was last updated (e.g. streaming complete) */
  updatedAt?: string;

  /** Optional reference to a graph entity this message relates to */
  graphRef?: ChatGraphRef;
}

/** Reference to a graph entity for contextual messages */
export interface ChatGraphRef {
  /** Node ID in the design graph */
  nodeId: string;

  /** Node type (e.g. 'requirement', 'component', 'test') */
  nodeType: string;

  /** Human-readable label */
  label: string;
}

// ---------------------------------------------------------------------------
// Scope types
// ---------------------------------------------------------------------------

/** Defines the context for a chat conversation */
export interface ChatScope {
  /** What kind of entity this conversation is about */
  kind: 'session' | 'approval' | 'bom-entry' | 'digital-twin-node' | 'project';

  /** ID of the scoped entity (session ID, approval ID, BOM entry ID, node ID, or project ID) */
  entityId: string;

  /** Optional human-readable label for the scoped entity */
  label?: string;
}

// ---------------------------------------------------------------------------
// Thread and channel types
// ---------------------------------------------------------------------------

/** A conversation thread between an engineer and one or more agents */
export interface ChatThread {
  /** Unique thread identifier */
  id: string;

  /** Conversation scope — what this thread is about */
  scope: ChatScope;

  /** Channel this thread belongs to */
  channelId: string;

  /** Thread title (auto-generated or user-defined) */
  title: string;

  /** All messages in this thread (may be paginated) */
  messages: ChatMessage[];

  /** Participants in this thread */
  participants: ChatActor[];

  /** ISO timestamp when the thread was created */
  createdAt: string;

  /** ISO timestamp of the most recent message */
  lastMessageAt: string;

  /** Whether the thread is archived */
  archived: boolean;
}

/** A chat channel groups threads by scope kind */
export interface ChatChannel {
  /** Unique channel identifier */
  id: string;

  /** Channel display name (e.g. "Approvals", "BOM", "General") */
  name: string;

  /** Scope kind this channel serves */
  scopeKind: ChatScope['kind'];

  /** Number of unread messages across all threads in this channel */
  unreadCount: number;
}

WebSocket Event Payloads

/** Payload for chat.message.created events */
export interface ChatMessageCreatedEvent {
  /** The complete message that was created */
  message: ChatMessage;

  /** Thread ID for targeted UI updates */
  threadId: string;
}

/** Payload for chat.message.chunk events (streaming LLM responses) */
export interface ChatMessageChunkEvent {
  /** Message ID being streamed */
  messageId: string;

  /** Thread ID for targeted UI updates */
  threadId: string;

  /** The text chunk to append */
  chunk: string;

  /** Whether this is the final chunk */
  done: boolean;
}

/** Payload for chat.agent.typing events */
export interface ChatAgentTypingEvent {
  /** Thread ID where the agent is typing */
  threadId: string;

  /** The agent that is typing */
  agent: ChatActor;

  /** Whether the agent started or stopped typing */
  isTyping: boolean;
}

WebSocket Event Types

Four new values added to the WebSocketEventType union in src/types/websocket.ts:

export type WebSocketEventType =
  // ... existing event types ...
  | 'chat.message.created'
  | 'chat.message.chunk'
  | 'chat.agent.typing'
  | 'chat.thread.created';

EVENT_MAP Additions

New entries in the EVENT_MAP in src/hooks/use-event-stream.ts:

const EVENT_MAP: Record<string, InvalidationRule> = {
  // ... existing entries ...

  "chat.message.created": {
    keys: [["chat-threads"]],
  },
  "chat.thread.created": {
    keys: [["chat-threads"], ["chat-channels"]],
  },
};

chat.message.chunk and chat.agent.typing events are handled imperatively in the chat-store (via appendStreamChunk and setAgentTyping), not through TanStack Query cache invalidation. Streaming content updates at high frequency — invalidating the query cache on every chunk would cause unnecessary re-renders.


REST Endpoints

src/api/endpoints/chat.ts

Follows the existing Axios client pattern established in src/api/client.ts.

import { client } from "@/api/client";
import type {
  ChatThread,
  ChatChannel,
  ChatMessage,
  ChatScope,
} from "@/types/chat";
import type { PaginatedResponse } from "@/types/common";

// ---------------------------------------------------------------------------
// Query parameters
// ---------------------------------------------------------------------------

export interface GetChatThreadsParams {
  /** Filter by channel ID */
  channelId?: string;
  /** Filter by scope kind */
  scopeKind?: ChatScope['kind'];
  /** Filter by scope entity ID */
  entityId?: string;
  /** Include archived threads */
  includeArchived?: boolean;
  /** Pagination limit */
  limit?: number;
  /** Pagination offset */
  offset?: number;
}

export interface CreateChatThreadPayload {
  /** Conversation scope */
  scope: ChatScope;
  /** Optional initial message content */
  initialMessage?: string;
  /** Optional thread title (auto-generated if omitted) */
  title?: string;
}

export interface SendChatMessagePayload {
  /** Message content (Markdown) */
  content: string;
  /** Optional graph reference */
  graphRef?: {
    nodeId: string;
    nodeType: string;
    label: string;
  };
}

// ---------------------------------------------------------------------------
// Endpoints
// ---------------------------------------------------------------------------

/**
 * List chat threads with optional filtering and pagination.
 */
export async function getChatThreads(
  params?: GetChatThreadsParams,
): Promise<PaginatedResponse<ChatThread>> {
  const { data } = await client.get<PaginatedResponse<ChatThread>>(
    "/chat/threads",
    { params },
  );
  return data;
}

/**
 * Retrieve a single chat thread by ID, including all messages.
 */
export async function getChatThread(id: string): Promise<ChatThread> {
  const { data } = await client.get<ChatThread>(`/chat/threads/${id}`);
  return data;
}

/**
 * Create a new chat thread with a given scope.
 */
export async function createChatThread(
  payload: CreateChatThreadPayload,
): Promise<ChatThread> {
  const { data } = await client.post<ChatThread>("/chat/threads", payload);
  return data;
}

/**
 * Send a message in an existing chat thread.
 * The backend routes the message to the appropriate domain agent.
 */
export async function sendChatMessage(
  threadId: string,
  payload: SendChatMessagePayload,
): Promise<ChatMessage> {
  const { data } = await client.post<ChatMessage>(
    `/chat/threads/${threadId}/messages`,
    payload,
  );
  return data;
}

/**
 * List available chat channels.
 */
export async function getChatChannels(): Promise<ChatChannel[]> {
  const { data } = await client.get<{ channels: ChatChannel[] }>(
    "/chat/channels",
  );
  return data.channels;
}

Hooks

src/hooks/use-chat.ts

Follows use-approvals.ts as the model: TanStack Query for data fetching, mutations with targeted cache invalidation.

import {
  useQuery,
  useMutation,
  useQueryClient,
  type UseQueryOptions,
} from "@tanstack/react-query";
import {
  getChatThreads,
  getChatThread,
  getChatChannels,
  createChatThread,
  sendChatMessage,
  type GetChatThreadsParams,
  type CreateChatThreadPayload,
  type SendChatMessagePayload,
} from "@/api/endpoints/chat";
import type { PaginatedResponse } from "@/types/common";
import type { ChatThread, ChatChannel, ChatMessage } from "@/types/chat";

// ---------------------------------------------------------------------------
// Queries
// ---------------------------------------------------------------------------

/**
 * Fetch chat threads with optional filtering. Refetches every 30 seconds.
 */
export function useChatThreads(
  params?: GetChatThreadsParams,
  options?: Partial<UseQueryOptions<PaginatedResponse<ChatThread>>>,
) {
  return useQuery<PaginatedResponse<ChatThread>>({
    queryKey: ["chat-threads", params],
    queryFn: () => getChatThreads(params),
    staleTime: 30_000,
    refetchInterval: 30_000,
    ...options,
  });
}

/**
 * Fetch a single chat thread by ID, including all messages.
 */
export function useChatThread(
  id: string | undefined,
  options?: Partial<UseQueryOptions<ChatThread>>,
) {
  return useQuery<ChatThread>({
    queryKey: ["chat-thread", id],
    queryFn: () => getChatThread(id!),
    enabled: !!id,
    staleTime: 10_000,
    ...options,
  });
}

/**
 * Fetch available chat channels.
 */
export function useChatChannels(
  options?: Partial<UseQueryOptions<ChatChannel[]>>,
) {
  return useQuery<ChatChannel[]>({
    queryKey: ["chat-channels"],
    queryFn: getChatChannels,
    staleTime: 60_000,
    ...options,
  });
}

// ---------------------------------------------------------------------------
// Mutations
// ---------------------------------------------------------------------------

/**
 * Create a new chat thread. Invalidates the thread list on success.
 */
export function useCreateChatThread() {
  const queryClient = useQueryClient();

  return useMutation<ChatThread, Error, CreateChatThreadPayload>({
    mutationFn: createChatThread,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["chat-threads"] });
      queryClient.invalidateQueries({ queryKey: ["chat-channels"] });
    },
  });
}

/**
 * Send a message in a chat thread. Invalidates the specific thread on success.
 */
export function useSendChatMessage() {
  const queryClient = useQueryClient();

  return useMutation<
    ChatMessage,
    Error,
    { threadId: string; payload: SendChatMessagePayload }
  >({
    mutationFn: ({ threadId, payload }) =>
      sendChatMessage(threadId, payload),
    onSuccess: (_data, { threadId }) => {
      queryClient.invalidateQueries({ queryKey: ["chat-thread", threadId] });
      queryClient.invalidateQueries({ queryKey: ["chat-threads"] });
    },
  });
}

Zustand Store

src/store/chat-store.ts

Manages ephemeral chat UI state that does not belong in the server-state cache (TanStack Query). Streaming content and typing indicators update at high frequency and are handled imperatively.

import { create } from "zustand";

// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------

export interface ChatStoreState {
  /** Whether the persistent chat sidebar is open. */
  sidebarOpen: boolean;

  /** ID of the thread currently displayed in the sidebar. */
  activeSidebarThreadId: string | null;

  /**
   * Streaming content accumulator, keyed by message ID.
   * Each entry holds the concatenated chunks received so far.
   */
  streamingContent: Record<string, string>;

  /**
   * Set of thread IDs where an agent is currently typing.
   */
  typingThreadIds: Set<string>;
}

export interface ChatStoreActions {
  /** Open the sidebar, optionally jumping to a specific thread. */
  openSidebar: (threadId?: string) => void;

  /** Close the sidebar. */
  closeSidebar: () => void;

  /** Append a streaming chunk to a message's accumulator. */
  appendStreamChunk: (messageId: string, chunk: string) => void;

  /** Clear streaming content for a message (called when streaming completes). */
  clearStreamContent: (messageId: string) => void;

  /** Set the typing indicator for a thread. */
  setAgentTyping: (threadId: string, isTyping: boolean) => void;
}

export type ChatStore = ChatStoreState & ChatStoreActions;

// ---------------------------------------------------------------------------
// Store
// ---------------------------------------------------------------------------

export const useChatStore = create<ChatStore>()((set) => ({
  // -- state defaults -------------------------------------------------------
  sidebarOpen: false,
  activeSidebarThreadId: null,
  streamingContent: {},
  typingThreadIds: new Set(),

  // -- actions --------------------------------------------------------------

  openSidebar: (threadId) =>
    set({
      sidebarOpen: true,
      ...(threadId ? { activeSidebarThreadId: threadId } : {}),
    }),

  closeSidebar: () =>
    set({ sidebarOpen: false }),

  appendStreamChunk: (messageId, chunk) =>
    set((state) => ({
      streamingContent: {
        ...state.streamingContent,
        [messageId]: (state.streamingContent[messageId] ?? "") + chunk,
      },
    })),

  clearStreamContent: (messageId) =>
    set((state) => {
      const { [messageId]: _, ...rest } = state.streamingContent;
      return { streamingContent: rest };
    }),

  setAgentTyping: (threadId, isTyping) =>
    set((state) => {
      const next = new Set(state.typingThreadIds);
      if (isTyping) {
        next.add(threadId);
      } else {
        next.delete(threadId);
      }
      return { typingThreadIds: next };
    }),
}));

Dashboard Integration

Five integration points embed chat into the existing Dashboard pages.

Persistent Sidebar — ChatSidebar

A Sheet-based overlay mounted as a sibling of the main content area in root-layout.tsx. The MessageSquare icon in the topbar replaces the placeholder Bell notification icon, toggling the sidebar open/closed.

// src/components/chat/chat-sidebar.tsx
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { useChatStore } from "@/store/chat-store";
import { ChatPanel } from "./chat-panel";
import { useChatThreads, useChatThread } from "@/hooks/use-chat";

export function ChatSidebar() {
  const sidebarOpen = useChatStore((s) => s.sidebarOpen);
  const closeSidebar = useChatStore((s) => s.closeSidebar);
  const activeThreadId = useChatStore((s) => s.activeSidebarThreadId);

  const { data: threads } = useChatThreads();
  const { data: activeThread } = useChatThread(activeThreadId ?? undefined);

  return (
    <Sheet open={sidebarOpen} onOpenChange={(open) => !open && closeSidebar()}>
      <SheetContent side="right" className="w-[400px] sm:w-[480px] p-0">
        <SheetHeader className="px-4 py-3 border-b">
          <SheetTitle>Agent Chat</SheetTitle>
        </SheetHeader>

        {activeThread ? (
          <ChatPanel thread={activeThread} />
        ) : (
          /* Thread list view */
          <div className="p-4 space-y-2">
            {threads?.data.map((thread) => (
              /* Thread list item — click to select */
              <div key={thread.id} /* ... */ />
            ))}
          </div>
        )}
      </SheetContent>
    </Sheet>
  );
}

Topbar changes — In src/components/layout/topbar.tsx, the Bell icon button is replaced by a MessageSquare icon that toggles the chat sidebar:

import { MessageSquare } from "lucide-react";
import { useChatStore } from "@/store/chat-store";

// Inside Topbar component:
const toggleChatSidebar = useChatStore((s) =>
  s.sidebarOpen ? s.closeSidebar : () => s.openSidebar()
);

// Replaces the Bell icon button:
<Tooltip>
  <TooltipTrigger asChild>
    <Button variant="ghost" size="icon" onClick={toggleChatSidebar} aria-label="Chat">
      <MessageSquare className="h-4 w-4" />
    </Button>
  </TooltipTrigger>
  <TooltipContent>Agent Chat</TooltipContent>
</Tooltip>

Root layout changes — In src/components/layout/root-layout.tsx, ChatSidebar is mounted as a sibling to the main content area, outside the scroll container.

Approvals Page — ApprovalChatPanel

Replaces the static ApprovalComment[] comments section in pending-approvals-list.tsx. Renders inline below the diff viewer within expanded approval cards.

// src/pages/approvals/components/approval-chat-panel.tsx
import { ChatPanel } from "@/components/chat/chat-panel";
import { useChatThreads, useCreateChatThread } from "@/hooks/use-chat";
import type { Approval } from "@/types/approval";

interface ApprovalChatPanelProps {
  approval: Approval;
}

export function ApprovalChatPanel({ approval }: ApprovalChatPanelProps) {
  const { data: threads } = useChatThreads({
    scopeKind: "approval",
    entityId: approval.id,
  });
  const createThread = useCreateChatThread();

  const thread = threads?.data[0]; // One thread per approval

  // Auto-create thread on first render if none exists
  // ...

  return thread ? (
    <ChatPanel thread={thread} compact />
  ) : (
    <div className="text-center py-4 text-sm text-muted-foreground">
      Start a conversation about this approval...
    </div>
  );
}

BOM Page — ComponentChatPanel

Row-level chat action in the BOM table. When an engineer clicks the chat icon on a BOM row, a chat panel opens scoped to that component with the SC (Supply Chain) agent.

// src/pages/bom/components/component-chat-panel.tsx
import { ChatPanel } from "@/components/chat/chat-panel";
import { useChatThreads } from "@/hooks/use-chat";
import type { BOMEntry } from "@/types/bom";

interface ComponentChatPanelProps {
  entry: BOMEntry;
}

export function ComponentChatPanel({ entry }: ComponentChatPanelProps) {
  const { data: threads } = useChatThreads({
    scopeKind: "bom-entry",
    entityId: entry.id,
  });
  const thread = threads?.data[0];
  return thread ? <ChatPanel thread={thread} compact /> : null;
}

Digital Twin Page — NodeChatPanel

Toolbar button in the 3D viewer. When an engineer selects a mesh node and clicks the chat button, a panel opens scoped to that node with the ME (Mechanical) agent.

// src/pages/digital-twin/components/node-chat-panel.tsx
import { ChatPanel } from "@/components/chat/chat-panel";
import { useChatThreads } from "@/hooks/use-chat";

interface NodeChatPanelProps {
  nodeId: string;
  nodeLabel: string;
}

export function NodeChatPanel({ nodeId, nodeLabel }: NodeChatPanelProps) {
  const { data: threads } = useChatThreads({
    scopeKind: "digital-twin-node",
    entityId: nodeId,
  });
  const thread = threads?.data[0];
  return thread ? <ChatPanel thread={thread} compact /> : null;
}

Sessions Page — SessionChatPanel

Renders below the trace viewer in the session detail page. The orchestrator chooses the most relevant agent based on the session’s current trace context.

// src/pages/sessions/components/session-chat-panel.tsx
import { ChatPanel } from "@/components/chat/chat-panel";
import { useChatThreads } from "@/hooks/use-chat";

interface SessionChatPanelProps {
  sessionId: string;
}

export function SessionChatPanel({ sessionId }: SessionChatPanelProps) {
  const { data: threads } = useChatThreads({
    scopeKind: "session",
    entityId: sessionId,
  });
  const thread = threads?.data[0];
  return thread ? <ChatPanel thread={thread} /> : null;
}

IDE Integration

IDE extensions share the same WebSocket protocol and message types as the Dashboard, packaged in a shared @metaforge/chat-types npm package.

VS Code Extension — Chat Panel

The VS Code extension renders a chat panel as a webview in the sidebar. It detects file and cursor context automatically to scope conversations.

vscode-extension/
├── src/
│   ├── chat/
│   │   ├── chat-panel.ts          # Webview provider
│   │   ├── chat-view.html         # Panel HTML template
│   │   ├── context-detector.ts    # File/cursor → ChatScope resolution
│   │   └── ws-client.ts           # WebSocket connection to Gateway
│   └── extension.ts               # Extension entry point
├── package.json
└── tsconfig.json

Context detection — When the engineer has a file open, context-detector.ts resolves it to a ChatScope:

File Pattern Scope Kind Entity Resolution
*.kicad_sch, *.kicad_pcb bom-entry Parse selected component reference designator
*.c, *.h, pinmap.json session Resolve to active firmware session
*.FCStd, *.step digital-twin-node Resolve to selected part node
Other / no file project Project-level scope

KiCad Plugin — Chat Widget

The KiCad plugin embeds a chat widget as a Qt panel. When the engineer selects a component on the schematic or PCB, the widget scopes the conversation to that component.

kicad-plugin/
├── chat_widget.py         # Qt panel implementation
├── context_resolver.py    # Selected component → ChatScope
├── ws_client.py           # WebSocket connection to Gateway
└── __init__.py

Shared Types Package

packages/chat-types/
├── src/
│   ├── index.ts           # Barrel export
│   ├── chat.ts            # ChatMessage, ChatThread, ChatScope, etc.
│   └── events.ts          # WebSocket event payloads
├── package.json
└── tsconfig.json

Both the Dashboard and IDE extensions depend on @metaforge/chat-types to ensure message format consistency.


Backend Architecture

Kafka Topic

A new Kafka topic agent.chat carries all chat-related events.

topics:
  # ... existing topics ...
  - "agent.chat"          # Chat messages, typing indicators, thread lifecycle
Topic Retention Rationale
agent.chat 90 days Conversation history for context; older threads archived to PostgreSQL

EventType Additions

Four new values for the EventType enum in digital_twin/events.py:

class EventType(str, Enum):
    # ... existing types ...

    # Agent Chat Channel
    CHAT_MESSAGE_SENT = "chat.message.sent"
    CHAT_MESSAGE_CHUNK = "chat.message.chunk"
    CHAT_THREAD_CREATED = "chat.thread.created"
    CHAT_AGENT_TYPING = "chat.agent.typing"

Temporal Activity — handle_chat_message

When an engineer sends a chat message, the Gateway publishes it to Kafka and triggers a Temporal activity that assembles context, invokes the LLM, and streams the response back.

@activity.defn
async def handle_chat_message(
    thread_id: str,
    message_id: str,
    content: str,
    scope: dict,
    actor_id: str,
) -> dict:
    """Process an incoming chat message and generate an agent response.

    1. Assemble context from Neo4j based on the ChatScope
    2. Retrieve relevant knowledge via pgvector RAG
    3. Stream LLM response, publishing chunks to Kafka
    4. Persist the complete response to PostgreSQL
    """
    # 1. Context assembly
    context = await assemble_context(scope)

    # 2. RAG retrieval
    relevant_docs = await pgvector_search(content, scope)

    # 3. Stream LLM response
    agent_message_id = str(uuid4())
    await publish_event(EventType.CHAT_AGENT_TYPING, {
        "thread_id": thread_id,
        "is_typing": True,
    })

    full_response = ""
    async for chunk in llm_stream(content, context, relevant_docs):
        full_response += chunk
        await publish_to_kafka("agent.chat", {
            "event": "chat.message.chunk",
            "data": {
                "message_id": agent_message_id,
                "thread_id": thread_id,
                "chunk": chunk,
                "done": False,
            },
        })

    # Final chunk
    await publish_to_kafka("agent.chat", {
        "event": "chat.message.chunk",
        "data": {
            "message_id": agent_message_id,
            "thread_id": thread_id,
            "chunk": "",
            "done": True,
        },
    })

    # 4. Persist
    await persist_message(thread_id, agent_message_id, full_response)

    return {"status": "completed", "message_id": agent_message_id}

PostgreSQL Schema

-- Chat threads
CREATE TABLE chat_threads (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    channel_id      UUID NOT NULL,
    scope_kind      TEXT NOT NULL,
    scope_entity_id TEXT NOT NULL,
    title           TEXT NOT NULL,
    archived        BOOLEAN NOT NULL DEFAULT FALSE,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    last_message_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

    CONSTRAINT fk_channel FOREIGN KEY (channel_id)
        REFERENCES chat_channels(id) ON DELETE CASCADE
);

CREATE INDEX idx_threads_scope ON chat_threads(scope_kind, scope_entity_id);
CREATE INDEX idx_threads_channel ON chat_threads(channel_id);
CREATE INDEX idx_threads_last_message ON chat_threads(last_message_at DESC);

-- Chat messages
CREATE TABLE chat_messages (
    id              UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    thread_id       UUID NOT NULL,
    actor_id        TEXT NOT NULL,
    actor_kind      TEXT NOT NULL,
    content         TEXT NOT NULL,
    status          TEXT NOT NULL DEFAULT 'sent',
    graph_ref_node  TEXT,
    graph_ref_type  TEXT,
    graph_ref_label TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ,

    CONSTRAINT fk_thread FOREIGN KEY (thread_id)
        REFERENCES chat_threads(id) ON DELETE CASCADE
);

CREATE INDEX idx_messages_thread ON chat_messages(thread_id, created_at);
CREATE INDEX idx_messages_actor ON chat_messages(actor_id);

Component Specifications

ChatPanel — Base Composite

The primary chat UI component used by all integration points. Renders a message list, typing indicator, and message composer.

// src/components/chat/chat-panel.tsx
interface ChatPanelProps {
  /** The thread to display */
  thread: ChatThread;
  /** When true, renders in a compact layout for inline embedding */
  compact?: boolean;
}
Sub-component Description
Message list Scrollable area with ChatMessageBubble instances, auto-scrolls on new messages
TypingIndicator Shown when an agent is composing a response
ChatComposer Text input with send button at the bottom

ChatMessageBubble — Message Display

Renders a single message with actor-appropriate styling.

Actor Kind Alignment Background Avatar
user Right bg-primary text-primary-foreground User initial
agent Left bg-muted Agent code badge
system Center bg-muted/50 text-muted-foreground italic None

Markdown content is rendered using a lightweight Markdown renderer. Graph references render as clickable badges that navigate to the referenced entity.

TypingIndicator — Animated Dots

Three animated dots displayed when typingThreadIds in the chat store contains the current thread ID.

// src/components/chat/typing-indicator.tsx
export function TypingIndicator({ agentName }: { agentName: string }) {
  return (
    <div className="flex items-center gap-2 px-4 py-2 text-sm text-muted-foreground">
      <span>{agentName} is typing</span>
      <span className="flex gap-0.5">
        <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:0ms]" />
        <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:150ms]" />
        <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground animate-bounce [animation-delay:300ms]" />
      </span>
    </div>
  );
}

ChatSidebar — Sheet Overlay

See Dashboard Integration — Persistent Sidebar above. Uses shadcn/ui Sheet component, mounted in root-layout.tsx.

ChatComposer — Message Input

Textarea with send button. Supports Enter to send (with Shift+Enter for newlines).

// src/components/chat/chat-composer.tsx
interface ChatComposerProps {
  onSend: (content: string) => void;
  disabled?: boolean;
  placeholder?: string;
}

ApprovalChatPanel — Thin Wrapper

Wraps ChatPanel with approval-scoped thread resolution. See Approvals Page above.


Context Assembly

When the backend processes a chat message, it assembles context from Neo4j based on the ChatScope.kind. This context is included in the LLM prompt to ground the agent’s responses.

ChatScope.kind Neo4j Context Assembled
session Session traces, produced artifacts, recent graph mutations from this session
approval Proposed diffs, approval description, originating session context, constraint evaluation results
bom-entry Component properties (MPN, manufacturer, specs), alternate parts, supply chain risk score, graph neighbors (connected requirements, tests)
digital-twin-node Node properties, connected requirements (via TRACES_TO), simulation results, constraint status
project Recent sessions (last 10), gate readiness scores (EVT/DVT/PVT), open approvals count, unresolved constraint violations

The RAG layer (pgvector) supplements graph context with relevant passages from ingested knowledge documents (datasheets, standards, design guides).


Message Lifecycle

sequenceDiagram
    participant ENG as Engineer
    participant UI as Dashboard / IDE
    participant WS as WebSocket Gateway
    participant K as Kafka
    participant TMP as Temporal
    participant NEO as Neo4j
    participant LLM as LLM Provider
    participant PG as PostgreSQL

    ENG->>UI: Type message & send
    UI->>WS: WebSocket frame<br/>(chat.message.send)
    WS->>K: Publish to "agent.chat"
    WS->>PG: Persist user message
    WS-->>UI: chat.message.created<br/>(echo back)

    K->>TMP: Trigger handle_chat_message
    TMP->>NEO: Assemble context<br/>(scope-based query)
    NEO-->>TMP: Graph context
    TMP->>LLM: Stream prompt<br/>(context + message)

    loop Streaming chunks
        LLM-->>TMP: Token chunk
        TMP->>K: Publish chat.message.chunk
        K->>WS: Forward to subscribers
        WS-->>UI: chat.message.chunk
        UI->>UI: appendStreamChunk()
    end

    TMP->>PG: Persist agent response
    TMP->>K: Publish chat.message.created
    K->>WS: Forward
    WS-->>UI: chat.message.created<br/>(final message)

Component Hierarchy

graph TD
    RL["root-layout.tsx"] --> CS["ChatSidebar<br/>(Sheet overlay)"]
    CS --> CP1["ChatPanel"]

    AP["approvals-page"] --> ACP["ApprovalChatPanel"]
    ACP --> CP2["ChatPanel"]

    BP["bom-page"] --> CCP["ComponentChatPanel"]
    CCP --> CP3["ChatPanel"]

    DT["digital-twin-page"] --> NCP["NodeChatPanel"]
    NCP --> CP4["ChatPanel"]

    SP["sessions-page"] --> SCP["SessionChatPanel"]
    SCP --> CP5["ChatPanel"]

    CP1 --> MB["ChatMessageBubble"]
    CP1 --> TI["TypingIndicator"]
    CP1 --> CC["ChatComposer"]

    CP2 --> MB
    CP3 --> MB
    CP4 --> MB
    CP5 --> MB

    style CS fill:#4a6fa5,color:#fff
    style ACP fill:#e67e22,color:#fff
    style CCP fill:#27ae60,color:#fff
    style NCP fill:#8e44ad,color:#fff
    style SCP fill:#2c3e50,color:#fff

WebSocket Streaming Flow

flowchart LR
    LLM["LLM Provider"] -->|"token chunks"| TMP["Temporal Activity"]
    TMP -->|"chat.message.chunk"| K["Kafka<br/>agent.chat"]
    K -->|"WebSocket broadcast"| GW["Gateway<br/>WebSocket Server"]
    GW -->|"ws frame"| DASH["Dashboard<br/>chat-store.appendStreamChunk()"]
    GW -->|"ws frame"| IDE["IDE Extension<br/>webview update"]

    TMP -->|"chat.message.created<br/>(final)"| K
    K -->|"invalidation"| GW
    GW -->|"TanStack Query<br/>cache invalidation"| DASH

    style LLM fill:#e74c3c,color:#fff
    style K fill:#2c3e50,color:#fff
    style GW fill:#3498db,color:#fff
    style DASH fill:#27ae60,color:#fff
    style IDE fill:#8e44ad,color:#fff

Relationship to Existing Features

Feature Relationship
Assistant Mode Chat extends the assistant paradigm — instead of only surfacing notifications, engineers can now ask follow-up questions about constraint violations, drift reports, and ingest results
Approvals ApprovalChatPanel replaces the static ApprovalComment[] comments section, enabling real-time dialogue with the proposing agent before approving or rejecting
Sessions SessionChatPanel provides a conversational interface alongside the trace viewer, allowing engineers to ask agents about specific trace steps
AI Memory Chat message history feeds into the knowledge layer — agent responses and engineer feedback become searchable context for future conversations
Event Sourcing Chat events flow through the same Kafka infrastructure, using 4 new EventType values and the agent.chat topic
Constraint Engine When a constraint violation is surfaced via chat, the engineer can ask the agent to explain the violation, suggest fixes, or request a re-evaluation
IDE Assistants Chat shares the WebSocket protocol with IDE extensions, enabling the same conversational interface in VS Code and KiCad

Document Description
Event Sourcing Kafka topics, EventType enum, and event pipeline — defines the agent.chat topic and 4 new event types used by chat
Assistant Mode Dual-mode operation architecture — chat extends the assistant paradigm with bidirectional dialogue
Type Definitions Dashboard type conventions — chat types follow the same patterns
Custom Hooks TanStack Query hook patterns — chat hooks follow use-approvals.ts as model
State Management Zustand store patterns — chat-store follows existing conventions
API Client & Endpoints Axios client pattern — chat endpoints follow the same structure
Layout Components Root layout and topbar — ChatSidebar is mounted in root layout, MessageSquare replaces Bell in topbar
Approvals Workflow Page Approval page — ApprovalChatPanel replaces static comments section

Document Version: v1.0 Last Updated: 2026-03-03 Status: Technical Specification